forge-orkes 0.79.0 → 0.80.1
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 +6 -0
- package/package.json +1 -1
- package/template/.claude/hooks/forge-context-migrate.sh +317 -0
- package/template/.claude/hooks/forge-release-fold.sh +35 -2
- package/template/.claude/hooks/forge-size-gate.sh +175 -0
- package/template/.claude/hooks/forge-state-rollup.sh +135 -2
- package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
- package/template/.claude/hooks/tests/forge-context-migrate.test.sh +248 -0
- package/template/.claude/hooks/tests/forge-release-fold.test.sh +53 -2
- package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
- package/template/.claude/settings.json +5 -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/planning/SKILL.md +11 -9
- package/template/.claude/skills/researching/SKILL.md +2 -0
- package/template/.claude/skills/verifying/SKILL.md +2 -2
- package/template/.forge/FORGE.md +79 -91
- 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/migrations/0.80.0-context-md-sharding.md +175 -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
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
# (which a "the index is stale, work around it" project-memory habit can
|
|
8
8
|
# out-compete — the exact ptnrkit failure #22 was filed against).
|
|
9
9
|
#
|
|
10
|
+
# A third join (ADR-033, issue #37) renders .forge/context.md itself as a
|
|
11
|
+
# derived index over .forge/context/*.md — the per-milestone decision files
|
|
12
|
+
# forge-context-migrate.sh produces. Same contract as the other two: derived,
|
|
13
|
+
# git-ignored, rendered fresh every boot, never hand-edited.
|
|
14
|
+
#
|
|
10
15
|
# forge-state-rollup.sh [--project <path>] [--quiet]
|
|
11
16
|
# (SessionStart hook) reads {cwd, hook_event_name, source} from stdin JSON.
|
|
12
17
|
#
|
|
@@ -17,9 +22,11 @@
|
|
|
17
22
|
# render this worktree's caches into the wrong tree.
|
|
18
23
|
# --quiet suppress the stdout summary line (still writes caches).
|
|
19
24
|
#
|
|
20
|
-
# What it writes (
|
|
25
|
+
# What it writes (all three are git-ignored render-on-read caches since
|
|
26
|
+
# 0.53.0 / this addendum for context.md):
|
|
21
27
|
# .forge/state/index.yml — derived milestone registry
|
|
22
28
|
# .forge/streams/active.yml — derived stream registry (if streams/ exists)
|
|
29
|
+
# .forge/context.md — derived context index (if context/ exists)
|
|
23
30
|
# It NEVER commits them and NEVER runs git add/commit. Rendering a gitignored
|
|
24
31
|
# cache is a local write that never lands on main, so any checkout (main OR
|
|
25
32
|
# worktree) may render its own copy (FORGE.md → State Ownership, Derived class).
|
|
@@ -398,15 +405,136 @@ render_active() {
|
|
|
398
405
|
STREAM_ROW_COUNT="$sn"
|
|
399
406
|
}
|
|
400
407
|
|
|
408
|
+
# --- Context Index (ADR-033 addendum) ----------------------------------------
|
|
409
|
+
# Third join: .forge/context/*.md (one file per milestone, produced by
|
|
410
|
+
# forge-context-migrate.sh) ⋈ the index.yml rows this same pass already
|
|
411
|
+
# rendered above — no second fold invocation, reuse what render_index wrote.
|
|
412
|
+
|
|
413
|
+
# context_index_lookup <norm_id> — prints "name\tstatus\trelease_state" for
|
|
414
|
+
# the index.yml row matching <norm_id> (already normalized, no leading "m-").
|
|
415
|
+
# Empty output when no row matches — degrade gracefully, never error.
|
|
416
|
+
context_index_lookup() {
|
|
417
|
+
awk -v want="$1" '
|
|
418
|
+
/^ - id:/ { id=$3; gsub(/["\x27]/,"",id); sub(/^m-/,"",id); inrow=(id==want); name=""; status=""; rel="" }
|
|
419
|
+
inrow && /^ name:/ { name=$0; sub(/^ name: /,"",name); gsub(/^["\x27]|["\x27]$/,"",name) }
|
|
420
|
+
inrow && /^ status:/ { status=$2 }
|
|
421
|
+
inrow && /^ release_state:/ { rel=$2; print name "\t" status "\t" rel; exit }
|
|
422
|
+
' "$root/.forge/state/index.yml" 2>/dev/null
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
# context_summary <file> — a <!-- summary: ... --> marker wins over the
|
|
426
|
+
# first "- " bullet; empty when the file has neither (row still renders,
|
|
427
|
+
# just with an empty summary — never a crash).
|
|
428
|
+
context_summary() {
|
|
429
|
+
f="$1"
|
|
430
|
+
s="$(awk '/<!-- summary:/ { line=$0; sub(/^.*<!-- summary: */,"",line); sub(/ *-->.*$/,"",line); print line; exit }' "$f" 2>/dev/null)"
|
|
431
|
+
if [ -z "$s" ]; then
|
|
432
|
+
s="$(awk '/^- / { line=$0; sub(/^- /,"",line); print line; exit }' "$f" 2>/dev/null)"
|
|
433
|
+
fi
|
|
434
|
+
printf '%s\n' "$s"
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
CONTEXT_ROW_COUNT=0
|
|
438
|
+
|
|
439
|
+
render_context_index() {
|
|
440
|
+
context_path="$root/.forge/context.md"
|
|
441
|
+
ctx_rows="" # "id\tname\tstatus\trelease_state\tsummary\tpath"
|
|
442
|
+
cn=0
|
|
443
|
+
|
|
444
|
+
for cfile in "$root"/.forge/context/*.md; do
|
|
445
|
+
[ -f "$cfile" ] || continue
|
|
446
|
+
case "$(basename "$cfile")" in _shared.md) continue ;; esac
|
|
447
|
+
c_id="$(basename "$cfile" .md)"
|
|
448
|
+
[ -z "$c_id" ] && continue
|
|
449
|
+
rel_path=".forge/context/$(basename "$cfile")"
|
|
450
|
+
|
|
451
|
+
fields="$(context_index_lookup "$(norm_id "$c_id")")"
|
|
452
|
+
if [ -n "$fields" ]; then
|
|
453
|
+
c_name="$(printf '%s' "$fields" | awk -F'\t' '{print $1}')"
|
|
454
|
+
c_status="$(printf '%s' "$fields" | awk -F'\t' '{print $2}')"
|
|
455
|
+
c_rel="$(printf '%s' "$fields" | awk -F'\t' '{print $3}')"
|
|
456
|
+
else
|
|
457
|
+
c_name=""
|
|
458
|
+
c_status="unknown"
|
|
459
|
+
c_rel="unknown"
|
|
460
|
+
fi
|
|
461
|
+
[ -z "$c_status" ] && c_status="unknown"
|
|
462
|
+
[ -z "$c_rel" ] && c_rel="unknown"
|
|
463
|
+
c_summary="$(context_summary "$cfile")"
|
|
464
|
+
|
|
465
|
+
# A tab-delimited empty field is unsafe: `read` with IFS set to a plain
|
|
466
|
+
# tab still treats it as IFS-whitespace, so consecutive/empty fields get
|
|
467
|
+
# collapsed and every column after them shifts left. name/summary are the
|
|
468
|
+
# only fields that can legitimately be empty — substitute a sentinel and
|
|
469
|
+
# translate it back to "" after the read loop below.
|
|
470
|
+
[ -z "$c_name" ] && c_name="@@EMPTY@@"
|
|
471
|
+
[ -z "$c_summary" ] && c_summary="@@EMPTY@@"
|
|
472
|
+
|
|
473
|
+
ctx_rows="$ctx_rows$c_id $c_name $c_status $c_rel $c_summary $rel_path
|
|
474
|
+
"
|
|
475
|
+
cn=$((cn + 1))
|
|
476
|
+
done
|
|
477
|
+
|
|
478
|
+
sorted="$(printf '%s' "$ctx_rows" | LC_ALL=C sort -t' ' -k1,1)"
|
|
479
|
+
|
|
480
|
+
{
|
|
481
|
+
printf '# Locked Context — Index (DERIVED — do not hand-edit)\n'
|
|
482
|
+
printf '# Regenerated at boot by .claude/hooks/forge-state-rollup.sh: one row per\n'
|
|
483
|
+
printf '# .forge/context/{id}.md, joined with state/index.yml (status/release_state\n'
|
|
484
|
+
printf '# already resolved by this same pass). Git-ignored render-on-read cache —\n'
|
|
485
|
+
printf '# never committed. Full decision text lives in .forge/context/{id}.md, not\n'
|
|
486
|
+
printf '# here. See .forge/context-log.md (Amendment Log + Needs Resolution + any\n'
|
|
487
|
+
printf '# content with no resolvable milestone id) and .forge/context/_shared.md\n'
|
|
488
|
+
printf '# (cross-cutting Deferred Ideas / Discretion Areas, if present).\n'
|
|
489
|
+
printf '#\n'
|
|
490
|
+
printf '# Rules (apply to every context/{id}.md this index points at):\n'
|
|
491
|
+
printf '# - Locked Decisions -> MUST be implemented exactly as stated.\n'
|
|
492
|
+
printf '# - Deferred Ideas -> MUST NOT appear in plans, tasks, or code.\n'
|
|
493
|
+
printf '# - Discretion Areas -> Agent uses judgment; research, pick, document why.\n'
|
|
494
|
+
printf '#\n'
|
|
495
|
+
printf '# A milestone that is complete AND validated renders as a thin row\n'
|
|
496
|
+
printf '# (id, name, path only) — content never moves, only the render shortens.\n'
|
|
497
|
+
printf 'context:\n'
|
|
498
|
+
if [ "$cn" -eq 0 ]; then
|
|
499
|
+
printf ' []\n'
|
|
500
|
+
else
|
|
501
|
+
printf '%s\n' "$sorted" | while IFS=' ' read -r r_id r_name r_status r_rel r_summary r_path; do
|
|
502
|
+
[ -z "$r_id" ] && continue
|
|
503
|
+
[ "$r_name" = "@@EMPTY@@" ] && r_name=""
|
|
504
|
+
[ "$r_summary" = "@@EMPTY@@" ] && r_summary=""
|
|
505
|
+
printf ' - id: %s\n' "$r_id"
|
|
506
|
+
printf ' name: %s\n' "$(yaml_quote "$r_name")"
|
|
507
|
+
if [ "$r_status" = "complete" ] && [ "$r_rel" = "validated" ]; then
|
|
508
|
+
printf ' path: %s\n' "$r_path"
|
|
509
|
+
else
|
|
510
|
+
printf ' status: %s\n' "$r_status"
|
|
511
|
+
printf ' release_state: %s\n' "$r_rel"
|
|
512
|
+
[ -n "$r_summary" ] && printf ' summary: %s\n' "$(yaml_quote "$r_summary")"
|
|
513
|
+
printf ' path: %s\n' "$r_path"
|
|
514
|
+
fi
|
|
515
|
+
done
|
|
516
|
+
fi
|
|
517
|
+
printf 'see_also:\n'
|
|
518
|
+
printf ' - .forge/context-log.md\n'
|
|
519
|
+
printf ' - .forge/context/_shared.md\n'
|
|
520
|
+
} > "$context_path.tmp.$$" && mv -f "$context_path.tmp.$$" "$context_path"
|
|
521
|
+
|
|
522
|
+
CONTEXT_ROW_COUNT="$cn"
|
|
523
|
+
}
|
|
524
|
+
|
|
401
525
|
# --- run ---------------------------------------------------------------------
|
|
402
526
|
|
|
403
|
-
render_index # index.yml first — stream rollup
|
|
527
|
+
render_index # index.yml first — stream rollup + context index read it
|
|
404
528
|
|
|
405
529
|
if [ -d "$root/.forge/streams" ]; then
|
|
406
530
|
stream_autoclose_sweep # step 0 (main checkout only)
|
|
407
531
|
render_active # then the join
|
|
408
532
|
fi
|
|
409
533
|
|
|
534
|
+
if [ -d "$root/.forge/context" ]; then
|
|
535
|
+
render_context_index # third join
|
|
536
|
+
fi
|
|
537
|
+
|
|
410
538
|
# --- summary (stdout → injected into boot context) ---------------------------
|
|
411
539
|
|
|
412
540
|
if [ "$quiet" -eq 0 ]; then
|
|
@@ -418,6 +546,11 @@ if [ "$quiet" -eq 0 ]; then
|
|
|
418
546
|
[ "${STREAM_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
|
|
419
547
|
summary="$summary)"
|
|
420
548
|
fi
|
|
549
|
+
if [ -d "$root/.forge/context" ]; then
|
|
550
|
+
summary="$summary + context.md (${CONTEXT_ROW_COUNT:-0} context row"
|
|
551
|
+
[ "${CONTEXT_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
|
|
552
|
+
summary="$summary)"
|
|
553
|
+
fi
|
|
421
554
|
[ -n "$SWEEP_CLOSED" ] && summary="$summary; auto-closed:${SWEEP_CLOSED}"
|
|
422
555
|
[ "${fold_unavailable:-0}" -gt 0 ] && summary="$summary; ${fold_unavailable} release_state unknown (fold unavailable)"
|
|
423
556
|
if [ "$in_main_checkout" -eq 1 ]; then
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Entry-point skills — checked against the 40KB size gate by
|
|
2
|
+
# forge-size-gate.sh instead of the 20KB default every other skill gets.
|
|
3
|
+
# One skill name per line (matches the skill's directory name under
|
|
4
|
+
# .claude/skills/). Lines starting with # are comments.
|
|
5
|
+
forge
|
|
6
|
+
chief-of-staff
|
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
# --- labelled milestone ids (issue #42) -------------------------------------
|
|
163
|
+
# Non-numeric ids (M-EVT01, m-SEQPRIM01) must shard into distinct files —
|
|
164
|
+
# digit-stripping them onto a shared "m-01" was the exact silent-collision
|
|
165
|
+
# bug reported upstream. Purely-numeric ids must keep their existing
|
|
166
|
+
# normalization (m-53 stays m-53, unaffected by the labelled-id path).
|
|
167
|
+
|
|
168
|
+
LBLROOT="$(mktemp -d)"
|
|
169
|
+
trap 'rm -rf "$ROOT" "$LBLROOT"' EXIT INT TERM
|
|
170
|
+
LBLSRC="$LBLROOT/context.md"
|
|
171
|
+
LBLDEST="$LBLROOT/.forge"
|
|
172
|
+
mkdir -p "$LBLDEST"
|
|
173
|
+
|
|
174
|
+
cat > "$LBLSRC" <<'FIXTURE'
|
|
175
|
+
## Locked Decisions
|
|
176
|
+
|
|
177
|
+
### M-EVT01 — Event pipeline
|
|
178
|
+
|
|
179
|
+
- **Decision**: Emit events. Reason: because.
|
|
180
|
+
|
|
181
|
+
### m-SEQPRIM01 — Sequencer primitives
|
|
182
|
+
|
|
183
|
+
- **Decision**: Order things. Reason: also because.
|
|
184
|
+
|
|
185
|
+
### m-53 — Numeric id alongside labelled ones
|
|
186
|
+
|
|
187
|
+
- **Decision**: Still works numerically.
|
|
188
|
+
|
|
189
|
+
## Deferred Ideas
|
|
190
|
+
|
|
191
|
+
- m-EVT01: a follow-up scoped to the labelled id.
|
|
192
|
+
|
|
193
|
+
## Needs Resolution
|
|
194
|
+
|
|
195
|
+
(none)
|
|
196
|
+
|
|
197
|
+
## Amendment Log
|
|
198
|
+
|
|
199
|
+
(none)
|
|
200
|
+
FIXTURE
|
|
201
|
+
|
|
202
|
+
LBLOUT="$("$MIGRATE" --source "$LBLSRC" --dest "$LBLDEST" 2>&1)"
|
|
203
|
+
LBLRC=$?
|
|
204
|
+
[ "$LBLRC" -eq 0 ] && pass "labelled-id migration exits 0" || fail "labelled-id migration exits 0 (exit $LBLRC)"
|
|
205
|
+
|
|
206
|
+
check_file "$LBLDEST/context/m-evt01.md" "context/m-evt01.md created (labelled id, lowercased)"
|
|
207
|
+
check_file "$LBLDEST/context/m-seqprim01.md" "context/m-seqprim01.md created (distinct from m-evt01, not digit-collapsed)"
|
|
208
|
+
check_file "$LBLDEST/context/m-53.md" "context/m-53.md still created (numeric id unaffected by labelled-id path)"
|
|
209
|
+
|
|
210
|
+
lbl_count="$(ls "$LBLDEST"/context/*.md 2>/dev/null | wc -l | tr -d ' ')"
|
|
211
|
+
[ "$lbl_count" -eq 3 ] && pass "labelled + numeric ids produce 3 distinct files, none collapsed" || fail "labelled-id run produced $lbl_count file(s), want 3"
|
|
212
|
+
|
|
213
|
+
evt="$(cat "$LBLDEST/context/m-evt01.md" 2>/dev/null)"
|
|
214
|
+
check_has "m-evt01.md carries its own Deferred entry (labelled-prefix match)" "$evt" "a follow-up scoped to the labelled id"
|
|
215
|
+
|
|
216
|
+
# --- unresolvable id scheme guard (issue #42) --------------------------------
|
|
217
|
+
# A context.md whose Locked Decisions headings don't match m-<number> or a
|
|
218
|
+
# labelled m-<label> form at all (e.g. a different project's own convention)
|
|
219
|
+
# must fail loudly instead of silently producing zero shards.
|
|
220
|
+
|
|
221
|
+
BADROOT="$(mktemp -d)"
|
|
222
|
+
trap 'rm -rf "$ROOT" "$LBLROOT" "$BADROOT"' EXIT INT TERM
|
|
223
|
+
BADSRC="$BADROOT/context.md"
|
|
224
|
+
BADDEST="$BADROOT/.forge"
|
|
225
|
+
mkdir -p "$BADDEST"
|
|
226
|
+
|
|
227
|
+
cat > "$BADSRC" <<'FIXTURE'
|
|
228
|
+
## Locked Decisions
|
|
229
|
+
|
|
230
|
+
### FEATURE-ABC — unrelated id scheme
|
|
231
|
+
|
|
232
|
+
- **Decision**: Something.
|
|
233
|
+
|
|
234
|
+
### FEATURE-XYZ — another unrelated id
|
|
235
|
+
|
|
236
|
+
- **Decision**: Something else.
|
|
237
|
+
FIXTURE
|
|
238
|
+
|
|
239
|
+
BADOUT="$("$MIGRATE" --source "$BADSRC" --dest "$BADDEST" --dry-run 2>&1)"
|
|
240
|
+
BADRC=$?
|
|
241
|
+
[ "$BADRC" -ne 0 ] && pass "unresolvable id scheme exits non-zero instead of silently producing 0 shards" || fail "unresolvable id scheme should exit non-zero (exit $BADRC)"
|
|
242
|
+
check_has "unresolvable id scheme prints an ERROR line" "$BADOUT" "ERROR"
|
|
243
|
+
|
|
244
|
+
# --- summary -----------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
printf '\n%s passed, %s failed\n' "$PASSED" "$FAILED"
|
|
247
|
+
[ "$FAILED" -eq 0 ] || exit 1
|
|
248
|
+
exit 0
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
# ./.claude/hooks/tests/forge-release-fold.test.sh ordering # ordering controls
|
|
25
25
|
# Filter tags (one arg, selects matching case blocks):
|
|
26
26
|
# unmerged merged grandfather human-verified signoff-tag stored-field
|
|
27
|
-
# exit-2 stable-sha ordering promotion forgery labeled
|
|
27
|
+
# exit-2 stable-sha ordering promotion forgery labeled squash
|
|
28
28
|
# `promotion` + `forgery` (plan 51-01): validation_event=promotion_approval —
|
|
29
29
|
# a CI-signed tag on a deploy sha CONTAINING the unit's landed sha validates;
|
|
30
30
|
# tag deletion reverts; unsigned/wrong-key lookalikes and non-containing tags
|
|
@@ -791,9 +791,60 @@ lifecycle:
|
|
|
791
791
|
check_absent "unsafe labeled id: no verdict on stdout" "$OUT" "release_state="
|
|
792
792
|
fi
|
|
793
793
|
|
|
794
|
+
# --- case: squash-merged unit resolves merged (issue #39, m-53/PR#38 shape) -----
|
|
795
|
+
# GitHub squash-and-merge creates a brand-new commit on main with no ancestor
|
|
796
|
+
# relationship to the source branch — the direct ancestor check alone
|
|
797
|
+
# permanently misreports this as unmerged. Reproduces the real bug shape:
|
|
798
|
+
# a live branch tip (resolved via streams/m-N.yml runtime.branch, exactly
|
|
799
|
+
# resolution rule 1's `else` route — no lifecycle.worktree_branch in the state
|
|
800
|
+
# file, matching the real m-53 fixture), plus an unrelated commit on main
|
|
801
|
+
# between the branch's fork point and the squash (mirrors main advancing
|
|
802
|
+
# independently in between — and would defeat a naive whole-tree-hash
|
|
803
|
+
# comparison, which this repo's own investigation found fails under exactly
|
|
804
|
+
# this kind of drift).
|
|
805
|
+
if running squash; then
|
|
806
|
+
printf '\n--- case: squash-merged unit resolves merged (git merge --squash, not --no-ff) ---\n'
|
|
807
|
+
d="$(new_repo squash)"
|
|
808
|
+
write_milestone "$d" 120 'milestone:
|
|
809
|
+
id: 120
|
|
810
|
+
name: "fixture: squash-merged unit"
|
|
811
|
+
|
|
812
|
+
current:
|
|
813
|
+
tier: standard
|
|
814
|
+
status: executing
|
|
815
|
+
last_updated: "2026-07-29"
|
|
816
|
+
human_verified: null
|
|
817
|
+
|
|
818
|
+
lifecycle:
|
|
819
|
+
worktree_mode: active'
|
|
820
|
+
write_stream "$d" m-120 forge/m-120
|
|
821
|
+
git -C "$d" checkout -qb forge/m-120
|
|
822
|
+
# Real file content — an --allow-empty commit here would give the branch a
|
|
823
|
+
# zero-diff tip, making the squash-probe trivially match ANY zero-diff main
|
|
824
|
+
# commit regardless of the mechanism under test. Genuine content is what
|
|
825
|
+
# makes this a meaningful positive fixture.
|
|
826
|
+
printf 'feature work\n' > "$d/FEATURE.txt"
|
|
827
|
+
git -C "$d" add FEATURE.txt
|
|
828
|
+
git -C "$d" commit -qm "work on forge/m-120 (will be squash-merged)"
|
|
829
|
+
git -C "$d" checkout -q main
|
|
830
|
+
# Unrelated main-side drift between fork and squash, with its OWN real file
|
|
831
|
+
# content — mirrors main advancing independently in between, exactly like
|
|
832
|
+
# m-53/PR#38 (main gained m-54's declaration in between). Would defeat a
|
|
833
|
+
# naive whole-tree-hash comparison, which this repo's own investigation
|
|
834
|
+
# found fails under exactly this kind of drift.
|
|
835
|
+
printf 'unrelated\n' > "$d/OTHER.txt"
|
|
836
|
+
git -C "$d" add OTHER.txt
|
|
837
|
+
git -C "$d" commit -qm "unrelated main-side work"
|
|
838
|
+
git -C "$d" merge -q --squash forge/m-120 >/dev/null 2>&1
|
|
839
|
+
git -C "$d" commit -q -m "squash merge forge/m-120 (#120)"
|
|
840
|
+
run_fold "$d" m-120
|
|
841
|
+
check "squash-merged: exit 0" "$RC" "0"
|
|
842
|
+
check "squash-merged: resolves merged (no false negative on ancestor-check failure)" "$OUT" "release_state=merged reason=no-signoff"
|
|
843
|
+
fi
|
|
844
|
+
|
|
794
845
|
# --- summary -------------------------------------------------------------------
|
|
795
846
|
if [ -n "$FILTER" ] && [ "$SELECTED" -eq 0 ]; then
|
|
796
|
-
printf 'ERROR: filter "%s" matched no cases (tags: unmerged merged grandfather human-verified signoff-tag stored-field exit-2 stable-sha ordering promotion forgery labeled)\n' "$FILTER" >&2
|
|
847
|
+
printf 'ERROR: filter "%s" matched no cases (tags: unmerged merged grandfather human-verified signoff-tag stored-field exit-2 stable-sha ordering promotion forgery labeled squash)\n' "$FILTER" >&2
|
|
797
848
|
exit 2
|
|
798
849
|
fi
|
|
799
850
|
|
|
@@ -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 ]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"forge": {
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.1",
|
|
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
|
}
|