forge-orkes 0.77.0 → 0.80.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/forge-context-migrate.sh +297 -0
- package/template/.claude/hooks/forge-size-gate.sh +175 -0
- package/template/.claude/hooks/forge-state-rollup.sh +135 -2
- package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
- package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
- package/template/.claude/hooks/tests/forge-context-migrate.test.sh +166 -0
- package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
- package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
- package/template/.claude/settings.json +14 -1
- package/template/.claude/skills/discussing/SKILL.md +30 -31
- package/template/.claude/skills/executing/SKILL.md +3 -3
- package/template/.claude/skills/forge/SKILL.md +112 -170
- package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
- package/template/.claude/skills/initializing/SKILL.md +20 -1
- package/template/.claude/skills/planning/SKILL.md +11 -9
- package/template/.claude/skills/researching/SKILL.md +2 -0
- package/template/.claude/skills/reviewing/SKILL.md +17 -0
- package/template/.claude/skills/upgrading/SKILL.md +22 -2
- package/template/.claude/skills/verifying/SKILL.md +2 -2
- package/template/.forge/FORGE.md +81 -90
- package/template/.forge/bin/new-worktree.sh +61 -0
- package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
- package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
- package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
- package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -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.78.0-weld-primary-checkout.md +66 -0
- package/template/.forge/migrations/0.80.0-context-md-sharding.md +158 -0
- package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
- package/template/.forge/templates/constitution.md +1 -1
- package/template/.forge/templates/context-milestone.md +30 -0
- package/template/.forge/templates/slice-runner/config.yml +7 -1
- package/template/.forge/templates/state/desire-path.yml +3 -0
- package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Self-contained fixture test for .claude/hooks/forge-context-migrate.sh
|
|
3
|
+
# (phase 95 plan-01 task 1, issue #37 / ADR-033).
|
|
4
|
+
#
|
|
5
|
+
# Builds a small throwaway monolithic context.md fixture covering every
|
|
6
|
+
# classification case the migrator has to get right: an `M{N}` heading, a
|
|
7
|
+
# `m-{N}` heading, an id-less "drafting" heading, prefixed + unprefixed
|
|
8
|
+
# Deferred/Discretion entries, Needs Resolution, and an Amendment Log. Proves:
|
|
9
|
+
# one context/{id}.md per locked milestone heading, id-less content routed to
|
|
10
|
+
# context-log.md (never a milestone), unprefixed Deferred/Discretion entries
|
|
11
|
+
# routed to context/_shared.md (never guessed onto a milestone), and that
|
|
12
|
+
# re-running against an already-migrated tree is a no-op (skips every id,
|
|
13
|
+
# touches no existing file).
|
|
14
|
+
#
|
|
15
|
+
# Run: ./.claude/hooks/tests/forge-context-migrate.test.sh
|
|
16
|
+
set -u
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
19
|
+
MIGRATE="$SCRIPT_DIR/../forge-context-migrate.sh"
|
|
20
|
+
|
|
21
|
+
[ -x "$MIGRATE" ] || { printf 'FATAL: migrator not executable: %s\n' "$MIGRATE" >&2; exit 1; }
|
|
22
|
+
|
|
23
|
+
ROOT="$(mktemp -d)"
|
|
24
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
25
|
+
|
|
26
|
+
PASSED=0
|
|
27
|
+
FAILED=0
|
|
28
|
+
pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
|
|
29
|
+
fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
|
|
30
|
+
check_file() { [ -f "$1" ] && pass "$2" || fail "$2 (missing: $1)"; }
|
|
31
|
+
check_absent_file() { [ -f "$1" ] && fail "$2 (should not exist: $1)" || pass "$2"; }
|
|
32
|
+
check_has() { case "$2" in *"$3"*) pass "$1" ;; *) fail "$1 (missing substring '$3')" ;; esac; }
|
|
33
|
+
check_absent() { case "$2" in *"$3"*) fail "$1 (must NOT contain '$3')" ;; *) pass "$1" ;; esac; }
|
|
34
|
+
|
|
35
|
+
FIXDIR="$ROOT/src"
|
|
36
|
+
mkdir -p "$FIXDIR"
|
|
37
|
+
SOURCE="$FIXDIR/context.md"
|
|
38
|
+
DEST="$ROOT/.forge"
|
|
39
|
+
mkdir -p "$DEST"
|
|
40
|
+
|
|
41
|
+
cat > "$SOURCE" <<'FIXTURE'
|
|
42
|
+
# Locked Context
|
|
43
|
+
|
|
44
|
+
Preamble prose that belongs to nobody — dropped, never carried anywhere.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Locked Decisions
|
|
49
|
+
|
|
50
|
+
### M7 — Widget Registry (locked 2026-01-01)
|
|
51
|
+
|
|
52
|
+
Context: why m7 exists.
|
|
53
|
+
|
|
54
|
+
- **Decision A**: Do the thing. Reason: because.
|
|
55
|
+
|
|
56
|
+
### m-9 — Gadget Pipeline (locked 2026-01-02)
|
|
57
|
+
|
|
58
|
+
- **Decision B**: Do the other thing. Reason: also because.
|
|
59
|
+
|
|
60
|
+
### M? — Future Program (drafting)
|
|
61
|
+
|
|
62
|
+
- **Draft idea**: not locked yet, no milestone id assigned.
|
|
63
|
+
|
|
64
|
+
## Deferred Ideas
|
|
65
|
+
|
|
66
|
+
- M7: narrower follow-up — Deferred because: out of scope. Revisit: later.
|
|
67
|
+
- Cross-cutting idea with no milestone prefix — Deferred because: nobody owns
|
|
68
|
+
this yet. Revisit: when it does.
|
|
69
|
+
|
|
70
|
+
## Discretion Areas
|
|
71
|
+
|
|
72
|
+
- m-9: exact wording of the follow-up doc.
|
|
73
|
+
- Generic discretion with no prefix at all.
|
|
74
|
+
|
|
75
|
+
## Needs Resolution
|
|
76
|
+
|
|
77
|
+
(none)
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Amendment Log
|
|
82
|
+
|
|
83
|
+
| Date | Change | Rationale | Approved By |
|
|
84
|
+
|------|--------|-----------|-------------|
|
|
85
|
+
| 2026-01-01 | Added M7 | Fixture seed | Tester |
|
|
86
|
+
FIXTURE
|
|
87
|
+
|
|
88
|
+
# --- first run: real migration --------------------------------------------
|
|
89
|
+
|
|
90
|
+
OUT1="$("$MIGRATE" --source "$SOURCE" --dest "$DEST" 2>&1)"
|
|
91
|
+
RC1=$?
|
|
92
|
+
check() { [ "$RC1" -eq 0 ] && pass "$1" || fail "$1 (exit $RC1)"; }
|
|
93
|
+
check "migrator exits 0 on first run"
|
|
94
|
+
|
|
95
|
+
check_file "$DEST/context/m-7.md" "context/m-7.md created (M7 heading normalized)"
|
|
96
|
+
check_file "$DEST/context/m-9.md" "context/m-9.md created (m-9 heading)"
|
|
97
|
+
check_absent_file "$DEST/context/m-future.md" "no file invented for the M? drafting block"
|
|
98
|
+
check_file "$DEST/context-log.md" "context-log.md created"
|
|
99
|
+
check_file "$DEST/context/_shared.md" "context/_shared.md created"
|
|
100
|
+
|
|
101
|
+
m7="$(cat "$DEST/context/m-7.md" 2>/dev/null)"
|
|
102
|
+
check_has "m-7.md carries its Locked Decision body" "$m7" "Do the thing"
|
|
103
|
+
check_has "m-7.md carries its own Deferred entry (prefix match)" "$m7" "narrower follow-up"
|
|
104
|
+
check_absent "m-7.md does NOT carry m-9's Discretion entry" "$m7" "exact wording of the follow-up doc"
|
|
105
|
+
|
|
106
|
+
m9="$(cat "$DEST/context/m-9.md" 2>/dev/null)"
|
|
107
|
+
check_has "m-9.md carries its Locked Decision body" "$m9" "Do the other thing"
|
|
108
|
+
check_has "m-9.md carries its own Discretion entry (prefix match)" "$m9" "exact wording of the follow-up doc"
|
|
109
|
+
|
|
110
|
+
log="$(cat "$DEST/context-log.md" 2>/dev/null)"
|
|
111
|
+
check_has "context-log.md carries the id-less drafting block" "$log" "Future Program"
|
|
112
|
+
check_has "context-log.md carries the Amendment Log verbatim" "$log" "Added M7"
|
|
113
|
+
check_has "context-log.md carries Needs Resolution" "$log" "(none)"
|
|
114
|
+
check_absent "context-log.md does NOT carry M7's locked content" "$log" "Do the thing"
|
|
115
|
+
|
|
116
|
+
shared="$(cat "$DEST/context/_shared.md" 2>/dev/null)"
|
|
117
|
+
check_has "_shared.md carries the unprefixed Deferred entry" "$shared" "Cross-cutting idea with no milestone prefix"
|
|
118
|
+
check_has "_shared.md carries the unprefixed Discretion entry" "$shared" "Generic discretion with no prefix"
|
|
119
|
+
check_absent "_shared.md does NOT carry M7's prefixed Deferred entry" "$shared" "narrower follow-up"
|
|
120
|
+
|
|
121
|
+
# --- idempotency: second run must be a pure no-op --------------------------
|
|
122
|
+
|
|
123
|
+
sum_before_7="$(cat "$DEST/context/m-7.md" | wc -c)"
|
|
124
|
+
sum_before_log="$(cat "$DEST/context-log.md" | wc -c)"
|
|
125
|
+
sum_before_shared="$(cat "$DEST/context/_shared.md" | wc -c)"
|
|
126
|
+
|
|
127
|
+
OUT2="$("$MIGRATE" --source "$SOURCE" --dest "$DEST" 2>&1)"
|
|
128
|
+
RC2=$?
|
|
129
|
+
[ "$RC2" -eq 0 ] && pass "migrator exits 0 on re-run" || fail "migrator exits 0 on re-run (exit $RC2)"
|
|
130
|
+
|
|
131
|
+
check_has "re-run reports a SKIP for m-7" "$OUT2" "SKIP"
|
|
132
|
+
check_has "re-run names m-7 in a skip line" "$OUT2" "m-7"
|
|
133
|
+
|
|
134
|
+
sum_after_7="$(cat "$DEST/context/m-7.md" | wc -c)"
|
|
135
|
+
sum_after_log="$(cat "$DEST/context-log.md" | wc -c)"
|
|
136
|
+
sum_after_shared="$(cat "$DEST/context/_shared.md" | wc -c)"
|
|
137
|
+
|
|
138
|
+
[ "$sum_before_7" -eq "$sum_after_7" ] && pass "re-run left context/m-7.md byte-identical" || fail "re-run mutated context/m-7.md"
|
|
139
|
+
[ "$sum_before_log" -eq "$sum_after_log" ] && pass "re-run left context-log.md byte-identical" || fail "re-run mutated context-log.md"
|
|
140
|
+
[ "$sum_before_shared" -eq "$sum_after_shared" ] && pass "re-run left context/_shared.md byte-identical" || fail "re-run mutated context/_shared.md"
|
|
141
|
+
|
|
142
|
+
file_count_after="$(ls "$DEST"/context/*.md 2>/dev/null | wc -l | tr -d ' ')"
|
|
143
|
+
[ "$file_count_after" -eq 3 ] && pass "re-run created no duplicate files (still m-7, m-9, _shared)" || fail "re-run changed file count (got $file_count_after, want 3)"
|
|
144
|
+
|
|
145
|
+
# --- dry-run touches nothing -------------------------------------------------
|
|
146
|
+
|
|
147
|
+
DRYDEST="$ROOT/.forge-dry"
|
|
148
|
+
mkdir -p "$DRYDEST/context"
|
|
149
|
+
OUT3="$("$MIGRATE" --source "$SOURCE" --dest "$DRYDEST" --dry-run 2>&1)"
|
|
150
|
+
RC3=$?
|
|
151
|
+
[ "$RC3" -eq 0 ] && pass "dry-run exits 0" || fail "dry-run exits 0 (exit $RC3)"
|
|
152
|
+
check_has "dry-run reports what it would do" "$OUT3" "DRY-RUN"
|
|
153
|
+
check_absent_file "$DRYDEST/context/m-7.md" "dry-run creates no real file"
|
|
154
|
+
check_absent_file "$DRYDEST/context-log.md" "dry-run creates no real context-log.md"
|
|
155
|
+
|
|
156
|
+
# --- source file is never modified ------------------------------------------
|
|
157
|
+
|
|
158
|
+
src_after="$(cat "$SOURCE" | wc -c)"
|
|
159
|
+
[ -n "$src_after" ] && pass "source context.md still readable after migration" || fail "source context.md unreadable after migration"
|
|
160
|
+
grep -q "### M7 — Widget Registry" "$SOURCE" && pass "source context.md left untouched" || fail "source context.md was modified"
|
|
161
|
+
|
|
162
|
+
# --- summary -----------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
printf '\n%s passed, %s failed\n' "$PASSED" "$FAILED"
|
|
165
|
+
[ "$FAILED" -eq 0 ] || exit 1
|
|
166
|
+
exit 0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Self-contained unit test for .claude/hooks/forge-size-gate.sh (issue #37).
|
|
3
|
+
#
|
|
4
|
+
# Proves the mechanical size gate: exactly one advisory line naming every
|
|
5
|
+
# over-gate artifact (with size + gate), total silence when everything is
|
|
6
|
+
# within gate, silent skip (no crash, no error) when a declared-gate artifact
|
|
7
|
+
# is simply absent, and correct tiering of skill files (40KB for names listed
|
|
8
|
+
# in skill-gate-tiers.txt, 20KB default for everything else).
|
|
9
|
+
#
|
|
10
|
+
# Builds throwaway git repos in mktemp dirs, NO remote configured. Isolated
|
|
11
|
+
# from the developer's git config. Run:
|
|
12
|
+
# ./.claude/hooks/tests/forge-size-gate.test.sh
|
|
13
|
+
set -u
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
16
|
+
GATE="$SCRIPT_DIR/../forge-size-gate.sh"
|
|
17
|
+
|
|
18
|
+
[ -x "$GATE" ] || { printf 'FATAL: hook not executable: %s\n' "$GATE" >&2; exit 1; }
|
|
19
|
+
command -v git >/dev/null 2>&1 || { printf 'FATAL: git not found\n' >&2; exit 1; }
|
|
20
|
+
|
|
21
|
+
GIT_CONFIG_GLOBAL=/dev/null
|
|
22
|
+
GIT_CONFIG_SYSTEM=/dev/null
|
|
23
|
+
export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
|
|
24
|
+
unset FORGE_ALLOW_HOOK_EDITS 2>/dev/null || true
|
|
25
|
+
|
|
26
|
+
ROOT="$(mktemp -d)"
|
|
27
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
28
|
+
|
|
29
|
+
PASSED=0
|
|
30
|
+
FAILED=0
|
|
31
|
+
pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
|
|
32
|
+
fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
|
|
33
|
+
check() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi; }
|
|
34
|
+
check_has() { case "$2" in *"$3"*) pass "$1" ;; *) fail "$1 (got '$2', want substring '$3')" ;; esac; }
|
|
35
|
+
check_absent() { case "$2" in *"$3"*) fail "$1 (got '$2', must NOT contain '$3')" ;; *) pass "$1" ;; esac; }
|
|
36
|
+
|
|
37
|
+
# pad_to <path> <bytes> — write <path> with exactly <bytes> bytes of filler.
|
|
38
|
+
pad_to() {
|
|
39
|
+
path="$1"; n="$2"
|
|
40
|
+
mkdir -p "$(dirname "$path")"
|
|
41
|
+
awk -v n="$n" 'BEGIN { for (i = 0; i < n; i++) printf "a"; printf "\n" }' | head -c "$n" > "$path"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
REPO="$ROOT/repo"
|
|
45
|
+
mkdir -p "$REPO"
|
|
46
|
+
git -C "$REPO" init -q
|
|
47
|
+
git -C "$REPO" symbolic-ref HEAD refs/heads/main
|
|
48
|
+
git -C "$REPO" config user.email t@example.com
|
|
49
|
+
git -C "$REPO" config user.name tester
|
|
50
|
+
|
|
51
|
+
mkdir -p "$REPO/.forge"
|
|
52
|
+
mkdir -p "$REPO/.claude/hooks"
|
|
53
|
+
printf 'forge\nchief-of-staff\n' > "$REPO/.claude/hooks/skill-gate-tiers.txt"
|
|
54
|
+
|
|
55
|
+
git -C "$REPO" add -A
|
|
56
|
+
git -C "$REPO" commit -qm "chore(forge): seed empty repo" --allow-empty >/dev/null 2>&1
|
|
57
|
+
|
|
58
|
+
# ===========================================================================
|
|
59
|
+
# Case A — an artifact over its gate is named in the ONE advisory line, with
|
|
60
|
+
# correct size + gate numbers.
|
|
61
|
+
# ===========================================================================
|
|
62
|
+
pad_to "$REPO/.forge/FORGE.md" 35800 # over the 35KB (35000B) gate
|
|
63
|
+
out_a="$("$GATE" --project "$REPO" 2>&1)"
|
|
64
|
+
check_has "over-gate artifact named" "$out_a" ".forge/FORGE.md"
|
|
65
|
+
check_has "over-gate artifact shows actual bytes" "$out_a" "35800B"
|
|
66
|
+
check_has "over-gate artifact shows its gate" "$out_a" "35KB"
|
|
67
|
+
check_has "advisory line points at FORGE.md Size Gates" "$out_a" "FORGE.md § Size Gates"
|
|
68
|
+
# exactly one line of output
|
|
69
|
+
lc_a="$(printf '%s\n' "$out_a" | grep -c .)"
|
|
70
|
+
check "advisory is exactly one line" "$lc_a" "1"
|
|
71
|
+
rm -f "$REPO/.forge/FORGE.md"
|
|
72
|
+
|
|
73
|
+
# ===========================================================================
|
|
74
|
+
# Case B — every checked artifact within gate → zero output.
|
|
75
|
+
# ===========================================================================
|
|
76
|
+
pad_to "$REPO/.forge/FORGE.md" 100
|
|
77
|
+
pad_to "$REPO/.forge/project.yml" 100
|
|
78
|
+
out_b="$("$GATE" --project "$REPO" 2>&1)"
|
|
79
|
+
check "all within gate → no output" "$out_b" ""
|
|
80
|
+
rc_b=$?
|
|
81
|
+
"$GATE" --project "$REPO" >/dev/null 2>&1
|
|
82
|
+
check "all within gate → exit 0" "$?" "0"
|
|
83
|
+
|
|
84
|
+
# ===========================================================================
|
|
85
|
+
# Case C — a declared-gate artifact simply absent → no crash, no error, and
|
|
86
|
+
# it is never named (context.md was never created in this fixture repo).
|
|
87
|
+
# ===========================================================================
|
|
88
|
+
out_c="$("$GATE" --project "$REPO" 2>&1)"; rc_c=$?
|
|
89
|
+
check "missing artifact → exit 0" "$rc_c" "0"
|
|
90
|
+
check_absent "missing artifact never named" "$out_c" "context.md"
|
|
91
|
+
|
|
92
|
+
# ===========================================================================
|
|
93
|
+
# Case D — tiered skill gate: same-sized file, different tier outcome.
|
|
94
|
+
# - "forge" is in skill-gate-tiers.txt → 40KB tier → 35KB file is fine.
|
|
95
|
+
# - "some-other-skill" is NOT listed → 20KB default tier → 35KB file is over.
|
|
96
|
+
# ===========================================================================
|
|
97
|
+
pad_to "$REPO/.claude/skills/forge/SKILL.md" 35000
|
|
98
|
+
pad_to "$REPO/.claude/skills/some-other-skill/SKILL.md" 35000
|
|
99
|
+
out_d="$("$GATE" --project "$REPO" 2>&1)"
|
|
100
|
+
check_absent "entry-point skill under its 40KB tier → no violation" "$out_d" "skills/forge/SKILL.md"
|
|
101
|
+
check_has "non-entry-point skill over the 20KB default tier → violation" "$out_d" "skills/some-other-skill/SKILL.md"
|
|
102
|
+
check_has "non-entry-point violation shows the 20KB gate" "$out_d" "20KB"
|
|
103
|
+
rm -rf "$REPO/.claude/skills"
|
|
104
|
+
|
|
105
|
+
# ===========================================================================
|
|
106
|
+
# Case E — missing tier-list file degrades to "every skill at 20KB", never
|
|
107
|
+
# a crash.
|
|
108
|
+
# ===========================================================================
|
|
109
|
+
rm -f "$REPO/.claude/hooks/skill-gate-tiers.txt"
|
|
110
|
+
pad_to "$REPO/.claude/skills/forge/SKILL.md" 25000 # over 20KB, would be fine under the 40KB tier
|
|
111
|
+
out_e="$("$GATE" --project "$REPO" 2>&1)"; rc_e=$?
|
|
112
|
+
check "missing tier-list → exit 0 (no crash)" "$rc_e" "0"
|
|
113
|
+
check_has "missing tier-list → default 20KB tier applied even to 'forge'" "$out_e" "skills/forge/SKILL.md"
|
|
114
|
+
rm -rf "$REPO/.claude/skills"
|
|
115
|
+
printf 'forge\nchief-of-staff\n' > "$REPO/.claude/hooks/skill-gate-tiers.txt"
|
|
116
|
+
|
|
117
|
+
# ===========================================================================
|
|
118
|
+
# Case F — non-Forge repo / non-repo dir: silent no-op, exit 0.
|
|
119
|
+
# ===========================================================================
|
|
120
|
+
mkdir -p "$ROOT/plain"
|
|
121
|
+
"$GATE" --project "$ROOT/plain" >"$ROOT/out_f" 2>&1; rc_f=$?
|
|
122
|
+
check "non-git dir → exit 0" "$rc_f" "0"
|
|
123
|
+
check "non-git dir → no output" "$(cat "$ROOT/out_f")" ""
|
|
124
|
+
|
|
125
|
+
mkdir -p "$ROOT/emptygit"
|
|
126
|
+
git -C "$ROOT/emptygit" init -q
|
|
127
|
+
"$GATE" --project "$ROOT/emptygit" >"$ROOT/out_f2" 2>&1; rc_f2=$?
|
|
128
|
+
check "git repo without .forge → exit 0, silent" "$rc_f2" "0"
|
|
129
|
+
check "git repo without .forge → no output" "$(cat "$ROOT/out_f2")" ""
|
|
130
|
+
|
|
131
|
+
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
|
|
132
|
+
[ "$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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"forge": {
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0",
|
|
4
4
|
"default_tier": "standard",
|
|
5
5
|
"beads_integration": false,
|
|
6
6
|
"context_gates": {
|
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
{
|
|
64
64
|
"type": "command",
|
|
65
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
|
+
"type": "command",
|
|
69
|
+
"command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-size-gate.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-size-gate.sh\" || true"
|
|
66
70
|
}
|
|
67
71
|
]
|
|
68
72
|
}
|
|
@@ -141,6 +145,15 @@
|
|
|
141
145
|
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-commands.sh\""
|
|
142
146
|
}
|
|
143
147
|
]
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"matcher": "Bash",
|
|
151
|
+
"hooks": [
|
|
152
|
+
{
|
|
153
|
+
"type": "command",
|
|
154
|
+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-primary-checkout.sh\""
|
|
155
|
+
}
|
|
156
|
+
]
|
|
144
157
|
}
|
|
145
158
|
]
|
|
146
159
|
}
|