forge-orkes 0.71.0 → 0.73.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 (35) hide show
  1. package/bin/create-forge.js +6 -4
  2. package/experimental/m10/source/mcp-server/README.md +2 -2
  3. package/experimental/m10/source/mcp-server/index.js +2 -2
  4. package/experimental/m10/source/skills/orchestrating/SKILL.md +7 -3
  5. package/package.json +1 -1
  6. package/template/.claude/hooks/forge-model-outcome.sh +5 -3
  7. package/template/.claude/hooks/forge-reserve.sh +419 -106
  8. package/template/.claude/hooks/forge-slice-notify.sh +12 -3
  9. package/template/.claude/hooks/forge-slice-runner.sh +40 -19
  10. package/template/.claude/hooks/tests/forge-reserve.test.sh +169 -0
  11. package/template/.claude/skills/chief-of-staff/SKILL.md +1 -0
  12. package/template/.claude/skills/forge/SKILL.md +8 -6
  13. package/template/.claude/skills/notion-integration/SKILL.md +1 -0
  14. package/template/.claude/skills/planning/SKILL.md +3 -3
  15. package/template/.forge/FORGE.md +5 -5
  16. package/template/.forge/checks/forge-board-render.sh +21 -18
  17. package/template/.forge/checks/forge-check-lint.sh +48 -5
  18. package/template/.forge/checks/forge-hold-check.sh +123 -2
  19. package/template/.forge/checks/forge-jarvis-awareness.sh +62 -11
  20. package/template/.forge/checks/forge-jarvis-dispatch.sh +88 -8
  21. package/template/.forge/checks/forge-jarvis-relay.sh +15 -6
  22. package/template/.forge/checks/forge-jarvis.sh +28 -0
  23. package/template/.forge/checks/forge-roadmap-check.sh +106 -22
  24. package/template/.forge/checks/tests/forge-check-lint.test.sh +126 -4
  25. package/template/.forge/checks/tests/forge-hold-check.test.sh +166 -1
  26. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +53 -0
  27. package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +114 -0
  28. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +30 -0
  29. package/template/.forge/checks/tests/forge-jarvis.test.sh +78 -4
  30. package/template/.forge/checks/tests/forge-reserve.test.sh +235 -0
  31. package/template/.forge/checks/tests/forge-roadmap-check.test.sh +487 -0
  32. package/template/.forge/templates/hold-config.yml +15 -0
  33. package/template/.forge/templates/project.yml +10 -0
  34. package/template/.forge/templates/state/desire-path.yml +15 -45
  35. package/template/.forge/templates/waive-hold.yml +63 -0
@@ -339,6 +339,120 @@ else
339
339
  bad "dry-run rotation" "dry-run had side effects"
340
340
  fi
341
341
 
342
+ # 22. NEG: --work-dir refused loudly BEFORE the detach (R092) — a forwarded work dir
343
+ # would split-brain against the pinned notify.log/dispatch.out/rotation paths.
344
+ # Both arg forms; no slice.yml materialized, nothing dispatched.
345
+ WSLICE="$ROOT/workdir-wt"
346
+ mkdir -p "$WSLICE/.forge/phases/milestone-78/1-bbb"
347
+ printf 'x\n' > "$WSLICE/.forge/phases/milestone-78/1-bbb/plan-01.md"
348
+ err22="$(sh "$DISPATCH" --slice "$WSLICE" --work-dir "$ROOT/elsewhere" 2>&1 >/dev/null)"; rc=$?
349
+ err22b="$(sh "$DISPATCH" --slice "$WSLICE" --work-dir="$ROOT/elsewhere" 2>&1 >/dev/null)"; rc2=$?
350
+ if [ "$rc" = 2 ] && [ "$rc2" = 2 ] && [ ! -f "$WSLICE/slice.yml" ] \
351
+ && printf '%s' "$err22" | grep -q 'REFUSED' && printf '%s' "$err22b" | grep -q 'REFUSED'; then
352
+ ok "neg-control: --work-dir refused (both forms), nothing materialized"
353
+ else
354
+ bad "work-dir refusal" "rc=$rc rc2=$rc2 err=$err22"
355
+ fi
356
+
357
+ # --- 23-24. STALENESS GUARD (live 2026-07-23: an amended source plan never reached the
358
+ # materialized work order — plain re-dispatch silently rebuilt the OLD spec). The
359
+ # materializer fingerprints each bundled plan-NN.md (`# source: ... cksum=N`); a plain
360
+ # re-dispatch re-checks them and refuses loudly on divergence. Refuse, never auto-rebuild
361
+ # (the honored-as-is branch serves resume paths whose runner-work must survive).
362
+
363
+ # 23. NEG: amend a source plan after materialization → plain re-dispatch REFUSES loudly;
364
+ # slice.yml + runner-work untouched (no rotation — the operator decides)
365
+ ZSLICE="$ROOT/stale-wt"
366
+ mkdir -p "$ZSLICE/.forge/phases/milestone-77/1-aaa"
367
+ printf 'original spec\n' > "$ZSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
368
+ sh "$DISPATCH" --slice "$ZSLICE" --materialize-only >/dev/null 2>&1
369
+ mkdir -p "$ZSLICE/.forge/runner-work/pings" # seed AFTER — see case 19 note
370
+ touch "$ZSLICE/.forge/runner-work/pings/k-done.marker"
371
+ printf 'AMENDED spec\n' > "$ZSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
372
+ before23="$(cat "$ZSLICE/slice.yml")"
373
+ err23="$(sh "$DISPATCH" --slice "$ZSLICE" --materialize-only 2>&1)"; rc=$?
374
+ if [ "$rc" != 0 ] && printf '%s' "$err23" | grep -q 'REFUSED' && printf '%s' "$err23" | grep -q 'STALE' \
375
+ && printf '%s' "$err23" | grep -q '1-aaa/plan-01.md' \
376
+ && [ "$(cat "$ZSLICE/slice.yml")" = "$before23" ] \
377
+ && [ -f "$ZSLICE/.forge/runner-work/pings/k-done.marker" ] \
378
+ && grep -q 'original spec' "$ZSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
379
+ ok "staleness guard: amended source plan → plain re-dispatch refuses loudly, nothing touched"
380
+ else
381
+ bad "staleness guard" "rc=$rc err=$err23"
382
+ fi
383
+
384
+ # 23b. the remedy the refusal names: --plan re-materializes the amended spec (and case 20's
385
+ # unchanged-source plain re-dispatch already proves the guard stays quiet when fresh)
386
+ err23b="$(sh "$DISPATCH" --slice "$ZSLICE" --materialize-only --plan 1-aaa 2>&1)"; rc=$?
387
+ if [ "$rc" = 0 ] && grep -q 'AMENDED spec' "$ZSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
388
+ ok "staleness remedy: --plan rebuilds the work order to the amended spec"
389
+ else
390
+ bad "staleness remedy" "rc=$rc err=$err23b"
391
+ fi
392
+
393
+ # 24. NEG (lazy migration): a pre-guard materialized slice.yml (helper header, NO
394
+ # fingerprint lines) is still honored as-is — the guard never bricks an in-flight order
395
+ GSLICE="$ROOT/legacy-wt"
396
+ mkdir -p "$GSLICE/.forge/phases/milestone-77/1-aaa" "$GSLICE/phases/1-aaa"
397
+ printf 'x\n' > "$GSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
398
+ printf 'x\n' > "$GSLICE/phases/1-aaa/plan.md"
399
+ printf '# Materialized by forge-jarvis-dispatch from .forge/phases/milestone-77 (delete to re-materialize)\nphases:\n - 1-aaa\n' > "$GSLICE/slice.yml"
400
+ err24="$(sh "$DISPATCH" --slice "$GSLICE" --materialize-only 2>&1)"; rc=$?
401
+ if [ "$rc" = 0 ] && printf '%s' "$err24" | grep -q 'using it as-is'; then
402
+ ok "lazy migration: pre-guard materialized slice.yml (no fingerprints) honored as-is"
403
+ else
404
+ bad "legacy materialized order" "rc=$rc err=$err24"
405
+ fi
406
+
407
+ # --- 25-27. BARE BRANCH LOOSENING (issue #25): the branch-derived milestone-dir pick also
408
+ # matches a bare m-<id>-slug / m<digits>-slug branch (no forge/ prefix) — same match family
409
+ # as awareness/relay. A bare slug can carry several descriptive words, so the pick walks
410
+ # truncations of the last hyphen-segment until an existing milestone dir is found.
411
+
412
+ # 25. bare branch m-176-marcy-mcp (hyphenated id + 2-word slug) picks milestone-176 among
413
+ # several dirs
414
+ BSLICE="$ROOT/bare-wt"
415
+ mkdir -p "$BSLICE/.forge/phases/milestone-176/1-num" "$BSLICE/.forge/phases/milestone-5/1-decoy"
416
+ printf 'bare plan\n' > "$BSLICE/.forge/phases/milestone-176/1-num/plan-01.md"
417
+ printf 'decoy\n' > "$BSLICE/.forge/phases/milestone-5/1-decoy/plan-01.md"
418
+ mkbranch "$BSLICE" m-176-marcy-mcp
419
+ err25="$(sh "$DISPATCH" --slice "$BSLICE" --materialize-only 2>&1)"; rc=$?
420
+ if [ "$rc" = 0 ] && grep -q '^ - 1-num$' "$BSLICE/slice.yml" 2>/dev/null \
421
+ && grep -q 'bare plan' "$BSLICE/phases/1-num/plan.md" 2>/dev/null && [ ! -d "$BSLICE/phases/1-decoy" ]; then
422
+ ok "bare branch m-176-marcy-mcp: branch picks milestone-176 among several dirs (no forge/ prefix)"
423
+ else
424
+ bad "bare-branch pick (hyphenated id)" "rc=$rc err=$err25"
425
+ fi
426
+
427
+ # 26. bare branch m167-close-sync (no hyphen after m, 2-word slug) picks milestone-167
428
+ CSLICE="$ROOT/bare-wt2"
429
+ mkdir -p "$CSLICE/.forge/phases/milestone-167/1-num" "$CSLICE/.forge/phases/milestone-9/1-decoy"
430
+ printf 'bare plan\n' > "$CSLICE/.forge/phases/milestone-167/1-num/plan-01.md"
431
+ printf 'decoy\n' > "$CSLICE/.forge/phases/milestone-9/1-decoy/plan-01.md"
432
+ mkbranch "$CSLICE" m167-close-sync
433
+ err26="$(sh "$DISPATCH" --slice "$CSLICE" --materialize-only 2>&1)"; rc=$?
434
+ if [ "$rc" = 0 ] && grep -q '^ - 1-num$' "$CSLICE/slice.yml" 2>/dev/null \
435
+ && grep -q 'bare plan' "$CSLICE/phases/1-num/plan.md" 2>/dev/null && [ ! -d "$CSLICE/phases/1-decoy" ]; then
436
+ ok "bare branch m167-close-sync: branch picks milestone-167 among several dirs (m immediately followed by digits)"
437
+ else
438
+ bad "bare-branch pick (numeric, no hyphen)" "rc=$rc err=$err26"
439
+ fi
440
+
441
+ # 27. GUARD: a bare-number branch (fix-issue-2, no m- token) does NOT resolve — several
442
+ # milestone dirs with no branch-derived pick still refuses as ambiguous
443
+ NSLICE="$ROOT/guard-wt"
444
+ mkdir -p "$NSLICE/.forge/phases/milestone-1/1-a" "$NSLICE/.forge/phases/milestone-2/1-b"
445
+ printf 'a\n' > "$NSLICE/.forge/phases/milestone-1/1-a/plan-01.md"
446
+ printf 'b\n' > "$NSLICE/.forge/phases/milestone-2/1-b/plan-01.md"
447
+ mkbranch "$NSLICE" fix-issue-2
448
+ err27="$(sh "$DISPATCH" --slice "$NSLICE" --materialize-only 2>&1)"; rc=$?
449
+ if [ "$rc" != 0 ] && [ ! -f "$NSLICE/slice.yml" ] && printf '%s' "$err27" | grep -q 'REFUSED' \
450
+ && printf '%s' "$err27" | grep -q 'no forge/m-\* or bare m-<id> branch'; then
451
+ ok "guard: bare-number branch fix-issue-2 (no m- token) refuses ambiguous pick, names both accepted forms"
452
+ else
453
+ bad "guard: bare-number branch fix-issue-2" "rc=$rc err=$err27"
454
+ fi
455
+
342
456
  # --- summary -----------------------------------------------------------------
343
457
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
344
458
  [ "$FAILED" = 0 ]
@@ -130,6 +130,36 @@ case "$out" in
130
130
  *) bad "catchup summary" "no while-you-were-away line: $out" ;;
131
131
  esac
132
132
 
133
+ # --- 14-15. BARE BRANCH LOOSENING (issue #25): --list-logs glob against REAL git worktrees on
134
+ # bare m-<id>-slug branches (no forge/ prefix) — --list-logs only needs the notify-log
135
+ # PATH, no id derivation, so this pins the match extension alone (FORGE_JARVIS_REPO_ROOT
136
+ # points the glob at a throwaway fixture repo). --------------------------------------------
137
+ GITROOT="$ROOT/git-fixture"; mkdir -p "$GITROOT/main"
138
+ (
139
+ cd "$GITROOT/main" || exit 1
140
+ git init -q .
141
+ git config user.email t@t.com; git config user.name test
142
+ : > .gitkeep; git add .gitkeep; git commit -q -m init
143
+ git worktree add -q -b m-176-marcy-mcp "$GITROOT/bare-1" >/dev/null 2>&1
144
+ git worktree add -q -b fix-issue-2 "$GITROOT/guard-1" >/dev/null 2>&1
145
+ ) >/dev/null 2>&1
146
+
147
+ out="$(FORGE_JARVIS_REPO_ROOT="$GITROOT/main" sh "$RELAY" --list-logs 2>/dev/null)"
148
+
149
+ # 14. bare branch m-176-marcy-mcp emits its notify-log path (pings reach the phone)
150
+ if printf '%s' "$out" | grep -q "$GITROOT/bare-1/.forge/runner-work/notify.log"; then
151
+ ok "bare branch m-176-marcy-mcp: --list-logs emits its notify-log path"
152
+ else
153
+ bad "bare branch --list-logs path" "$out"
154
+ fi
155
+
156
+ # 15. GUARD: a bare-number branch (fix-issue-2, no m- token) emits NO log path
157
+ if ! printf '%s' "$out" | grep -q 'guard-1'; then
158
+ ok "guard: bare-number branch fix-issue-2 (no m- token) emits no log path"
159
+ else
160
+ bad "guard: bare-number branch fix-issue-2 (no m- token) emits no log path" "$out"
161
+ fi
162
+
133
163
  # --- summary -----------------------------------------------------------------
134
164
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
135
165
  [ "$FAILED" = 0 ]
@@ -13,11 +13,17 @@
13
13
  # mode (post-UAT 2026-07-22): jarvis.spawn_permission_mode appends --permission-mode,
14
14
  # env FORGE_JARVIS_SPAWN_PERMISSION_MODE beats config, unset = no flag. Refusals:
15
15
  # jarvis.enabled: false, unknown spawn_permission_mode, or not a git repo →
16
- # non-zero exit, NO command on stdout.
16
+ # non-zero exit, NO command on stdout. Double-start guard (firstmate-review
17
+ # adoption 2026-07-22): on REAL launch a live `claude remote-control` already
18
+ # holding the resolved sticky name → refusal naming the live pid; exact --name
19
+ # token match (a live prefix-sibling never blocks); unreadable ps → warn +
20
+ # proceed. --dry-run never runs the guard.
17
21
  #
18
- # Builds throwaway git repos in a mktemp dir, offline by construction; only ever
19
- # invokes --dry-run, so `claude`/`caffeinate` need not exist (dry-run is pure
20
- # resolution — that determinism is itself part of the contract).
22
+ # Builds throwaway git repos in a mktemp dir, offline by construction; dry-run
23
+ # cases need no `claude`/`caffeinate` (dry-run is pure resolution — that
24
+ # determinism is itself part of the contract), and the real-launch guard cases
25
+ # stub the ps read via FORGE_JARVIS_PS and put a fake `claude` first on PATH so
26
+ # fall-through provably reaches the launch without ever starting a real server.
21
27
  #
22
28
  # Usage: ./.forge/checks/tests/forge-jarvis.test.sh
23
29
  # RED-phase friendly: a missing/non-executable script fails every case, exits non-zero.
@@ -176,6 +182,74 @@ else
176
182
  bad "neg-control non-repo" "rc=$rc out=$out"
177
183
  fi
178
184
 
185
+ # --- double-start guard (real launch; firstmate-review adoption 2026-07-22) ---
186
+ # Reset the fixture to a clean enabled config (sticky base is jarvis-proj again).
187
+ cat > "$REPO/.forge/project.yml" <<'EOF'
188
+ jarvis:
189
+ enabled: true
190
+ EOF
191
+ # Fake `claude` first on PATH: fall-through cases provably REACH the launch
192
+ # (marker file written) without ever starting a real server. caffeinate, when
193
+ # present, resolves `claude` through the same inherited PATH → still the fake.
194
+ FAKEBIN="$ROOT/fakebin"
195
+ mkdir -p "$FAKEBIN"
196
+ cat > "$FAKEBIN/claude" <<EOF
197
+ #!/bin/sh
198
+ printf '%s\n' "\$*" > "$ROOT/launch.args"
199
+ exit 0
200
+ EOF
201
+ chmod +x "$FAKEBIN/claude"
202
+
203
+ # 13. a live same-name server → refusal naming the live pid, nothing launched
204
+ STUB_DUP="$ROOT/stub-ps-dup"
205
+ cat > "$STUB_DUP" <<'EOF'
206
+ #!/bin/sh
207
+ printf ' 4242 caffeinate -s claude remote-control --name jarvis-proj --spawn worktree --capacity 4\n'
208
+ printf ' 4243 claude remote-control --name jarvis-proj --spawn worktree --capacity 4\n'
209
+ EOF
210
+ chmod +x "$STUB_DUP"
211
+ rm -f "$ROOT/launch.args"
212
+ out="$(cd "$REPO" && FORGE_JARVIS_PS="$STUB_DUP" PATH="$FAKEBIN:$PATH" sh "$JARVIS" 2>/dev/null)"; rc=$?
213
+ err="$(cd "$REPO" && FORGE_JARVIS_PS="$STUB_DUP" PATH="$FAKEBIN:$PATH" sh "$JARVIS" 2>&1 >/dev/null)"
214
+ if [ "$rc" != 0 ] && [ -z "$out" ] && [ ! -f "$ROOT/launch.args" ]; then
215
+ case "$err" in
216
+ *jarvis-proj*4242*) ok "double-start guard: live same-name server → refusal naming the live pid" ;;
217
+ *) bad "double-start refusal message" "err=$err" ;;
218
+ esac
219
+ else
220
+ bad "double-start guard" "rc=$rc out=$out launched=$([ -f "$ROOT/launch.args" ] && echo yes || echo no)"
221
+ fi
222
+
223
+ # 14. NEG: prefix near-miss (jarvis-proj-personal live) must NOT trip the guard —
224
+ # exact --name token match; fall-through provably reaches the launch
225
+ STUB_NEAR="$ROOT/stub-ps-nearmiss"
226
+ cat > "$STUB_NEAR" <<'EOF'
227
+ #!/bin/sh
228
+ printf ' 5151 caffeinate -s claude remote-control --name jarvis-proj-personal --spawn worktree --capacity 4\n'
229
+ printf ' 5152 vim notes-on-remote-control.md\n'
230
+ EOF
231
+ chmod +x "$STUB_NEAR"
232
+ rm -f "$ROOT/launch.args"
233
+ out="$(cd "$REPO" && FORGE_JARVIS_PS="$STUB_NEAR" PATH="$FAKEBIN:$PATH" sh "$JARVIS" 2>/dev/null)"; rc=$?
234
+ if [ "$rc" = 0 ] && [ -f "$ROOT/launch.args" ] && grep -q -- "--name jarvis-proj --spawn" "$ROOT/launch.args"; then
235
+ ok "neg-control: live prefix-sibling name does not block — exact token match, launch reached"
236
+ else
237
+ bad "neg-control prefix near-miss" "rc=$rc launched=$([ -f "$ROOT/launch.args" ] && cat "$ROOT/launch.args" || echo no)"
238
+ fi
239
+
240
+ # 15. unreadable ps → warn + proceed (advisory hygiene, never a hard fail)
241
+ rm -f "$ROOT/launch.args"
242
+ out="$(cd "$REPO" && FORGE_JARVIS_PS="$ROOT/no-such-stub" PATH="$FAKEBIN:$PATH" sh "$JARVIS" 2>/dev/null)"; rc=$?
243
+ err="$(cd "$REPO" && FORGE_JARVIS_PS="$ROOT/no-such-stub" PATH="$FAKEBIN:$PATH" sh "$JARVIS" 2>&1 >/dev/null)"
244
+ if [ "$rc" = 0 ] && [ -f "$ROOT/launch.args" ]; then
245
+ case "$err" in
246
+ *"duplicate-door check unavailable"*) ok "unreadable ps → stated warning + launch proceeds (fail-open, explicit)" ;;
247
+ *) bad "ps-failure warning wording" "err=$err" ;;
248
+ esac
249
+ else
250
+ bad "ps-failure fail-open" "rc=$rc launched=$([ -f "$ROOT/launch.args" ] && echo yes || echo no)"
251
+ fi
252
+
179
253
  # --- summary -----------------------------------------------------------------
180
254
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
181
255
  [ "$FAILED" = 0 ]
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env sh
2
+ # Fixture-repo test suite for .claude/hooks/forge-reserve.sh (m-41, plan 81-01).
3
+ #
4
+ # Contract under test (ADR-021/023/029 + FR-236/237/238, NFR-045):
5
+ # phase — computed P-0NN; in-tree scan = live roadmap `- id: N` ints ∪ archived
6
+ # phase dirs .forge/archive/milestone-*/phases/<N>-* (burned ids never
7
+ # reallocated); ledger + main:reservations.yml floors as for all kinds.
8
+ # wave — computed W-0NN; in-tree scan = live waves-map KEYS only (never the
9
+ # phase ids inside the brackets).
10
+ # version — RECORD-ONLY (--value): clean claim dual-writes ledger +
11
+ # reservations.yml and echoes the value; the SAME value claimed by a
12
+ # DIFFERENT milestone → exit 1, stderr names the holder, nothing
13
+ # written; re-claim by the SAME milestone → exit 0, idempotent, no
14
+ # duplicate line; missing --value → usage exit 2, nothing written.
15
+ # regression (NFR-045) — the pre-existing kinds' ids and file shapes are
16
+ # unchanged; the lock releases so back-to-back calls never time out.
17
+ #
18
+ # Every case builds its own mktemp fixture repo (real `git init`) — the suite
19
+ # never touches the enclosing dev repo's state. Output: one line per case,
20
+ # `case N <tag> PASS` or `case N <tag> FAIL: <why>`; non-zero exit if any FAIL.
21
+ #
22
+ # Usage: sh .forge/checks/tests/forge-reserve.test.sh
23
+ set -u
24
+
25
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
26
+ RESERVE="$SCRIPT_DIR/../../../.claude/hooks/forge-reserve.sh"
27
+ [ -f "$RESERVE" ] || { printf 'FATAL: %s missing\n' "$RESERVE" >&2; exit 1; }
28
+
29
+ GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
30
+ export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
31
+
32
+ ROOT="$(mktemp -d)"
33
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
34
+
35
+ fails=0
36
+ n=0
37
+
38
+ # new_fixture <name> → prints the fixture repo path
39
+ new_fixture() {
40
+ fx="$ROOT/$1"
41
+ mkdir -p "$fx"
42
+ git -C "$fx" init -q
43
+ printf '%s' "$fx"
44
+ }
45
+
46
+ report() { # report <tag> <pass|fail> [why]
47
+ n=$((n + 1))
48
+ if [ "$2" = pass ]; then
49
+ printf 'case %d %s PASS\n' "$n" "$1"
50
+ else
51
+ printf 'case %d %s FAIL: %s\n' "$n" "$1" "${3:-unspecified}"
52
+ fails=$((fails + 1))
53
+ fi
54
+ }
55
+
56
+ # ── FR-236: phase kind ────────────────────────────────────────────────────────
57
+
58
+ # case 1 phase-archive-aware — roadmap max 12, archived phase dir 14 → P-015
59
+ fx=$(new_fixture c1)
60
+ mkdir -p "$fx/.forge/archive/milestone-9/phases/14-old"
61
+ printf 'roadmap:\n phases:\n - id: 11\n - id: 12\n' > "$fx/.forge/roadmap.yml"
62
+ out=$(cd "$fx" && "$RESERVE" phase --milestone m-t 2>/dev/null)
63
+ if [ "$out" = "P-015" ]; then report phase-archive-aware pass
64
+ else report phase-archive-aware fail "expected P-015 (archived 14 must stay burned), got: $out"; fi
65
+
66
+ # case 2 phase-dual-write — case 1's claim landed in BOTH registries
67
+ if grep -q "phase P-015 m-t" "$fx/.git/forge/id-reservations" 2>/dev/null \
68
+ && grep -q "id: P-015" "$fx/.forge/reservations.yml" 2>/dev/null; then
69
+ report phase-dual-write pass
70
+ else report phase-dual-write fail "P-015 missing from ledger or reservations.yml"; fi
71
+
72
+ # case 3 phase-sibling-ledger — uncommitted sibling ledger claim is visible
73
+ fx=$(new_fixture c3)
74
+ mkdir -p "$fx/.forge" "$fx/.git/forge"
75
+ printf 'roadmap:\n phases:\n - id: 12\n' > "$fx/.forge/roadmap.yml"
76
+ printf 'phase\tP-015\tm-sib\t2026-07-23T00:00:00Z\n' >> "$fx/.git/forge/id-reservations"
77
+ out=$(cd "$fx" && "$RESERVE" phase --milestone m-t 2>/dev/null)
78
+ if [ "$out" = "P-016" ]; then report phase-sibling-ledger pass
79
+ else report phase-sibling-ledger fail "sibling claim P-015 ignored — expected P-016, got: $out"; fi
80
+
81
+ # case 4 phase-fresh — no roadmap, no archive → P-001
82
+ fx=$(new_fixture c4)
83
+ out=$(cd "$fx" && "$RESERVE" phase --milestone m-t 2>/dev/null)
84
+ if [ "$out" = "P-001" ]; then report phase-fresh pass
85
+ else report phase-fresh fail "fresh repo expected P-001, got: $out"; fi
86
+
87
+ # case 5 phase-wave-keys-not-counted — waves KEYS and bracket ids must not leak
88
+ # into the phase max: roadmap phase max 3, waves key 9 pointing at [3] → P-004.
89
+ fx=$(new_fixture c5)
90
+ mkdir -p "$fx/.forge"
91
+ printf 'roadmap:\n phases:\n - id: 3\n waves:\n 9: [3]\n' > "$fx/.forge/roadmap.yml"
92
+ out=$(cd "$fx" && "$RESERVE" phase --milestone m-t 2>/dev/null)
93
+ if [ "$out" = "P-004" ]; then report phase-wave-keys-not-counted pass
94
+ else report phase-wave-keys-not-counted fail "expected P-004 (wave key 9 is not a phase id), got: $out"; fi
95
+
96
+ # ── FR-237: wave kind ────────────────────────────────────────────────────────
97
+
98
+ # case 6 wave-max-key — waves keys 1,7 (bracket ids larger) → W-008
99
+ fx=$(new_fixture c6)
100
+ mkdir -p "$fx/.forge"
101
+ printf 'roadmap:\n phases:\n - id: 1\n waves:\n 1: [40]\n 7: [41]\n' > "$fx/.forge/roadmap.yml"
102
+ out=$(cd "$fx" && "$RESERVE" wave --milestone m-t 2>/dev/null)
103
+ if [ "$out" = "W-008" ]; then report wave-max-key pass
104
+ else report wave-max-key fail "expected W-008 (max KEY 7, ids in brackets ignored), got: $out"; fi
105
+
106
+ # case 7 wave-fresh — no waves block anywhere → W-001
107
+ fx=$(new_fixture c7)
108
+ out=$(cd "$fx" && "$RESERVE" wave --milestone m-t 2>/dev/null)
109
+ if [ "$out" = "W-001" ]; then report wave-fresh pass
110
+ else report wave-fresh fail "expected W-001, got: $out"; fi
111
+
112
+ # ── FR-238: version kind (record-only) ───────────────────────────────────────
113
+
114
+ # case 8 version-clean-claim — records in BOTH registries, echoes the value
115
+ fx=$(new_fixture c8)
116
+ out=$(cd "$fx" && "$RESERVE" version --value 0.9.0 --milestone m-a 2>/dev/null)
117
+ if [ "$out" = "0.9.0" ] \
118
+ && grep -q "version 0.9.0 m-a" "$fx/.git/forge/id-reservations" 2>/dev/null \
119
+ && grep -q 'id: 0.9.0' "$fx/.forge/reservations.yml" 2>/dev/null; then
120
+ report version-clean-claim pass
121
+ else report version-clean-claim fail "claim not recorded in both registries (stdout: $out)"; fi
122
+
123
+ # case 9 version-conflict-refused — different milestone, same value → exit 1,
124
+ # stderr names the holder, NOTHING written
125
+ err=$(cd "$fx" && "$RESERVE" version --value 0.9.0 --milestone m-b 2>&1 >/dev/null)
126
+ rc=$?
127
+ lines=$(grep -c '^version' "$fx/.git/forge/id-reservations" 2>/dev/null)
128
+ case "$err" in
129
+ *m-a*) named=yes ;;
130
+ *) named=no ;;
131
+ esac
132
+ if [ "$rc" -ne 0 ] && [ "$named" = yes ] && [ "$lines" = "1" ]; then
133
+ report version-conflict-refused pass
134
+ else report version-conflict-refused fail "rc=$rc named-holder=$named ledger-version-lines=$lines (want rc!=0, holder m-a named, 1 line)"; fi
135
+
136
+ # case 10 version-idempotent-reclaim — same milestone re-claims → exit 0, no dup
137
+ out=$(cd "$fx" && "$RESERVE" version --value 0.9.0 --milestone m-a 2>/dev/null)
138
+ rc=$?
139
+ lines=$(grep -c '^version' "$fx/.git/forge/id-reservations" 2>/dev/null)
140
+ if [ "$rc" -eq 0 ] && [ "$out" = "0.9.0" ] && [ "$lines" = "1" ]; then
141
+ report version-idempotent-reclaim pass
142
+ else report version-idempotent-reclaim fail "rc=$rc stdout=$out ledger-version-lines=$lines (want 0 / 0.9.0 / 1)"; fi
143
+
144
+ # case 11 version-missing-value — usage exit 2, nothing written
145
+ fx=$(new_fixture c11)
146
+ err=$(cd "$fx" && "$RESERVE" version --milestone m-a 2>&1 >/dev/null)
147
+ rc=$?
148
+ if [ "$rc" -eq 2 ] && [ ! -f "$fx/.git/forge/id-reservations" ] && [ ! -f "$fx/.forge/reservations.yml" ]; then
149
+ report version-missing-value pass
150
+ else report version-missing-value fail "rc=$rc (want 2) or files written on usage error"; fi
151
+
152
+ # ── NFR-045: existing kinds regression + lock release ────────────────────────
153
+
154
+ # case 12 regression-existing-kinds — fr x2 / milestone / refactor sequence and
155
+ # reservations.yml shape identical to pre-m-41 behavior
156
+ fx=$(new_fixture c12)
157
+ a=$(cd "$fx" && "$RESERVE" fr --milestone m-t 2>/dev/null)
158
+ b=$(cd "$fx" && "$RESERVE" fr --milestone m-t 2>/dev/null)
159
+ m=$(cd "$fx" && "$RESERVE" milestone --milestone m-new 2>/dev/null)
160
+ r=$(cd "$fx" && "$RESERVE" refactor --milestone m-t 2>/dev/null)
161
+ if [ "$a $b $m $r" = "FR-001 FR-002 M-1 R001" ] \
162
+ && grep -q '^reservations:' "$fx/.forge/reservations.yml" 2>/dev/null \
163
+ && grep -q 'kind: refactor' "$fx/.forge/reservations.yml" 2>/dev/null; then
164
+ report regression-existing-kinds pass
165
+ else report regression-existing-kinds fail "expected FR-001 FR-002 M-1 R001 + seeded header, got: $a $b $m $r"; fi
166
+
167
+ # case 13 regression-lock-release — back-to-back calls in one fixture must not
168
+ # time out (a leaked lock would make the second call exit 3)
169
+ fx=$(new_fixture c13)
170
+ (cd "$fx" && "$RESERVE" adr --milestone m-t >/dev/null 2>&1)
171
+ out=$(cd "$fx" && FORGE_RESERVE_WAIT=2 "$RESERVE" adr --milestone m-t 2>/dev/null)
172
+ rc=$?
173
+ if [ "$rc" -eq 0 ] && [ "$out" = "ADR-002" ]; then
174
+ report regression-lock-release pass
175
+ else report regression-lock-release fail "second call rc=$rc out=$out (leaked lock?)"; fi
176
+
177
+ # ── input hardening (review, m-41): validation before the lock, nothing written ─
178
+
179
+ # case 14 version-value-rejects-non-semver — --value must be X.Y.Z; a non-semver
180
+ # (here a YAML-injection payload) → exit 2, NOTHING written
181
+ fx=$(new_fixture c14)
182
+ err=$(cd "$fx" && "$RESERVE" version --value '9.9.9
183
+ injected: pwned' --milestone m-a 2>&1 >/dev/null)
184
+ rc=$?
185
+ if [ "$rc" -eq 2 ] && [ ! -f "$fx/.git/forge/id-reservations" ] && [ ! -f "$fx/.forge/reservations.yml" ]; then
186
+ report version-value-rejects-non-semver pass
187
+ else report version-value-rejects-non-semver fail "rc=$rc (want 2) or files written on bad --value"; fi
188
+
189
+ # case 15 version-value-accepts-prerelease — X.Y.Z-pre is valid semver → recorded
190
+ fx=$(new_fixture c15)
191
+ out=$(cd "$fx" && "$RESERVE" version --value 1.2.0-rc.1 --milestone m-a 2>/dev/null)
192
+ if [ "$out" = "1.2.0-rc.1" ]; then report version-value-accepts-prerelease pass
193
+ else report version-value-accepts-prerelease fail "expected 1.2.0-rc.1 recorded, got: $out"; fi
194
+
195
+ # case 16 milestone-rejects-control-char — a newline in --milestone would split
196
+ # the ledger row / inject a sibling YAML key → exit 2, NOTHING written
197
+ fx=$(new_fixture c16)
198
+ err=$(cd "$fx" && "$RESERVE" fr --milestone 'm-1
199
+ injected: pwned' 2>&1 >/dev/null)
200
+ rc=$?
201
+ if [ "$rc" -eq 2 ] && [ ! -f "$fx/.git/forge/id-reservations" ] && [ ! -f "$fx/.forge/reservations.yml" ]; then
202
+ report milestone-rejects-control-char pass
203
+ else report milestone-rejects-control-char fail "rc=$rc (want 2) or files written on control-char milestone"; fi
204
+
205
+ # case 17 symlink-target-refused — reservations.yml is a symlink → refuse to
206
+ # write through it, exit 2, and the symlink target stays untouched
207
+ fx=$(new_fixture c17)
208
+ mkdir -p "$fx/.forge"
209
+ : > "$ROOT/c17-victim.txt"
210
+ ln -s "$ROOT/c17-victim.txt" "$fx/.forge/reservations.yml"
211
+ err=$(cd "$fx" && "$RESERVE" fr --milestone m-t 2>&1 >/dev/null)
212
+ rc=$?
213
+ if [ "$rc" -eq 2 ] && [ ! -s "$ROOT/c17-victim.txt" ]; then
214
+ report symlink-target-refused pass
215
+ else report symlink-target-refused fail "rc=$rc (want 2) or victim written through symlink"; fi
216
+
217
+ # case 18 main-floor — cross-machine/cold-start floor: main:reservations.yml
218
+ # carries a merged P-020, empty ledger + roadmap → next phase is P-021
219
+ fx=$(new_fixture c18)
220
+ mkdir -p "$fx/.forge"
221
+ printf 'reservations:\n - kind: phase\n id: P-020\n milestone: m-old\n' > "$fx/.forge/reservations.yml"
222
+ git -C "$fx" add .forge/reservations.yml >/dev/null 2>&1
223
+ git -C "$fx" -c user.email=t@t -c user.name=t commit -qm seed >/dev/null 2>&1
224
+ git -C "$fx" branch -M main >/dev/null 2>&1
225
+ out=$(cd "$fx" && "$RESERVE" phase --milestone m-t 2>/dev/null)
226
+ if [ "$out" = "P-021" ]; then report main-floor pass
227
+ else report main-floor fail "main floor P-020 ignored — expected P-021, got: $out"; fi
228
+
229
+ # ── summary ──────────────────────────────────────────────────────────────────
230
+ if [ "$fails" -eq 0 ]; then
231
+ printf 'forge-reserve.test.sh: all %d cases PASS\n' "$n"
232
+ exit 0
233
+ fi
234
+ printf 'forge-reserve.test.sh: %d of %d cases FAILED\n' "$fails" "$n" >&2
235
+ exit 1