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.
Files changed (28) hide show
  1. package/bin/create-forge.js +6 -0
  2. package/package.json +1 -1
  3. package/template/.claude/hooks/forge-context-migrate.sh +317 -0
  4. package/template/.claude/hooks/forge-release-fold.sh +35 -2
  5. package/template/.claude/hooks/forge-size-gate.sh +175 -0
  6. package/template/.claude/hooks/forge-state-rollup.sh +135 -2
  7. package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
  8. package/template/.claude/hooks/tests/forge-context-migrate.test.sh +248 -0
  9. package/template/.claude/hooks/tests/forge-release-fold.test.sh +53 -2
  10. package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
  11. package/template/.claude/settings.json +5 -1
  12. package/template/.claude/skills/discussing/SKILL.md +30 -31
  13. package/template/.claude/skills/executing/SKILL.md +3 -3
  14. package/template/.claude/skills/forge/SKILL.md +112 -170
  15. package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
  16. package/template/.claude/skills/planning/SKILL.md +11 -9
  17. package/template/.claude/skills/researching/SKILL.md +2 -0
  18. package/template/.claude/skills/verifying/SKILL.md +2 -2
  19. package/template/.forge/FORGE.md +79 -91
  20. package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
  21. package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
  22. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
  23. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
  24. package/template/.forge/migrations/0.80.0-context-md-sharding.md +175 -0
  25. package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
  26. package/template/.forge/templates/constitution.md +1 -1
  27. package/template/.forge/templates/context-milestone.md +30 -0
  28. 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 (both are git-ignored render-on-read caches since 0.53.0):
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 reads it
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.68.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
  }