forge-orkes 0.70.0 → 0.72.2

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.
@@ -192,6 +192,217 @@ else
192
192
  bad "numeric-id branch pick" "rc=$rc err=$err14"
193
193
  fi
194
194
 
195
+ # --- 15-18. --plan WORK-ORDER SCOPING (FR-234; canvaz desire path 2026-07-22 defect 2:
196
+ # materialization bundled ALL phase dirs + ALL plan-NN.md, so a mid-milestone re-dispatch
197
+ # re-bundled landed work). --plan <phase> takes a whole phase, <phase>/plan-NN one plan
198
+ # file; unknown specs and hand-authored slice.yml refuse loudly. mktemp fixtures — a
199
+ # single milestone dir exercises the (branch-free) fallback pick.
200
+
201
+ # 15. single-plan spec: only the named plan file lands in the phase's plan.md
202
+ PSLICE="$ROOT/plan-one-wt"
203
+ mkdir -p "$PSLICE/.forge/phases/milestone-77/1-aaa"
204
+ printf 'LANDED plan one\n' > "$PSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
205
+ printf 'NEXT plan two\n' > "$PSLICE/.forge/phases/milestone-77/1-aaa/plan-02.md"
206
+ err15="$(sh "$DISPATCH" --slice "$PSLICE" --materialize-only --plan 1-aaa/plan-02 2>&1)"; rc=$?
207
+ if [ "$rc" = 0 ] && grep -q '^ - 1-aaa$' "$PSLICE/slice.yml" 2>/dev/null \
208
+ && grep -q 'NEXT plan two' "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null \
209
+ && ! grep -q 'LANDED plan one' "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
210
+ ok "--plan <phase>/plan-NN scopes to exactly that plan (landed sibling NOT re-bundled)"
211
+ else
212
+ bad "--plan single-plan scoping" "rc=$rc err=$err15 plan.md=$(cat "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null)"
213
+ fi
214
+
215
+ # 16. whole-phase specs + ordering: numeric slice order regardless of flag order; unselected
216
+ # phases do NOT materialize
217
+ OSLICE="$ROOT/plan-order-wt"
218
+ mkdir -p "$OSLICE/.forge/phases/milestone-77/71-aaa" "$OSLICE/.forge/phases/milestone-77/72-bbb" "$OSLICE/.forge/phases/milestone-77/73-ccc"
219
+ printf 'plan A\n' > "$OSLICE/.forge/phases/milestone-77/71-aaa/plan-01.md"
220
+ printf 'plan B\n' > "$OSLICE/.forge/phases/milestone-77/72-bbb/plan-01.md"
221
+ printf 'decoy\n' > "$OSLICE/.forge/phases/milestone-77/73-ccc/plan-01.md"
222
+ err16="$(sh "$DISPATCH" --slice "$OSLICE" --materialize-only --plan 72-bbb --plan 71-aaa 2>&1)"; rc=$?
223
+ if [ "$rc" = 0 ] \
224
+ && [ "$(grep -E '^ - ' "$OSLICE/slice.yml" 2>/dev/null | tr -d ' -')" = "71aaa
225
+ 72bbb" ] \
226
+ && grep -q 'plan A' "$OSLICE/phases/71-aaa/plan.md" 2>/dev/null \
227
+ && grep -q 'plan B' "$OSLICE/phases/72-bbb/plan.md" 2>/dev/null \
228
+ && [ ! -d "$OSLICE/phases/73-ccc" ]; then
229
+ ok "--plan whole-phase specs keep numeric order (flag order ignored); unselected phase skipped"
230
+ else
231
+ bad "--plan ordering" "rc=$rc err=$err16 slice.yml=$(cat "$OSLICE/slice.yml" 2>/dev/null)"
232
+ fi
233
+
234
+ # 17. NEG: unknown spec → loud refusal, no slice.yml (a typo must not dispatch the wrong order)
235
+ USLICE="$ROOT/plan-unknown-wt"
236
+ mkdir -p "$USLICE/.forge/phases/milestone-77/1-aaa"
237
+ printf 'x\n' > "$USLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
238
+ err17="$(sh "$DISPATCH" --slice "$USLICE" --materialize-only --plan 2-nope 2>&1)"; rc=$?
239
+ if [ "$rc" != 0 ] && [ ! -f "$USLICE/slice.yml" ] && printf '%s' "$err17" | grep -q 'REFUSED'; then
240
+ ok "neg-control: unknown --plan spec → loud refusal, no slice.yml"
241
+ else
242
+ bad "unknown --plan spec" "rc=$rc err=$err17"
243
+ fi
244
+
245
+ # 18. NEG: --plan vs a HAND-AUTHORED slice.yml (no materializer header) → refusal; slice.yml
246
+ # byte-unchanged, seeded runner-work untouched (hand-authored work orders win, always)
247
+ HPSLICE="$ROOT/plan-hand-wt"
248
+ mkdir -p "$HPSLICE/.forge/phases/milestone-77/1-aaa" "$HPSLICE/.forge/runner-work/pings"
249
+ printf 'x\n' > "$HPSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
250
+ printf 'phases:\n - hand-rolled\n' > "$HPSLICE/slice.yml"
251
+ touch "$HPSLICE/.forge/runner-work/pings/k-done.marker"
252
+ before18="$(cat "$HPSLICE/slice.yml")"
253
+ err18="$(sh "$DISPATCH" --slice "$HPSLICE" --materialize-only --plan 1-aaa 2>&1)"; rc=$?
254
+ if [ "$rc" != 0 ] && printf '%s' "$err18" | grep -q 'REFUSED' \
255
+ && [ "$(cat "$HPSLICE/slice.yml")" = "$before18" ] \
256
+ && [ -f "$HPSLICE/.forge/runner-work/pings/k-done.marker" ]; then
257
+ ok "neg-control: --plan refuses a hand-authored slice.yml; nothing touched"
258
+ else
259
+ bad "--plan vs hand-authored" "rc=$rc err=$err18"
260
+ fi
261
+
262
+ # 18b. NEG (path hygiene): a plan part carrying a separator (traversal shape) refuses —
263
+ # sh case patterns let * match /, so plan-../x.md would pass the plan-*.md form check
264
+ # and reach outside the milestone dir without this guard.
265
+ TSLICE="$ROOT/plan-traverse-wt"
266
+ mkdir -p "$TSLICE/.forge/phases/milestone-77/1-aaa"
267
+ printf 'x\n' > "$TSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
268
+ err18b="$(sh "$DISPATCH" --slice "$TSLICE" --materialize-only --plan '1-aaa/plan-../x' 2>&1)"; rc=$?
269
+ if [ "$rc" != 0 ] && [ ! -f "$TSLICE/slice.yml" ] && printf '%s' "$err18b" | grep -q 'REFUSED'; then
270
+ ok "neg-control: --plan file part with a path separator → loud refusal (no traversal)"
271
+ else
272
+ bad "--plan traversal guard" "rc=$rc err=$err18b"
273
+ fi
274
+
275
+ # --- 19-21. RUNNER-WORK ROTATION (FR-235; canvaz desire path 2026-07-22 defect 3: stale
276
+ # pings/{key}-{kind}.marker silenced the new run's done ping and index-keyed
277
+ # phase-{idx}/phase-report.yml verdict:done short-circuited the new phase). A NEW work
278
+ # order rotates runner-work WHOLE (relay-markers are line-number-keyed siblings of
279
+ # notify.log — a partial reset would misalign offsets); resume paths keep slice.yml and
280
+ # are never rotated; archive, never delete.
281
+
282
+ # 19. materializing a NEW order rotates seeded state into runner-work-archive; a replaced
283
+ # helper-materialized slice.yml is archived alongside
284
+ RSLICE="$ROOT/rotate-wt"
285
+ mkdir -p "$RSLICE/.forge/phases/milestone-77/1-aaa"
286
+ printf 'LANDED\n' > "$RSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
287
+ printf 'NEXT\n' > "$RSLICE/.forge/phases/milestone-77/1-aaa/plan-02.md"
288
+ sh "$DISPATCH" --slice "$RSLICE" --materialize-only >/dev/null 2>&1 # first order (all plans) — seeds a materialized slice.yml
289
+ # Seed the "run happened" state AFTER the first materialization (a dispatch with stale
290
+ # runner-work already present would rotate it — exactly the behavior under test in 19).
291
+ mkdir -p "$RSLICE/.forge/runner-work/pings" "$RSLICE/.forge/runner-work/phase-1" "$RSLICE/.forge/runner-work/relay-markers"
292
+ touch "$RSLICE/.forge/runner-work/pings/k-done.marker"
293
+ printf 'verdict: done\n' > "$RSLICE/.forge/runner-work/phase-1/phase-report.yml"
294
+ touch "$RSLICE/.forge/runner-work/relay-markers/1.relayed"
295
+ err19="$(sh "$DISPATCH" --slice "$RSLICE" --materialize-only --plan 1-aaa/plan-02 2>&1)"; rc=$?
296
+ if [ "$rc" = 0 ] \
297
+ && [ ! -e "$RSLICE/.forge/runner-work/pings/k-done.marker" ] \
298
+ && [ ! -e "$RSLICE/.forge/runner-work/phase-1/phase-report.yml" ] \
299
+ && ls "$RSLICE/.forge/runner-work-archive/"*/pings/k-done.marker >/dev/null 2>&1 \
300
+ && ls "$RSLICE/.forge/runner-work-archive/"*/phase-1/phase-report.yml >/dev/null 2>&1 \
301
+ && ls "$RSLICE/.forge/runner-work-archive/"*/relay-markers/1.relayed >/dev/null 2>&1 \
302
+ && ls "$RSLICE/.forge/runner-work-archive/"*/slice.yml >/dev/null 2>&1 \
303
+ && grep -q 'NEXT' "$RSLICE/phases/1-aaa/plan.md" 2>/dev/null \
304
+ && ! grep -q 'LANDED' "$RSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
305
+ ok "new work order rotates runner-work WHOLE (pings+report+relay-markers) + archives replaced slice.yml"
306
+ else
307
+ bad "rotation on new order" "rc=$rc err=$err19"
308
+ fi
309
+
310
+ # 20. NEG (resume preserved): existing materialized slice.yml, NO --plan → honored as-is,
311
+ # runner-work untouched, no archive (crash-resume / park→answer→resume path)
312
+ KSLICE="$ROOT/keep-wt"
313
+ mkdir -p "$KSLICE/.forge/phases/milestone-77/1-aaa"
314
+ printf 'x\n' > "$KSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
315
+ sh "$DISPATCH" --slice "$KSLICE" --materialize-only >/dev/null 2>&1 # materialized header slice.yml
316
+ mkdir -p "$KSLICE/.forge/runner-work/pings" # seed AFTER — see case 19 note
317
+ touch "$KSLICE/.forge/runner-work/pings/k-done.marker"
318
+ before20="$(cat "$KSLICE/slice.yml")"
319
+ err20="$(sh "$DISPATCH" --slice "$KSLICE" --materialize-only 2>&1)"; rc=$?
320
+ if [ "$rc" = 0 ] && [ "$(cat "$KSLICE/slice.yml")" = "$before20" ] \
321
+ && [ -f "$KSLICE/.forge/runner-work/pings/k-done.marker" ] \
322
+ && [ ! -d "$KSLICE/.forge/runner-work-archive" ]; then
323
+ ok "neg-control: existing slice.yml honored → NO rotation (resume state survives)"
324
+ else
325
+ bad "resume preserved" "rc=$rc err=$err20"
326
+ fi
327
+
328
+ # 21. NEG: --dry-run stays fully side-effect-free — no slice.yml, no rotation, even with
329
+ # stale runner-work present (extends case 12)
330
+ YSLICE="$ROOT/dry-rotate-wt"
331
+ mkdir -p "$YSLICE/.forge/phases/milestone-77/1-aaa" "$YSLICE/.forge/runner-work/pings"
332
+ printf 'x\n' > "$YSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
333
+ touch "$YSLICE/.forge/runner-work/pings/k-done.marker"
334
+ sh "$DISPATCH" --slice "$YSLICE" --dry-run >/dev/null 2>&1
335
+ if [ ! -f "$YSLICE/slice.yml" ] && [ ! -d "$YSLICE/.forge/runner-work-archive" ] \
336
+ && [ -f "$YSLICE/.forge/runner-work/pings/k-done.marker" ]; then
337
+ ok "neg-control: --dry-run rotates nothing, writes nothing"
338
+ else
339
+ bad "dry-run rotation" "dry-run had side effects"
340
+ fi
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
195
406
  # --- summary -----------------------------------------------------------------
196
407
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
197
408
  [ "$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