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,431 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-state-rollup — the boot rollup made mechanical (issue #22). Ports
|
|
3
|
+
# State Rollup 1.0 (index.yml ← state/milestone-*.yml) and the Stream Rollup
|
|
4
|
+
# (active.yml ← streams/*.yml ⋈ active milestones, ADR-015 addendum) from
|
|
5
|
+
# advisory skill prose into an executable, so a boot regenerates the derived
|
|
6
|
+
# registries deterministically instead of trusting an agent to run the prose
|
|
7
|
+
# (which a "the index is stale, work around it" project-memory habit can
|
|
8
|
+
# out-compete — the exact ptnrkit failure #22 was filed against).
|
|
9
|
+
#
|
|
10
|
+
# forge-state-rollup.sh [--project <path>] [--quiet]
|
|
11
|
+
# (SessionStart hook) reads {cwd, hook_event_name, source} from stdin JSON.
|
|
12
|
+
#
|
|
13
|
+
# --project <path> operate on the repo containing <path>. Default: the
|
|
14
|
+
# SessionStart payload's `cwd`, else the CWD. NEVER
|
|
15
|
+
# $CLAUDE_PROJECT_DIR — that resolves to the repo ROOT
|
|
16
|
+
# (main checkout) even in a worktree, which would
|
|
17
|
+
# render this worktree's caches into the wrong tree.
|
|
18
|
+
# --quiet suppress the stdout summary line (still writes caches).
|
|
19
|
+
#
|
|
20
|
+
# What it writes (both are git-ignored render-on-read caches since 0.53.0):
|
|
21
|
+
# .forge/state/index.yml — derived milestone registry
|
|
22
|
+
# .forge/streams/active.yml — derived stream registry (if streams/ exists)
|
|
23
|
+
# It NEVER commits them and NEVER runs git add/commit. Rendering a gitignored
|
|
24
|
+
# cache is a local write that never lands on main, so any checkout (main OR
|
|
25
|
+
# worktree) may render its own copy (FORGE.md → State Ownership, Derived class).
|
|
26
|
+
#
|
|
27
|
+
# The ONE write that is committed-source and therefore main-checkout-only is
|
|
28
|
+
# the Stream Rollup's step-0 completed-stream auto-close sweep (it edits
|
|
29
|
+
# streams/{stream}.yml). In a linked worktree that step is skipped; the caches
|
|
30
|
+
# still render. Detection: `git rev-parse --git-dir` == `--git-common-dir`.
|
|
31
|
+
#
|
|
32
|
+
# release_state is NOT re-derived here — the fold binary
|
|
33
|
+
# (forge-release-fold.sh) is its only source (FORGE.md → derived release_state).
|
|
34
|
+
# Fold missing / exit 2 for a unit → that row gets release_state: unknown and
|
|
35
|
+
# one advisory stderr line; a broken fold must never break boot.
|
|
36
|
+
#
|
|
37
|
+
# Contract (SessionStart, verified against Claude Code hooks docs):
|
|
38
|
+
# - Exit 0 ALWAYS. SessionStart is non-blocking; this hook must never abort
|
|
39
|
+
# a session. Every failure degrades to a no-op + an advisory stderr line.
|
|
40
|
+
# - stdout is injected into the model's context. We emit ONE terse summary
|
|
41
|
+
# line (unless --quiet) so the boot session sees the caches were freshly
|
|
42
|
+
# rendered by machinery — it then READS them, never regenerates by hand.
|
|
43
|
+
# - stderr carries advisories/diagnostics (shown in transcript, not context).
|
|
44
|
+
#
|
|
45
|
+
# Portable: POSIX sh + git + awk/sed (mirrors forge-release-fold.sh). jq is
|
|
46
|
+
# used only to parse the SessionStart stdin JSON and degrades to a no-stdin
|
|
47
|
+
# path when absent. No network calls (NFR-030).
|
|
48
|
+
|
|
49
|
+
set -u
|
|
50
|
+
|
|
51
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
52
|
+
FOLD="$SCRIPT_DIR/forge-release-fold.sh"
|
|
53
|
+
|
|
54
|
+
# --- argument / stdin parsing ------------------------------------------------
|
|
55
|
+
|
|
56
|
+
project=""
|
|
57
|
+
quiet=0
|
|
58
|
+
|
|
59
|
+
while [ $# -gt 0 ]; do
|
|
60
|
+
case "$1" in
|
|
61
|
+
--project) shift; project="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
62
|
+
--project=*) project="${1#*=}"; shift ;;
|
|
63
|
+
--quiet) quiet=1; shift ;;
|
|
64
|
+
-*) shift ;; # unknown flag — ignore, never fail a boot hook
|
|
65
|
+
*) shift ;;
|
|
66
|
+
esac
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
# SessionStart delivers {cwd, source, hook_event_name, ...} on stdin. When no
|
|
70
|
+
# --project was given, prefer the payload's cwd (the actual worktree the
|
|
71
|
+
# session started in) over the process CWD. Read stdin only if it is not a tty
|
|
72
|
+
# and jq is present; never block waiting for input that will not arrive.
|
|
73
|
+
stdin_cwd=""
|
|
74
|
+
if [ -z "$project" ] && [ ! -t 0 ] && command -v jq >/dev/null 2>&1; then
|
|
75
|
+
payload="$(cat 2>/dev/null || true)"
|
|
76
|
+
if [ -n "$payload" ]; then
|
|
77
|
+
stdin_cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)"
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
[ -z "$project" ] && project="${stdin_cwd:-$PWD}"
|
|
81
|
+
|
|
82
|
+
# --- repo resolution ---------------------------------------------------------
|
|
83
|
+
# Resolve from `project` (cwd), NOT $CLAUDE_PROJECT_DIR. Silent no-op when not
|
|
84
|
+
# in a git repo or the repo has no .forge/ — the common case for a non-Forge
|
|
85
|
+
# session, and the hook must be invisible there.
|
|
86
|
+
|
|
87
|
+
root="$(git -C "$project" rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
|
88
|
+
[ -d "$root/.forge" ] || exit 0
|
|
89
|
+
[ -d "$root/.forge/state" ] || exit 0
|
|
90
|
+
|
|
91
|
+
# Main checkout vs linked worktree: --git-dir == --git-common-dir → main.
|
|
92
|
+
this_gitdir="$(git -C "$root" rev-parse --git-dir 2>/dev/null || true)"
|
|
93
|
+
common_gitdir="$(git -C "$root" rev-parse --git-common-dir 2>/dev/null || true)"
|
|
94
|
+
in_main_checkout=0
|
|
95
|
+
[ -n "$this_gitdir" ] && [ "$this_gitdir" = "$common_gitdir" ] && in_main_checkout=1
|
|
96
|
+
|
|
97
|
+
# --- YAML helpers (ported from forge-release-fold.sh, identical contract) -----
|
|
98
|
+
|
|
99
|
+
# yaml_get <content> <block> <key> — scalar value of a 2-space-indented key
|
|
100
|
+
# inside a top-level block. Strips trailing `# comments`. Prints raw value.
|
|
101
|
+
yaml_get() {
|
|
102
|
+
printf '%s\n' "$1" | awk -v blk="$2" -v key="$3" '
|
|
103
|
+
/^[^ \t]/ { inblk = (index($0, blk ":") == 1) }
|
|
104
|
+
inblk && index($0, " " key ":") == 1 {
|
|
105
|
+
val = substr($0, length(key) + 4)
|
|
106
|
+
sub(/[ \t]#.*$/, "", val)
|
|
107
|
+
if (val ~ /^[ \t]*#/) val = ""
|
|
108
|
+
gsub(/^[ \t]+/, "", val); gsub(/[ \t]+$/, "", val)
|
|
109
|
+
print val
|
|
110
|
+
exit
|
|
111
|
+
}
|
|
112
|
+
'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
unquote() {
|
|
116
|
+
uq="$1"
|
|
117
|
+
uq="${uq#\"}"; uq="${uq%\"}"
|
|
118
|
+
uq="${uq#"'"}"; uq="${uq%"'"}"
|
|
119
|
+
printf '%s\n' "$uq"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
yaml_scalar() { unquote "$(yaml_get "$1" "$2" "$3")"; }
|
|
123
|
+
|
|
124
|
+
# is_nullish <val> — true when empty, "null", or "~".
|
|
125
|
+
is_nullish() {
|
|
126
|
+
case "$1" in ''|null|'~') return 0 ;; *) return 1 ;; esac
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# --- milestone Rollup 1.0 ----------------------------------------------------
|
|
130
|
+
# Deterministic + idempotent: output is a pure function of the milestone files,
|
|
131
|
+
# so two sessions rendering it produce identical bytes.
|
|
132
|
+
|
|
133
|
+
fold_unavailable=0 # count of rows that fell back to release_state: unknown
|
|
134
|
+
|
|
135
|
+
render_index() {
|
|
136
|
+
index_path="$root/.forge/state/index.yml"
|
|
137
|
+
rows="" # accumulated "id\tname\tstatus\tlast_updated\trelease_state" lines
|
|
138
|
+
n=0
|
|
139
|
+
|
|
140
|
+
for mfile in "$root"/.forge/state/milestone-*.yml; do
|
|
141
|
+
[ -f "$mfile" ] || continue
|
|
142
|
+
content="$(cat "$mfile")"
|
|
143
|
+
|
|
144
|
+
m_id="$(yaml_scalar "$content" milestone id)"
|
|
145
|
+
[ -z "$m_id" ] && continue # a file with no milestone.id is not a registry row
|
|
146
|
+
m_name="$(yaml_scalar "$content" milestone name)"
|
|
147
|
+
c_status="$(yaml_scalar "$content" current status)"
|
|
148
|
+
|
|
149
|
+
# last_updated: current.last_updated → legacy progress.last_update → null.
|
|
150
|
+
last_updated="$(yaml_scalar "$content" current last_updated)"
|
|
151
|
+
if is_nullish "$last_updated"; then
|
|
152
|
+
last_updated="$(yaml_scalar "$content" progress last_update)"
|
|
153
|
+
fi
|
|
154
|
+
is_nullish "$last_updated" && last_updated="null"
|
|
155
|
+
|
|
156
|
+
deferred_at="$(yaml_scalar "$content" lifecycle deferred_at)"
|
|
157
|
+
resumed_at="$(yaml_scalar "$content" lifecycle resumed_at)"
|
|
158
|
+
|
|
159
|
+
# Derive registry status (order matters — deferred wins unless resumed later).
|
|
160
|
+
reg_status="active"
|
|
161
|
+
if [ "$c_status" = "complete" ]; then
|
|
162
|
+
reg_status="complete"
|
|
163
|
+
elif [ "$c_status" = "not_started" ]; then
|
|
164
|
+
reg_status="not_started"
|
|
165
|
+
fi
|
|
166
|
+
if ! is_nullish "$deferred_at"; then
|
|
167
|
+
# deferred unless a later resumed_at supersedes it (string compare works
|
|
168
|
+
# for ISO 8601 / date strings; a resumed_at >= deferred_at un-defers).
|
|
169
|
+
if is_nullish "$resumed_at" || [ "$resumed_at" \< "$deferred_at" ]; then
|
|
170
|
+
reg_status="deferred"
|
|
171
|
+
fi
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
# release_state — the fold is the ONLY source. Degrade to unknown, never
|
|
175
|
+
# re-derive. Fold unit id is the milestone id normalized to m-{N}.
|
|
176
|
+
fold_unit="$m_id"
|
|
177
|
+
case "$fold_unit" in m-*) ;; *) fold_unit="m-$m_id" ;; esac
|
|
178
|
+
rel="unknown"
|
|
179
|
+
if [ -x "$FOLD" ]; then
|
|
180
|
+
fold_out="$("$FOLD" "$fold_unit" --project "$root" 2>/dev/null)"; fold_rc=$?
|
|
181
|
+
if [ "$fold_rc" -eq 0 ]; then
|
|
182
|
+
rel="$(printf '%s\n' "$fold_out" | sed -n 's/^release_state=\([a-z]*\).*/\1/p' | head -1)"
|
|
183
|
+
[ -z "$rel" ] && rel="unknown"
|
|
184
|
+
fi
|
|
185
|
+
fi
|
|
186
|
+
if [ "$rel" = "unknown" ]; then
|
|
187
|
+
fold_unavailable=$((fold_unavailable + 1))
|
|
188
|
+
printf 'forge-state-rollup: release fold unavailable for %s — release_state: unknown\n' "$fold_unit" >&2
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
rows="$rows$m_id $m_name $reg_status $last_updated $rel
|
|
192
|
+
"
|
|
193
|
+
n=$((n + 1))
|
|
194
|
+
done
|
|
195
|
+
|
|
196
|
+
# Sort rows by id (stable, deterministic). Then emit YAML.
|
|
197
|
+
sorted="$(printf '%s' "$rows" | LC_ALL=C sort -t' ' -k1,1)"
|
|
198
|
+
|
|
199
|
+
{
|
|
200
|
+
printf '# Forge Global State — Cross-Milestone Index (DERIVED — do not hand-edit)\n'
|
|
201
|
+
printf '# Regenerated at boot by .claude/hooks/forge-state-rollup.sh from\n'
|
|
202
|
+
printf '# state/milestone-*.yml (+ per-row release_state from forge-release-fold.sh).\n'
|
|
203
|
+
printf '# Git-ignored render-on-read cache (0.53.0) — never committed.\n'
|
|
204
|
+
printf 'milestones:\n'
|
|
205
|
+
if [ "$n" -eq 0 ]; then
|
|
206
|
+
printf ' []\n'
|
|
207
|
+
else
|
|
208
|
+
printf '%s\n' "$sorted" | while IFS=' ' read -r r_id r_name r_status r_lu r_rel; do
|
|
209
|
+
[ -z "$r_id" ] && continue
|
|
210
|
+
printf ' - id: %s\n' "$r_id"
|
|
211
|
+
printf ' name: %s\n' "$(yaml_quote "$r_name")"
|
|
212
|
+
printf ' status: %s\n' "$r_status"
|
|
213
|
+
if [ "$r_lu" = "null" ]; then
|
|
214
|
+
printf ' last_updated: null\n'
|
|
215
|
+
else
|
|
216
|
+
printf ' last_updated: %s\n' "$(yaml_quote "$r_lu")"
|
|
217
|
+
fi
|
|
218
|
+
printf ' release_state: %s\n' "$r_rel"
|
|
219
|
+
done
|
|
220
|
+
fi
|
|
221
|
+
} > "$index_path.tmp.$$" && mv -f "$index_path.tmp.$$" "$index_path"
|
|
222
|
+
|
|
223
|
+
INDEX_ROW_COUNT="$n"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# yaml_quote <val> — always double-quote a scalar for the rendered cache,
|
|
227
|
+
# escaping embedded backslashes and double quotes. Keeps names with `:`/`#`/
|
|
228
|
+
# unicode safe. Empty → "".
|
|
229
|
+
yaml_quote() {
|
|
230
|
+
qq="$1"
|
|
231
|
+
qq="$(printf '%s' "$qq" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
232
|
+
printf '"%s"' "$qq"
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# --- Stream Rollup (ADR-015 addendum) ----------------------------------------
|
|
236
|
+
|
|
237
|
+
# norm_id <id> — canonical unit key: strip a single leading `m-` so a stream's
|
|
238
|
+
# `milestone: "m-27"` and a numeric milestone file id `27` compare equal (a
|
|
239
|
+
# real repo mixes both spellings). A labeled id (m-AUTO01) has no numeric twin,
|
|
240
|
+
# so stripping is harmless there — the key is just used for equality.
|
|
241
|
+
norm_id() { printf '%s' "${1#m-}"; }
|
|
242
|
+
|
|
243
|
+
# derived_milestone_status <id> — read the just-rendered index.yml row's status
|
|
244
|
+
# for a milestone id, matched on the normalized key. Empty when no index row.
|
|
245
|
+
derived_milestone_status() {
|
|
246
|
+
awk -v want="$(norm_id "$1")" '
|
|
247
|
+
/^ - id:/ { id=$3; gsub(/["\x27]/,"",id); sub(/^m-/,"",id) }
|
|
248
|
+
/^ status:/ { if (id==want) { print $2; exit } }
|
|
249
|
+
' "$root/.forge/state/index.yml" 2>/dev/null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# stream_field <file> <block> <key> — thin wrapper over yaml_scalar on a file.
|
|
253
|
+
stream_field() { yaml_scalar "$(cat "$1")" "$2" "$3"; }
|
|
254
|
+
|
|
255
|
+
# worktree_is_live <path> — true when <path> resolves to a registered live
|
|
256
|
+
# git worktree of this repo. Empty/nullish path → not live.
|
|
257
|
+
worktree_is_live() {
|
|
258
|
+
wt="$1"
|
|
259
|
+
is_nullish "$wt" && return 1
|
|
260
|
+
git -C "$root" worktree list --porcelain 2>/dev/null \
|
|
261
|
+
| awk -v p="$wt" '/^worktree / { if ($2 == p) { found=1 } } END { exit found?0:1 }'
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
SWEEP_CLOSED="" # ids auto-closed this run (for the summary)
|
|
265
|
+
|
|
266
|
+
# step 0 — completed-stream auto-close sweep. Writes stream files (committed
|
|
267
|
+
# source) → MAIN CHECKOUT ONLY. A milestone-backed stream whose milestone is
|
|
268
|
+
# `complete`, that is not already `closed`, with no live worktree → auto-close.
|
|
269
|
+
stream_autoclose_sweep() {
|
|
270
|
+
[ "$in_main_checkout" -eq 1 ] || return 0
|
|
271
|
+
for sfile in "$root"/.forge/streams/*.yml; do
|
|
272
|
+
[ -f "$sfile" ] || continue
|
|
273
|
+
case "$(basename "$sfile")" in active.yml) continue ;; esac
|
|
274
|
+
s_status="$(stream_field "$sfile" stream status)"
|
|
275
|
+
[ "$s_status" = "closed" ] && continue
|
|
276
|
+
s_ms="$(stream_field "$sfile" stream milestone)"
|
|
277
|
+
is_nullish "$s_ms" && continue
|
|
278
|
+
m_status="$(derived_milestone_status "$s_ms")"
|
|
279
|
+
[ "$m_status" = "complete" ] || continue
|
|
280
|
+
# Guard: a deferred milestone is never swept (handled above by status
|
|
281
|
+
# check). A complete milestone WITH a live worktree is left for the
|
|
282
|
+
# forge-boot finalize path — do not sweep it here.
|
|
283
|
+
s_wt="$(stream_field "$sfile" runtime worktree)"
|
|
284
|
+
if worktree_is_live "$s_wt"; then continue; fi
|
|
285
|
+
|
|
286
|
+
# Auto-close: status → closed, merge.readiness → merged, append a note.
|
|
287
|
+
# Edit in place with awk (2-space-indented keys under their blocks).
|
|
288
|
+
awk '
|
|
289
|
+
/^[^ \t]/ { blk=$0; sub(/:.*/,"",blk) }
|
|
290
|
+
blk=="stream" && /^ status:/ { print " status: closed # auto-closed: milestone complete + on main (rollup sweep)"; next }
|
|
291
|
+
blk=="merge" && /^ readiness:/ { print " readiness: merged # auto-closed by rollup sweep"; next }
|
|
292
|
+
{ print }
|
|
293
|
+
' "$sfile" > "$sfile.tmp.$$" && mv -f "$sfile.tmp.$$" "$sfile"
|
|
294
|
+
SWEEP_CLOSED="$SWEEP_CLOSED $s_ms"
|
|
295
|
+
printf 'forge-state-rollup: auto-closed stale stream %s — milestone complete, already on main\n' "$s_ms" >&2
|
|
296
|
+
done
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
STREAM_ROW_COUNT=0
|
|
300
|
+
|
|
301
|
+
render_active() {
|
|
302
|
+
[ -d "$root/.forge/streams" ] || return 0
|
|
303
|
+
active_path="$root/.forge/streams/active.yml"
|
|
304
|
+
|
|
305
|
+
stream_rows="" # "id\tcoordination\tphase\tmilestone\tmerge_readiness"
|
|
306
|
+
merge_rows="" # stream ids with merge.readiness: ready
|
|
307
|
+
seen_ms=" " # milestones covered by a stream file (for implicit rows)
|
|
308
|
+
sn=0
|
|
309
|
+
|
|
310
|
+
for sfile in "$root"/.forge/streams/*.yml; do
|
|
311
|
+
[ -f "$sfile" ] || continue
|
|
312
|
+
case "$(basename "$sfile")" in active.yml) continue ;; esac
|
|
313
|
+
s_id="$(stream_field "$sfile" stream id)"
|
|
314
|
+
[ -z "$s_id" ] && s_id="$(basename "$sfile" .yml)"
|
|
315
|
+
s_coord="$(stream_field "$sfile" stream status)"
|
|
316
|
+
[ -z "$s_coord" ] && s_coord="unknown"
|
|
317
|
+
s_ms="$(stream_field "$sfile" stream milestone)"
|
|
318
|
+
s_merge="$(stream_field "$sfile" merge readiness)"
|
|
319
|
+
[ -z "$s_merge" ] && s_merge="not_ready"
|
|
320
|
+
|
|
321
|
+
# phase from the milestone (backed streams only) — never stored in stream.
|
|
322
|
+
s_phase="-"
|
|
323
|
+
if ! is_nullish "$s_ms"; then
|
|
324
|
+
seen_ms="$seen_ms$(norm_id "$s_ms") " # normalized key: m-27 and 27 collapse
|
|
325
|
+
mp="$root/.forge/state/milestone-${s_ms#m-}.yml"
|
|
326
|
+
[ -f "$mp" ] || mp="$root/.forge/state/milestone-${s_ms}.yml"
|
|
327
|
+
if [ -f "$mp" ]; then
|
|
328
|
+
s_phase="$(yaml_scalar "$(cat "$mp")" current status)"
|
|
329
|
+
[ -z "$s_phase" ] && s_phase="-"
|
|
330
|
+
fi
|
|
331
|
+
else
|
|
332
|
+
s_ms="null"
|
|
333
|
+
fi
|
|
334
|
+
|
|
335
|
+
stream_rows="$stream_rows$s_id $s_coord $s_phase $s_ms $s_merge
|
|
336
|
+
"
|
|
337
|
+
[ "$s_merge" = "ready" ] && merge_rows="$merge_rows$s_id
|
|
338
|
+
"
|
|
339
|
+
sn=$((sn + 1))
|
|
340
|
+
done
|
|
341
|
+
|
|
342
|
+
# implicit rows: every ACTIVE milestone (from the fresh index.yml) with no
|
|
343
|
+
# stream file → a coverage row so it can never be silently missing.
|
|
344
|
+
if [ -f "$root/.forge/state/index.yml" ]; then
|
|
345
|
+
active_ms="$(awk '
|
|
346
|
+
/^ - id:/ { id=$3; gsub(/["\x27]/,"",id) }
|
|
347
|
+
/^ status:/ { if ($2=="active") print id }
|
|
348
|
+
' "$root/.forge/state/index.yml" 2>/dev/null)"
|
|
349
|
+
for am in $active_ms; do
|
|
350
|
+
case "$seen_ms" in *" $(norm_id "$am") "*) continue ;; esac
|
|
351
|
+
mp="$root/.forge/state/milestone-${am#m-}.yml"
|
|
352
|
+
[ -f "$mp" ] || mp="$root/.forge/state/milestone-${am}.yml"
|
|
353
|
+
ph="-"
|
|
354
|
+
[ -f "$mp" ] && ph="$(yaml_scalar "$(cat "$mp")" current status)"
|
|
355
|
+
[ -z "$ph" ] && ph="-"
|
|
356
|
+
stream_rows="$stream_rows$am implicit $ph $am not_ready
|
|
357
|
+
"
|
|
358
|
+
sn=$((sn + 1))
|
|
359
|
+
done
|
|
360
|
+
fi
|
|
361
|
+
|
|
362
|
+
sorted="$(printf '%s' "$stream_rows" | LC_ALL=C sort -t' ' -k1,1)"
|
|
363
|
+
msorted="$(printf '%s' "$merge_rows" | LC_ALL=C sort)"
|
|
364
|
+
|
|
365
|
+
{
|
|
366
|
+
printf '# Forge Stream Registry — active work (DERIVED — do not hand-edit)\n'
|
|
367
|
+
printf '# Regenerated at boot by .claude/hooks/forge-state-rollup.sh: streams/*.yml\n'
|
|
368
|
+
printf '# joined with active milestones (ADR-015). Git-ignored cache — never committed.\n'
|
|
369
|
+
printf 'version: 1\n'
|
|
370
|
+
printf 'streams:\n'
|
|
371
|
+
if [ "$sn" -eq 0 ]; then
|
|
372
|
+
printf ' []\n'
|
|
373
|
+
else
|
|
374
|
+
printf '%s\n' "$sorted" | while IFS=' ' read -r r_id r_coord r_phase r_ms r_merge; do
|
|
375
|
+
[ -z "$r_id" ] && continue
|
|
376
|
+
printf ' - id: %s\n' "$(yaml_quote "$r_id")"
|
|
377
|
+
printf ' coordination: %s\n' "$r_coord"
|
|
378
|
+
printf ' phase: %s\n' "$r_phase"
|
|
379
|
+
if [ "$r_ms" = "null" ]; then
|
|
380
|
+
printf ' milestone: null\n'
|
|
381
|
+
else
|
|
382
|
+
printf ' milestone: %s\n' "$(yaml_quote "$r_ms")"
|
|
383
|
+
fi
|
|
384
|
+
printf ' merge_readiness: %s\n' "$r_merge"
|
|
385
|
+
done
|
|
386
|
+
fi
|
|
387
|
+
printf 'merge_queue:\n'
|
|
388
|
+
if [ -z "$msorted" ]; then
|
|
389
|
+
printf ' []\n'
|
|
390
|
+
else
|
|
391
|
+
printf '%s\n' "$msorted" | while IFS= read -r mq; do
|
|
392
|
+
[ -z "$mq" ] && continue
|
|
393
|
+
printf ' - %s\n' "$(yaml_quote "$mq")"
|
|
394
|
+
done
|
|
395
|
+
fi
|
|
396
|
+
} > "$active_path.tmp.$$" && mv -f "$active_path.tmp.$$" "$active_path"
|
|
397
|
+
|
|
398
|
+
STREAM_ROW_COUNT="$sn"
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
# --- run ---------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
render_index # index.yml first — stream rollup reads it
|
|
404
|
+
|
|
405
|
+
if [ -d "$root/.forge/streams" ]; then
|
|
406
|
+
stream_autoclose_sweep # step 0 (main checkout only)
|
|
407
|
+
render_active # then the join
|
|
408
|
+
fi
|
|
409
|
+
|
|
410
|
+
# --- summary (stdout → injected into boot context) ---------------------------
|
|
411
|
+
|
|
412
|
+
if [ "$quiet" -eq 0 ]; then
|
|
413
|
+
summary="[forge-state-rollup] rendered index.yml (${INDEX_ROW_COUNT:-0} milestone"
|
|
414
|
+
[ "${INDEX_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
|
|
415
|
+
summary="$summary)"
|
|
416
|
+
if [ -d "$root/.forge/streams" ]; then
|
|
417
|
+
summary="$summary + active.yml (${STREAM_ROW_COUNT:-0} stream row"
|
|
418
|
+
[ "${STREAM_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
|
|
419
|
+
summary="$summary)"
|
|
420
|
+
fi
|
|
421
|
+
[ -n "$SWEEP_CLOSED" ] && summary="$summary; auto-closed:${SWEEP_CLOSED}"
|
|
422
|
+
[ "${fold_unavailable:-0}" -gt 0 ] && summary="$summary; ${fold_unavailable} release_state unknown (fold unavailable)"
|
|
423
|
+
if [ "$in_main_checkout" -eq 1 ]; then
|
|
424
|
+
summary="$summary. Registries are fresh — READ them, do not regenerate by hand."
|
|
425
|
+
else
|
|
426
|
+
summary="$summary (worktree: caches rendered locally; auto-close sweep skipped). READ the caches, do not regenerate by hand."
|
|
427
|
+
fi
|
|
428
|
+
printf '%s\n' "$summary"
|
|
429
|
+
fi
|
|
430
|
+
|
|
431
|
+
exit 0
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Weld the primary checkout to the default branch — deny branch create/switch there.
|
|
3
|
+
#
|
|
4
|
+
# PreToolUse(Bash) hook. Exit 2 (+ JSON permissionDecision:deny) = block. Exit 0 = allow.
|
|
5
|
+
#
|
|
6
|
+
# WHY: the primary checkout's HEAD is shared mutable state across every concurrent
|
|
7
|
+
# Claude session running in that directory. One session running `git checkout -b`
|
|
8
|
+
# silently redirects every OTHER session's subsequent commits onto its branch — an
|
|
9
|
+
# invisible clobber that surfaces later as wrong-branch commits, contaminated PRs,
|
|
10
|
+
# stranded milestone state, or dropped history (issue #21: 3 confirmed incidents —
|
|
11
|
+
# m85's 12-commit drop, m62's stranded phase commits, a Quick-tier checkout -b that
|
|
12
|
+
# contaminated a concurrent PR). Branch work belongs in worktrees, which each own
|
|
13
|
+
# their own HEAD.
|
|
14
|
+
#
|
|
15
|
+
# Scope: fires ONLY in the primary checkout (git-dir == git-common-dir). Linked
|
|
16
|
+
# worktrees are unaffected (their git-dir differs). Unlike the dormant
|
|
17
|
+
# forge-branch-guard.sh this supersedes, the branch weld is UNCONDITIONAL — it fires
|
|
18
|
+
# even when no sibling worktree exists yet, because the second session's worktree is
|
|
19
|
+
# typically created AFTER the offending `checkout -b`. This hook also folds in the
|
|
20
|
+
# one distinct protection the old guard provided: denying a hard-reset in the primary
|
|
21
|
+
# checkout while sibling worktrees are branched off it (the history-rewrite class).
|
|
22
|
+
#
|
|
23
|
+
# Default branch is resolved dynamically (main/master + init.defaultBranch), never
|
|
24
|
+
# hardcoded — Forge ships to repos on any default branch.
|
|
25
|
+
#
|
|
26
|
+
# Fails OPEN (exit 0 / allow) on any malfunction (no jq, empty command, non-git cwd,
|
|
27
|
+
# git probe error) — a guard must never wedge the agent on its own failure.
|
|
28
|
+
#
|
|
29
|
+
# Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
emit_deny() {
|
|
34
|
+
local reason="${1//\"/\\\"}"
|
|
35
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
|
|
36
|
+
exit 2
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
|
|
40
|
+
|
|
41
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
42
|
+
|
|
43
|
+
INPUT=$(cat)
|
|
44
|
+
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
|
|
45
|
+
[ -z "$COMMAND" ] && exit 0
|
|
46
|
+
|
|
47
|
+
# Normalize the cheapest whitespace-substitution evasion (`git checkout$IFS-b`) to a
|
|
48
|
+
# real space before matching. A string matcher on an un-expanded shell command can
|
|
49
|
+
# never be complete against expansion — the pre-commit backstop is the sound layer —
|
|
50
|
+
# but closing the trivial $IFS trick keeps the PreToolUse layer honest.
|
|
51
|
+
COMMAND=$(printf '%s' "$COMMAND" | sed -E 's/\$\{?IFS\}?/ /g')
|
|
52
|
+
|
|
53
|
+
# Git allows global options BEFORE the subcommand: `git -c k=v checkout`,
|
|
54
|
+
# `git --no-pager switch`, `git -C <path> -p checkout`, etc. The guard must tolerate
|
|
55
|
+
# ANY run of them, not just a single `-C <path>` — otherwise `git -c x=y checkout -b`
|
|
56
|
+
# sails past (issue #21 regression). GITPRE matches `git` + zero-or-more global options
|
|
57
|
+
# (a `-x`/`--long`/`--long=val`/`-C <path>`/`-c <kv>` token, each optionally taking the
|
|
58
|
+
# next word as its value) up to the subcommand.
|
|
59
|
+
GITPRE='git[[:space:]]+((-[A-Za-z]|--[A-Za-z-]+)(=[^[:space:]]+)?[[:space:]]+([^-][^[:space:]]*[[:space:]]+)?)*'
|
|
60
|
+
|
|
61
|
+
# Fast path: only inspect commands that touch checkout/switch/reset at all. Anything
|
|
62
|
+
# else exits immediately WITHOUT any git probe (keeps the hook cheap on every Bash call).
|
|
63
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch|reset)([[:space:]]|\$)" || exit 0
|
|
64
|
+
|
|
65
|
+
# Resolve which repo dir the command targets: honor `git -C <path>`, else the hook cwd.
|
|
66
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
|
|
67
|
+
[ -z "$CWD" ] && CWD=$(pwd)
|
|
68
|
+
# Honor a GLOBAL `git -C <path>` (before the subcommand) only. Isolate the pre-subcommand
|
|
69
|
+
# segment first so `switch -C`/`checkout -C` (create-force, AFTER the subcommand) is never
|
|
70
|
+
# mistaken for a target-dir option.
|
|
71
|
+
PRE=$(printf '%s' "$COMMAND" | sed -E 's/[[:space:]](checkout|switch|reset)[[:space:]].*$//')
|
|
72
|
+
GC_PATH=$(printf '%s' "$PRE" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' | head -1 | awk '{print $2}' || true)
|
|
73
|
+
# A flag-shaped -C value (e.g. `-C --foo`) is not a real target dir → ignore it.
|
|
74
|
+
case "$GC_PATH" in -*) GC_PATH="" ;; esac
|
|
75
|
+
TARGET_DIR="${GC_PATH:-$CWD}"
|
|
76
|
+
|
|
77
|
+
GIT_DIR=$(git -C "$TARGET_DIR" rev-parse --git-dir 2>/dev/null) || exit 0
|
|
78
|
+
COMMON_DIR=$(git -C "$TARGET_DIR" rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
79
|
+
GIT_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
|
|
80
|
+
COMMON_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
|
|
81
|
+
|
|
82
|
+
# In a linked worktree git-dir != git-common-dir → branch ops are fine there.
|
|
83
|
+
[ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
|
|
84
|
+
|
|
85
|
+
# ── Default-branch set (dynamic — mirrors block-dangerous-commands.sh) ───────────
|
|
86
|
+
# Seed main+master, add the configured init.defaultBranch. A switch/checkout TO any
|
|
87
|
+
# of these names is "returning home" and always allowed.
|
|
88
|
+
DEFAULT_BRANCHES="main master"
|
|
89
|
+
GIT_DEFAULT=$(git -C "$TARGET_DIR" config --get init.defaultBranch 2>/dev/null || true)
|
|
90
|
+
[ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
|
|
91
|
+
is_default_branch() {
|
|
92
|
+
for b in $DEFAULT_BRANCHES; do [ "$1" = "$b" ] && return 0; done
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
DENY_MSG="Blocked: the primary checkout's HEAD stays on the default branch — branch create/switch here silently redirects every other concurrent session's commits onto your branch (recurring clobber, issue #21). Do branch work in a worktree: bash .forge/bin/new-worktree.sh <slug> (or git worktree add). To operate on an existing worktree from here, use git -C <worktree-path>. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
97
|
+
|
|
98
|
+
# ── 1) Branch creation / orphan / detach / track: always deny in primary checkout ──
|
|
99
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}checkout[[:space:]]+((-[a-zA-Z]*[bB][a-zA-Z]*|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
100
|
+
emit_deny "$DENY_MSG"
|
|
101
|
+
fi
|
|
102
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}switch[[:space:]]+((-[cC]|--create|--force-create|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
103
|
+
emit_deny "$DENY_MSG"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# ── 2) hard/keep/merge reset in the primary checkout while worktrees are branched ──
|
|
107
|
+
# Folded from the superseded forge-branch-guard.sh: a reset that rewrites HEAD +
|
|
108
|
+
# working tree here can drop verified work committed under sibling worktrees. Gated
|
|
109
|
+
# on >1 worktree (unlike the branch weld, which is unconditional).
|
|
110
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}reset[[:space:]]+(--hard|--keep|--merge)([[:space:]]|\$)"; then
|
|
111
|
+
wt_count=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l | tr -d ' ')
|
|
112
|
+
if [ "${wt_count:-1}" -gt 1 ]; then
|
|
113
|
+
emit_deny "Refusing to move the shared primary HEAD via reset — $wt_count worktrees are branched off it (the 'burned twice' history-rewrite failure, issue #21). Reset inside a worktree, or stop the other sessions first. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
114
|
+
fi
|
|
115
|
+
exit 0 # single-worktree hard-reset is the operator's own business — allow.
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# ── 3) checkout/switch to a ref other than the default branch ───────────────────
|
|
119
|
+
# Only checkout/switch move HEAD to a ref. A reset that reached this far (--soft /
|
|
120
|
+
# --mixed, or a hard/keep/merge reset in a single-worktree repo) is not a branch move
|
|
121
|
+
# off the default → allow it and stop.
|
|
122
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch)([[:space:]]|\$)" || exit 0
|
|
123
|
+
|
|
124
|
+
# `git checkout <ref> -- <paths>` and `git checkout -- <paths>` never move HEAD →
|
|
125
|
+
# allow anything containing a ` -- ` pathspec separator.
|
|
126
|
+
printf '%s' "$COMMAND" | grep -qE '[[:space:]]--([[:space:]]|$)' && exit 0
|
|
127
|
+
|
|
128
|
+
# Extract the first non-flag argument after checkout/switch. Strip everything up to and
|
|
129
|
+
# including the subcommand keyword — whatever git global options preceded it (`-c`,
|
|
130
|
+
# `--no-pager`, `-C <path>`, …) are consumed by the greedy prefix. The trailing separator
|
|
131
|
+
# is optional so a BARE `git checkout` (no target) yields empty ARGS → allowed below.
|
|
132
|
+
ARGS=$(printf '%s' "$COMMAND" | sed -E 's/.*[[:space:]](checkout|switch)([[:space:]]+|$)/ /' | head -1)
|
|
133
|
+
FIRST_REF=""
|
|
134
|
+
for word in $ARGS; do
|
|
135
|
+
case "$word" in
|
|
136
|
+
-*) continue ;; # flags (creation flags already denied above)
|
|
137
|
+
\;|\&\&|\|\||\||\&) break ;; # end of this git command
|
|
138
|
+
*) FIRST_REF="$word"; break ;;
|
|
139
|
+
esac
|
|
140
|
+
done
|
|
141
|
+
|
|
142
|
+
# Bare `git checkout`/`git switch` (no target) is a no-op/error → allow.
|
|
143
|
+
[ -z "$FIRST_REF" ] && exit 0
|
|
144
|
+
|
|
145
|
+
# Returning home (default branch) or file-restore forms → allow. Any other ref → deny.
|
|
146
|
+
case "$FIRST_REF" in
|
|
147
|
+
HEAD|.) exit 0 ;; # file restore forms
|
|
148
|
+
*) is_default_branch "$FIRST_REF" && exit 0 || emit_deny "$DENY_MSG" ;;
|
|
149
|
+
esac
|
|
@@ -71,6 +71,26 @@ For a `deny`/`allow` style hook, every case file should include:
|
|
|
71
71
|
- At least one **allow** that sits right next to a deny path (e.g. `git push origin main` denies, `git push origin feature/foo` allows) — proves the pattern is specific, not blanket.
|
|
72
72
|
- One **no-op** case where the hook input does not match the shape the hook expects (e.g. `file_path` missing, `command` empty) — proves the hook fails open for irrelevant events.
|
|
73
73
|
|
|
74
|
+
## Standalone git-fixture tests
|
|
75
|
+
|
|
76
|
+
Some hooks need a real git repository (tracked files, merges, worktrees) rather
|
|
77
|
+
than a single stdin payload, so they ship self-contained `*.test.sh` scripts run
|
|
78
|
+
directly instead of through `run.sh`:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
.claude/hooks/tests/forge-state-rollup.test.sh # boot rollup (index.yml + active.yml)
|
|
82
|
+
.claude/hooks/tests/forge-release-fold.test.sh # release_state fold
|
|
83
|
+
.claude/hooks/tests/forge-reserve.test.sh # id reservation
|
|
84
|
+
.claude/hooks/tests/verify-signoff.test.sh # signed-tag verification
|
|
85
|
+
.claude/hooks/tests/wu-done.test.sh # done predicate
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Each builds throwaway repos in `mktemp` dirs with no remote and isolated git
|
|
89
|
+
config. `forge-state-rollup.test.sh` covers the #22 acceptance shape (a stale
|
|
90
|
+
cache is regenerated regardless), fold-degrade → `release_state: unknown`, the
|
|
91
|
+
main-only auto-close sweep, the linked-worktree guard (sweep skipped, caches
|
|
92
|
+
still rendered), and the SessionStart stdin path.
|
|
93
|
+
|
|
74
94
|
## Why this lives here
|
|
75
95
|
|
|
76
96
|
Hooks run on every tool call the agent makes. If they regress silently, either the agent starts bypassing real protections, or innocent edits start getting blocked and the hooks get disabled in frustration. A small test harness catches both before they land.
|