forge-orkes 0.71.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.
@@ -7,7 +7,7 @@
7
7
  # lint=fail + finding=<rule>:<file>[:<line>] lines
8
8
  # lint=justified justify="<trailer line>"
9
9
  # + "finding=<rule>:<file>[:<line>] justified" lines
10
- # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker
10
+ # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker|driven-by-missing
11
11
  # exit: 0 = clean or justified; 1 = fail; 2 = bad invocation.
12
12
  # Range MUST be three-dot BASE...HEAD; scanned against merge-base(BASE, HEAD).
13
13
  #
@@ -28,17 +28,24 @@
28
28
  # allowlist: still fires, still needs the operator act, only the label
29
29
  # changes (partial source shrink keeps the stricter deleted-assertion);
30
30
  # skip-marker — added .only(/.skip(/it.skip/describe.only/xit(/xdescribe(/
31
- # @pytest.mark.skip/t.Skip() line.
31
+ # @pytest.mark.skip/t.Skip() line;
32
+ # driven-by-missing — CONFIG-GATED, default OFF: when the BASE-side
33
+ # .forge/project.yml carries attribution.driven_by: true, every
34
+ # non-merge commit of merge-base..head must carry a
35
+ # Driven-by: <email> trailer (optional trailing session ref);
36
+ # finding's file slot = the offending commit's short SHA. Absent/
37
+ # false config = rule fully off. Base-side read: a PR cannot
38
+ # disable it for itself.
32
39
  # Justification: a "Lint-Justify: <one line>" trailer in ANY commit message of
33
40
  # merge-base..head (pure git). Lifts b/c/deleted-assertion/feature-removal/
34
- # skip-marker → exit 0 lint=justified. NEVER lifts (a).
41
+ # skip-marker/driven-by-missing → exit 0 lint=justified. NEVER lifts (a).
35
42
  #
36
43
  # Offline by construction: remote-less mktemp fixture repos (NFR-032).
37
44
  #
38
45
  # Usage:
39
46
  # ./.forge/checks/tests/forge-check-lint.test.sh # all cases
40
47
  # ./.forge/checks/tests/forge-check-lint.test.sh cannot-fail # one tag
41
- # Filter tags: cannot-fail silent-fail existence-only deleted-assertion feature-removal skip-marker justify docs-only invocation
48
+ # Filter tags: cannot-fail silent-fail existence-only deleted-assertion feature-removal skip-marker driven-by waiver-boundary justify docs-only invocation
42
49
  #
43
50
  # RED-phase friendly: missing helper → every selected case FAILS, suite exits
44
51
  # non-zero, no abort. Exits 0 iff every selected case passes. Self-cleans.
@@ -566,6 +573,121 @@ if running justify; then
566
573
  check "justify: trailer never saves a PR carrying rule a (exit 1)" "$RC" "1"
567
574
  fi
568
575
 
576
+ # ---------------------------------------------------------------------------
577
+ # driven-by — the config-gated Driven-by trailer rule (hold-waiver patch W2).
578
+ # Default OFF; base-side project.yml attribution.driven_by: true turns it on.
579
+ # ---------------------------------------------------------------------------
580
+ if running driven-by; then
581
+ PROJ_ON='attribution:
582
+ driven_by: true'
583
+
584
+ # OFF (default): no config → a trailer-less commit is clean (zero behavior change)
585
+ R=$(new_repo db-off)
586
+ put "$R" src/app.js 'x'
587
+ pr "$R"
588
+ put "$R" src/app.js 'y'
589
+ run_lint "$R" 'main...pr'
590
+ check "driven-by OFF: no config, trailer-less commit is clean (exit 0)" "$RC" "0"
591
+ check_absent "driven-by OFF: no driven-by finding" "$OUT" "driven-by-missing"
592
+
593
+ # OFF (explicit false): rule stays off
594
+ R=$(new_repo db-false)
595
+ put "$R" .forge/project.yml 'attribution:
596
+ driven_by: false'
597
+ pr "$R"
598
+ put "$R" src/app.js 'y'
599
+ run_lint "$R" 'main...pr'
600
+ check "driven-by OFF: driven_by false stays off (exit 0)" "$RC" "0"
601
+
602
+ # ON: a commit missing the trailer fails, finding names its short SHA
603
+ R=$(new_repo db-on)
604
+ put "$R" .forge/project.yml "$PROJ_ON"
605
+ pr "$R"
606
+ put "$R" src/app.js 'y'
607
+ DB_SHA="$(git -C "$R" rev-parse --short pr)"
608
+ run_lint "$R" 'main...pr'
609
+ check "driven-by ON: missing trailer fails (exit 1)" "$RC" "1"
610
+ check_contains "driven-by ON: verdict" "$OUT" "lint=fail"
611
+ check_contains "driven-by ON: finding names the commit" "$OUT" "finding=driven-by-missing:$DB_SHA"
612
+
613
+ # ON: every commit carrying the trailer is clean (optional session ref allowed)
614
+ R=$(new_repo db-ok)
615
+ put "$R" .forge/project.yml "$PROJ_ON"
616
+ pr "$R"
617
+ put "$R" src/app.js 'y' 'Driven-by: zayne@zayne.co.za (session cse_01X)'
618
+ put "$R" src/app.js 'z' 'Driven-by: zayne@zayne.co.za'
619
+ run_lint "$R" 'main...pr'
620
+ check "driven-by ON: trailered commits are clean (exit 0)" "$RC" "0"
621
+ check "driven-by ON: verdict clean" "$OUT" "lint=clean"
622
+
623
+ # ON: a malformed trailer (no email shape) still fails
624
+ R=$(new_repo db-malformed)
625
+ put "$R" .forge/project.yml "$PROJ_ON"
626
+ pr "$R"
627
+ put "$R" src/app.js 'y' 'Driven-by: somebody'
628
+ run_lint "$R" 'main...pr'
629
+ check "driven-by ON: trailer without an email fails (exit 1)" "$RC" "1"
630
+ check_contains "driven-by ON: malformed finding" "$OUT" "finding=driven-by-missing:"
631
+
632
+ # ON, mixed: one trailered + one bare commit → only the bare one is named
633
+ R=$(new_repo db-mixed)
634
+ put "$R" .forge/project.yml "$PROJ_ON"
635
+ pr "$R"
636
+ put "$R" src/app.js 'y' 'Driven-by: zayne@zayne.co.za'
637
+ put "$R" src/other.js 'w'
638
+ DB_BARE="$(git -C "$R" rev-parse --short pr)"
639
+ DB_GOOD="$(git -C "$R" rev-parse --short pr~1)"
640
+ run_lint "$R" 'main...pr'
641
+ check "driven-by ON mixed: bare commit fails (exit 1)" "$RC" "1"
642
+ check_contains "driven-by ON mixed: bare commit named" "$OUT" "finding=driven-by-missing:$DB_BARE"
643
+ check_absent "driven-by ON mixed: trailered commit not named" "$OUT" "finding=driven-by-missing:$DB_GOOD"
644
+
645
+ # Base-side gate: a PR that deletes the config cannot switch the rule off for itself
646
+ R=$(new_repo db-evade)
647
+ put "$R" .forge/project.yml "$PROJ_ON"
648
+ pr "$R"
649
+ git -C "$R" rm -q .forge/project.yml
650
+ git -C "$R" commit -qm "drop attribution config"
651
+ run_lint "$R" 'main...pr'
652
+ check "driven-by ON: head-side config removal cannot evade (exit 1)" "$RC" "1"
653
+ check_contains "driven-by ON: evasion finding" "$OUT" "finding=driven-by-missing:"
654
+
655
+ # Lint-Justify lifts it (same operator-act channel as the other liftable rules)
656
+ R=$(new_repo db-justify)
657
+ put "$R" .forge/project.yml "$PROJ_ON"
658
+ pr "$R"
659
+ put "$R" src/app.js 'y' 'Lint-Justify: host-authored dependabot commit, attribution n/a'
660
+ run_lint "$R" 'main...pr'
661
+ check "driven-by ON: Lint-Justify lifts (exit 0)" "$RC" "0"
662
+ check_contains "driven-by ON: justified verdict" "$OUT" "lint=justified"
663
+ check_contains "driven-by ON: finding listed as justified" "$OUT" "finding=driven-by-missing:"
664
+ fi
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # waiver-boundary — the hold check's run-waiver binding must have ZERO effect
668
+ # here: the waiver applies to the hold check only, never the lint.
669
+ # ---------------------------------------------------------------------------
670
+ if running waiver-boundary; then
671
+ R=$(new_repo wb)
672
+ put "$R" .forge/hold-config.yml 'version: 1
673
+ operators: []
674
+ irreversible_paths: []
675
+ deploy_paths: []
676
+ gating_tests: []
677
+ waiver:
678
+ workflow: waive-hold.yml
679
+ ref: main'
680
+ put "$R" "$WF" 'jobs: {}'
681
+ pr "$R"
682
+ put "$R" "$WF" 'jobs:
683
+ gate:
684
+ steps:
685
+ - run: exit 0'
686
+ run_lint "$R" 'main...pr'
687
+ check "waiver-boundary: hold waiver binding never lifts a lint finding (exit 1)" "$RC" "1"
688
+ check_contains "waiver-boundary: finding intact" "$OUT" "finding=cannot-fail:$WF"
689
+ fi
690
+
569
691
  # ---------------------------------------------------------------------------
570
692
  # docs-only — acceptance 6: a status on every PR
571
693
  # ---------------------------------------------------------------------------
@@ -25,6 +25,15 @@
25
25
  # suites) (+config deploy_paths, gating_tests)
26
26
  # Waiver: any --approvals login ∈ base-side config operators: → waived. Absent
27
27
  # config / empty operators → NO waiver possible (raise-only safe default).
28
+ # Run-waiver (hold-waiver patch): OPTIONAL base-side `waiver: {workflow, ref}`
29
+ # binding — when a hold fired and no operator approval waives it, a host-queried
30
+ # run of that workflow waives iff ALL of: workflow path matches; run ref ==
31
+ # repo default branch (== configured ref); conclusion == success; display_title
32
+ # carries the PR head SHA. Output: hold=waived waiver_run=<url>. FAIL-CLOSED on
33
+ # every leg (unconfigured / gh missing / API unreachable / no match / wrong ref
34
+ # / config-ref drift / wrong path / non-success / superseded SHA → hold stands);
35
+ # unconfigured behavior is byte-identical to the pre-waiver check (no gh call).
36
+ # gh is env-overridable (FORGE_HOLD_GH) — fixtures stub it and stay offline.
28
37
  #
29
38
  # Builds throwaway git repos in mktemp dirs with NO remote configured anywhere —
30
39
  # offline by construction (NFR-032); any network dependency in the script breaks here.
@@ -32,7 +41,7 @@
32
41
  # Usage:
33
42
  # ./.forge/checks/tests/forge-hold-check.test.sh # all cases
34
43
  # ./.forge/checks/tests/forge-hold-check.test.sh pain6 # one tag
35
- # Filter tags: irreversible structural pain6 waiver self-protect docs-only invocation multi
44
+ # Filter tags: irreversible structural pain6 waiver run-waiver self-protect docs-only invocation multi
36
45
  #
37
46
  # RED-phase friendly (TDD): a missing/non-executable helper does NOT abort the
38
47
  # suite — every selected case FAILS with "(helper missing ...)" as the got-value
@@ -382,6 +391,162 @@ if running multi; then
382
391
  check_contains "multi: protected named" "$OUT" "fact=protected:.github/workflows/deploy.yml"
383
392
  fi
384
393
 
394
+ # ---------------------------------------------------------------------------
395
+ # run-waiver — the Environment-gated waiver run channel: pass-on-match plus a
396
+ # neg-control per fail-closed leg. gh is stubbed offline via FORGE_HOLD_GH.
397
+ # ---------------------------------------------------------------------------
398
+ if running run-waiver; then
399
+ GH_STUB="$ROOT/gh-stub"
400
+ cat > "$GH_STUB" <<'STUB'
401
+ #!/bin/sh
402
+ # Offline gh stand-in for the run-waiver channel. Argv shapes the check uses:
403
+ # gh api repos/{owner}/{repo} --jq .default_branch
404
+ # gh api "repos/{owner}/{repo}/actions/workflows/<wf>/runs?..." --jq <projection>
405
+ # Driven by env: STUB_DEFAULT_BRANCH (empty/unset => API failure, exit 1),
406
+ # STUB_RUNS_FILE (TSV: conclusion, head_branch, path, html_url, display_title;
407
+ # unset/missing => API failure), STUB_LOG (append argv per call when set).
408
+ [ -n "${STUB_LOG:-}" ] && printf '%s\n' "$*" >> "$STUB_LOG"
409
+ case "${2:-}" in
410
+ 'repos/{owner}/{repo}')
411
+ [ -n "${STUB_DEFAULT_BRANCH:-}" ] || exit 1
412
+ printf '%s\n' "$STUB_DEFAULT_BRANCH" ;;
413
+ 'repos/{owner}/{repo}/actions/workflows/'*)
414
+ [ -n "${STUB_RUNS_FILE:-}" ] && [ -f "$STUB_RUNS_FILE" ] || exit 1
415
+ cat "$STUB_RUNS_FILE" ;;
416
+ *) exit 1 ;;
417
+ esac
418
+ STUB
419
+ chmod +x "$GH_STUB"
420
+
421
+ CONFIG_WAIVER='version: 1
422
+ operators: []
423
+ irreversible_paths: []
424
+ deploy_paths: []
425
+ gating_tests: []
426
+ waiver:
427
+ workflow: waive-hold.yml
428
+ ref: main'
429
+
430
+ WURL='https://host.example/actions/runs/101'
431
+ WPATH='.github/workflows/waive-hold.yml'
432
+
433
+ new_waiver_repo() { # name, config → repo whose pr branch carries a migration (hold fires)
434
+ nw_d=$(new_repo "$1")
435
+ put "$nw_d" .forge/hold-config.yml "$2"
436
+ pr "$nw_d"
437
+ put "$nw_d" app/migrations/010_waiver.py 'alter table'
438
+ printf '%s\n' "$nw_d"
439
+ }
440
+
441
+ # pass-on-match: approved success run on the default branch, title carries head SHA
442
+ R=$(new_waiver_repo rw-pass "$CONFIG_WAIVER")
443
+ SHA=$(git -C "$R" rev-parse pr)
444
+ printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.pass"
445
+ export FORGE_HOLD_GH="$GH_STUB" STUB_DEFAULT_BRANCH=main STUB_RUNS_FILE="$ROOT/runs.pass"
446
+ run_hold "$R" 'main...pr'
447
+ check "run-waiver: approved matching run waives (exit 0)" "$RC" "0"
448
+ check_contains "run-waiver: verdict annotates the run URL" "$OUT" "hold=waived waiver_run=$WURL"
449
+ check_contains "run-waiver: lists what it waived" "$OUT" "fact=irreversible:app/migrations/010_waiver.py waived"
450
+
451
+ # neg fail-closed leg: UNCONFIGURED — no waiver block: no gh call, and the
452
+ # blocked output is byte-identical to a run with no stub env at all.
453
+ R=$(new_waiver_repo rw-unconf "$CONFIG_OPS")
454
+ export STUB_LOG="$ROOT/gh.unconf.log"
455
+ : > "$STUB_LOG"
456
+ run_hold "$R" 'main...pr'
457
+ UNCONF_OUT="$OUT"
458
+ check "run-waiver neg (unconfigured): hold stands (exit 1)" "$RC" "1"
459
+ check "run-waiver neg (unconfigured): gh never invoked" "$(cat "$STUB_LOG")" ""
460
+ unset FORGE_HOLD_GH STUB_DEFAULT_BRANCH STUB_RUNS_FILE STUB_LOG
461
+ run_hold "$R" 'main...pr'
462
+ check "run-waiver neg (unconfigured): output byte-identical to pre-waiver check" "$OUT" "$UNCONF_OUT"
463
+
464
+ # neg fail-closed leg: gh MISSING (no host CLI at all)
465
+ R=$(new_waiver_repo rw-nogh "$CONFIG_WAIVER")
466
+ export FORGE_HOLD_GH="$ROOT/definitely-no-such-gh"
467
+ run_hold "$R" 'main...pr'
468
+ check "run-waiver neg (gh missing): hold stands (exit 1)" "$RC" "1"
469
+ check_contains "run-waiver neg (gh missing): blocked verdict" "$OUT" "hold=blocked"
470
+
471
+ # neg fail-closed leg: API UNREACHABLE (default-branch query fails)
472
+ export FORGE_HOLD_GH="$GH_STUB" STUB_DEFAULT_BRANCH="" STUB_RUNS_FILE="$ROOT/runs.pass"
473
+ run_hold "$R" 'main...pr'
474
+ check "run-waiver neg (API unreachable): hold stands (exit 1)" "$RC" "1"
475
+
476
+ # neg fail-closed leg: NO MATCHING RUN (API reachable, zero runs)
477
+ : > "$ROOT/runs.empty"
478
+ export STUB_DEFAULT_BRANCH=main STUB_RUNS_FILE="$ROOT/runs.empty"
479
+ run_hold "$R" 'main...pr'
480
+ check "run-waiver neg (no matching run): hold stands (exit 1)" "$RC" "1"
481
+
482
+ # neg fail-closed leg: WRONG REF — the only success run rode a PR-branch copy
483
+ # of the workflow (gate strippable), not the default branch.
484
+ SHA=$(git -C "$R" rev-parse pr)
485
+ printf 'success\tpr\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.wrongref"
486
+ export STUB_RUNS_FILE="$ROOT/runs.wrongref"
487
+ run_hold "$R" 'main...pr'
488
+ check "run-waiver neg (wrong ref): PR-branch run cannot waive (exit 1)" "$RC" "1"
489
+
490
+ # neg fail-closed leg: CONFIG-REF DRIFT — configured ref != repo default branch
491
+ R=$(new_waiver_repo rw-drift 'version: 1
492
+ operators: []
493
+ irreversible_paths: []
494
+ deploy_paths: []
495
+ gating_tests: []
496
+ waiver:
497
+ workflow: waive-hold.yml
498
+ ref: develop')
499
+ SHA=$(git -C "$R" rev-parse pr)
500
+ printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.drift"
501
+ export STUB_RUNS_FILE="$ROOT/runs.drift"
502
+ run_hold "$R" 'main...pr'
503
+ check "run-waiver neg (config-ref drift): waiver.ref != default branch (exit 1)" "$RC" "1"
504
+
505
+ # neg fail-closed leg: SUPERSEDED SHA — a new push after the approved run;
506
+ # the run's title carries the OLD head SHA, so it no longer matches.
507
+ R=$(new_waiver_repo rw-stale "$CONFIG_WAIVER")
508
+ OLD_SHA=$(git -C "$R" rev-parse pr)
509
+ printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$OLD_SHA" > "$ROOT/runs.stale"
510
+ put "$R" src/followup.js 'pushed after the waiver run'
511
+ export STUB_RUNS_FILE="$ROOT/runs.stale"
512
+ run_hold "$R" 'main...pr'
513
+ check "run-waiver neg (superseded SHA): new push invalidates the waiver (exit 1)" "$RC" "1"
514
+ check_contains "run-waiver neg (superseded SHA): blocked verdict" "$OUT" "hold=blocked"
515
+
516
+ # neg fail-closed leg: WRONG WORKFLOW PATH — a same-shaped run of another workflow
517
+ R=$(new_waiver_repo rw-path "$CONFIG_WAIVER")
518
+ SHA=$(git -C "$R" rev-parse pr)
519
+ printf 'success\tmain\t.github/workflows/other.yml\t%s\twaive-hold sha=%s\n' "$WURL" "$SHA" > "$ROOT/runs.path"
520
+ export STUB_RUNS_FILE="$ROOT/runs.path"
521
+ run_hold "$R" 'main...pr'
522
+ check "run-waiver neg (wrong workflow path): hold stands (exit 1)" "$RC" "1"
523
+
524
+ # neg fail-closed leg: NON-SUCCESS CONCLUSION — pending/rejected run never waives
525
+ printf 'failure\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.fail"
526
+ export STUB_RUNS_FILE="$ROOT/runs.fail"
527
+ run_hold "$R" 'main...pr'
528
+ check "run-waiver neg (non-success run): hold stands (exit 1)" "$RC" "1"
529
+
530
+ # channel precedence: an operator PR approval still waives on its own — the
531
+ # run-waiver channel is additive, not a replacement.
532
+ R=$(new_waiver_repo rw-op 'version: 1
533
+ operators:
534
+ - zayne
535
+ irreversible_paths: []
536
+ deploy_paths: []
537
+ gating_tests: []
538
+ waiver:
539
+ workflow: waive-hold.yml
540
+ ref: main')
541
+ AF=$(approvals rwop zayne)
542
+ export STUB_DEFAULT_BRANCH="" STUB_RUNS_FILE=""
543
+ run_hold "$R" 'main...pr' --approvals "$AF"
544
+ check "run-waiver: operator approval still waives independently (exit 0)" "$RC" "0"
545
+ check_contains "run-waiver: operator verdict unchanged" "$OUT" "hold=waived waived_by=zayne"
546
+
547
+ unset FORGE_HOLD_GH STUB_DEFAULT_BRANCH STUB_RUNS_FILE STUB_LOG
548
+ fi
549
+
385
550
  # ---------------------------------------------------------------------------
386
551
  # invocation — exit 2 on bad usage, never a false verdict
387
552
  # ---------------------------------------------------------------------------
@@ -339,6 +339,70 @@ 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
342
406
  # --- summary -----------------------------------------------------------------
343
407
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
344
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 ]