@arbiterforge/ca-pi 0.6.3 → 0.10.0

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 (67) hide show
  1. package/README.md +41 -98
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +145 -0
  4. package/plugins/ca-pi/COMMANDS.md +138 -68
  5. package/plugins/ca-pi/SKILLS.md +137 -30
  6. package/plugins/ca-pi/agents/INDEX.md +3 -2
  7. package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
  8. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  9. package/plugins/ca-pi/agents/finding-triage.md +31 -14
  10. package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
  11. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +37 -36
  12. package/plugins/ca-pi/extensions/codearbiter.js +844 -19
  13. package/plugins/ca-pi/generated/command-catalog.json +386 -196
  14. package/plugins/ca-pi/generated/roles.json +9 -0
  15. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  16. package/plugins/ca-pi/hooks/_bashguardlib.py +30 -12
  17. package/plugins/ca-pi/hooks/_gitexec.py +23 -0
  18. package/plugins/ca-pi/hooks/_githooks.py +50 -23
  19. package/plugins/ca-pi/hooks/_hooklib.py +148 -20
  20. package/plugins/ca-pi/hooks/_host.py +9 -1
  21. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  22. package/plugins/ca-pi/hooks/_modelib.py +762 -0
  23. package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
  24. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  25. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  26. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  27. package/plugins/ca-pi/hooks/_releaselib.py +278 -48
  28. package/plugins/ca-pi/hooks/_updatelib.py +230 -50
  29. package/plugins/ca-pi/hooks/doctor.py +58 -9
  30. package/plugins/ca-pi/hooks/git-enforce.py +10 -3
  31. package/plugins/ca-pi/hooks/hostapi.py +220 -22
  32. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  33. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  34. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  35. package/plugins/ca-pi/hooks/session-start.py +529 -435
  36. package/plugins/ca-pi/hooks/statusline.py +28 -10
  37. package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
  38. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  39. package/plugins/ca-pi/includes/command-compatibility.md +16 -0
  40. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  41. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  42. package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
  43. package/plugins/ca-pi/includes/redirect.md +12 -1
  44. package/plugins/ca-pi/includes/routing-table.md +14 -5
  45. package/plugins/ca-pi/includes/safety-core.md +86 -0
  46. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  47. package/plugins/ca-pi/routines/INDEX.md +1 -1
  48. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +55 -3
  49. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
  50. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  51. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
  52. package/plugins/ca-pi/routines/release/SKILL.md +1 -1
  53. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
  54. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
  55. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
  56. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
  57. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
  58. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
  59. package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
  60. package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
  61. package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
  62. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  63. package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
  64. package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
  65. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  66. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  67. package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
@@ -34,8 +34,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
34
  import hostapi # noqa: E402 — host seam (ADR-0011): plugin root + capability flags
35
35
  from _durabilitylib import is_ephemeral_path # noqa: E402
36
36
  from _hooklib import ( # noqa: E402
37
- frontmatter_enabled, get_host, project_root, set_host, utf8_stdio,
38
- write_text_atomic,
37
+ frontmatter_enabled, get_host, marker_root, project_root, set_host,
38
+ utf8_stdio, write_text_atomic,
39
39
  )
40
40
  from _standuplib import ( # noqa: E402
41
41
  any_actionable,
@@ -50,6 +50,14 @@ from _standuplib import ( # noqa: E402
50
50
  import _taskboardlib # noqa: E402 — shared task-board count/staleness logic
51
51
  import _provenancelib # noqa: E402 — shared provenance drift detection (T-16)
52
52
  import _updatelib # noqa: E402 — update-available notifier (cache read + notice text)
53
+ # T-06 (#437): the write-ahead audit-close ledger moved to _modelib.py — see
54
+ # that module's docstring. `_DEV_PENDING_CLOSE_MAX` is re-imported because a
55
+ # pre-existing test reads it off this module's namespace. T-42/T-47 (#437,
56
+ # mode-plane-deterministic-flip): the mode plane itself (MODES, current_mode,
57
+ # write_mode, the audit-line builder) is imported as a module so every call
58
+ # site stays explicit about which layer it is calling into.
59
+ from _modelib import _DEV_PENDING_CLOSE_MAX, _settle_dev_close # noqa: E402,F401
60
+ import _modelib # noqa: E402
53
61
 
54
62
  INITIALIZED_RE = re.compile(r"<!--\s*INITIALIZED\s*-->")
55
63
  STAGE_RE = re.compile(r"^stage:\s*([0-9]+)", re.I | re.M)
@@ -548,373 +556,300 @@ def _write_dev_session_owner(root, session_id, ts):
548
556
  pass
549
557
 
550
558
 
551
- # --- #396: a durable, retryable DEV: exit -----------------------------------
552
- # The synthetic close line is the ONLY thing that keeps the append-only audit
553
- # trail's DEV: enter/exit pairs matched after an abandoned maintainer session.
554
- # It used to be written best-effort ("except OSError: pass") and the marker was
555
- # then removed regardless — so a locked file, a full disk, or a permission blip
556
- # permanently erased the obligation and left an orphaned DEV: enter that no
557
- # later session could know about.
558
- #
559
- # The fix is a small write-ahead record: the owed line is staged on disk BEFORE
560
- # the append is attempted, and the record is deleted only once BOTH the append
561
- # is confirmed AND the marker it settles is gone. That single record therefore
562
- # carries three facts at once:
563
- #
564
- # "lines" — close lines still owed to overrides.log. Emptied one at a
565
- # time as each append is confirmed.
566
- # "marker_mtime" — the identity of the dev-active marker this close belongs
567
- # to. While the record still names a LIVE marker, the
568
- # force-close path knows that marker has already been
569
- # closed in the audit trail and refuses to mint a second
570
- # row for it — which is what makes a failed `os.remove`
571
- # idempotent rather than duplicating the close. It is
572
- # cleared the moment that marker is gone: an mtime only
573
- # identifies a file that still EXISTS, and a stale one is
574
- # free to collide with an unrelated future marker (2s
575
- # granularity on FAT32/exFAT/SMB/WSL mounts makes that a
576
- # real event, not a theoretical one) and suppress a close
577
- # that is genuinely owed.
578
- # "dropped" — how many owed close lines the bound below has discarded.
579
- # The cap keeps the record small, but the loss must not be
580
- # silent: the count is written to the trail as one
581
- # attributable note the moment overrides.log accepts writes.
582
- #
583
- # Replayed lines carry the timestamp they were MINTED with, not the time they
584
- # land, so a delayed replay leaves overrides.log non-chronological. Enter/exit
585
- # pairing is by timestamp, so that is correct — but an audit reader must not
586
- # assume file order is time order.
587
- #
588
- # Every boundary is covered:
589
- # crash before the append -> record present, line owed -> replayed
590
- # crash after the append -> record present, line owed -> the bounded
591
- # tail scan sees the line already landed and
592
- # drops it instead of appending a duplicate
593
- # marker removal fails -> record present, no line owed -> the next
594
- # session only retries the removal
595
- #
596
- # That tail scan is applied ONLY to lines read back off the record — the ones
597
- # that might have landed before a crash. A line minted in THIS process cannot
598
- # already be on the trail, and must never be dedupe-checked: close rows are
599
- # timestamped to the second, so two distinct closes minted in the same second
600
- # are byte-identical, and checking the fresh one against an owed copy of itself
601
- # would silently swallow a close that is genuinely owed.
602
- #
603
- # Everything here is best-effort by the module's standing convention: session
604
- # startup must never be bricked by audit bookkeeping, so nothing raises.
605
- _DEV_PENDING_CLOSE_MAX = 8 # bounded: never accumulate owed lines forever
606
- _DEV_PENDING_SCAN_BYTES = 64 * 1024 # bounded tail scan for the dedupe check
559
+ # Retries for the seen-anchor read-modify-write. Matches
560
+ # `_modelib._WRITE_MODE_ATTEMPTS` on purpose: same hazard, same shape, and two
561
+ # different numbers for one policy is how they drift apart.
562
+ _MODE_SEEN_WRITE_ATTEMPTS = 3
607
563
 
608
564
 
609
- def _dev_pending_close_path(root):
610
- return os.path.join(root, ".codearbiter", ".markers", "dev-close-pending.json")
565
+ def _mode_session_seen_path(root):
566
+ return os.path.join(root, ".codearbiter", ".markers", "mode-session-seen.json")
611
567
 
612
568
 
613
- def _overrides_log_path(root):
614
- return os.path.join(root, ".codearbiter", "overrides.log")
569
+ def _read_mode_session_seen(root):
570
+ """True iff `session_id`'s SessionStart has run in this repo before.
615
571
 
572
+ KEYED BY SESSION, exactly as the mode marker is. An earlier form stored a
573
+ single repo-global scalar — the id of whichever session started last — and
574
+ two live sessions then erased each other's record: A starts, B starts, A
575
+ compacts and reads "not seen", so the compaction clears A's live mode and
576
+ mints an `exit` row for a mode A never left. That is the SAME observable
577
+ failure this record was introduced to fix, merely moved from "a concurrent
578
+ session owns the legacy marker" to "a concurrent session exists at all".
579
+ A per-session question cannot be answered by a repo-global answer.
616
580
 
617
- def _read_dev_pending_close(root):
618
- """The pending-close record as
619
- {"lines": [...], "marker_mtime": float|None, "dropped": int}, or None when
620
- there is nothing usable on disk. A record that exists but carries no
621
- replayable line, no marker identity and no unreported drop is reported as
622
- None so the caller discards it a corrupt record must never wedge the
623
- mechanism shut. Never raises."""
624
- try:
625
- with open(_dev_pending_close_path(root), encoding="utf-8") as f:
626
- data = json.load(f)
627
- if not isinstance(data, dict):
628
- return None
629
- lines = [ln for ln in (data.get("lines") or [])
630
- if isinstance(ln, str) and ln.strip()][:_DEV_PENDING_CLOSE_MAX]
631
- mtime = data.get("marker_mtime")
632
- mtime = float(mtime) if isinstance(mtime, (int, float)) else None
633
- dropped = data.get("dropped")
634
- # `isinstance(True, int)` is True, so booleans are excluded explicitly.
635
- dropped = (int(dropped) if isinstance(dropped, int)
636
- and not isinstance(dropped, bool) and dropped > 0 else 0)
637
- if not lines and mtime is None and not dropped:
638
- return None
639
- return {"lines": lines, "marker_mtime": mtime, "dropped": dropped}
640
- except Exception: # noqa: BLE001 — absent/corrupt record -> no signal
641
- return None
581
+ DELIBERATELY SEPARATE from `dev-session-owner.json`. That record anchors the
582
+ legacy `dev-active` marker's force-close and is guarded by a liveness window
583
+ a bystander session must NOT overwrite it, or it would force-close a
584
+ concurrent owner's marker early. The mode plane needs a different question
585
+ answered ("has THIS session's SessionStart run before?"), and overloading one
586
+ record with both meanings is what let a compaction clear a live mode: when a
587
+ different live session owned the legacy marker, `clear_mode_marker` returned
588
+ early to protect that record, leaving the mode plane with no anchor at all.
642
589
 
590
+ Never raises; an absent or corrupt record reads as "not seen", which routes
591
+ to the conservative branch (clear), never to a silent retain.
592
+ """
593
+ return _read_mode_session_seen_map(root)
643
594
 
644
- def _write_dev_pending_close(root, rec):
645
- """Atomically persist the pending-close record. Never raises — a write
646
- failure only costs the retry signal this call was trying to create, which
647
- is exactly the pre-#396 behavior and still must not brick startup."""
648
- try:
649
- path = _dev_pending_close_path(root)
650
- os.makedirs(os.path.dirname(path), exist_ok=True)
651
- write_text_atomic(path, json.dumps(rec), newline="\n")
652
- except Exception: # noqa: BLE001 — must never brick session startup
653
- pass
654
595
 
596
+ def _read_mode_session_seen_map(root):
597
+ """`{session_id: ts}` for every session seen in this repo, or `{}`.
655
598
 
656
- def _discard_dev_pending_close(root):
599
+ Never raises; an absent or corrupt record reads as "nothing seen", which
600
+ routes to the conservative branch (clear), never to a silent retain."""
657
601
  try:
658
- os.remove(_dev_pending_close_path(root))
659
- except OSError:
602
+ with open(_mode_session_seen_path(root), encoding="utf-8") as f:
603
+ data = json.load(f)
604
+ if isinstance(data, dict) and not isinstance(data.get("session_id"), str):
605
+ return {k: v for k, v in data.items() if isinstance(k, str)}
606
+ # Legacy single-session record ({"session_id": …, "ts": …}) written by
607
+ # an earlier build. Read it forward rather than discarding it: dropping
608
+ # it would clear a live mode on the very first compaction after an
609
+ # upgrade, which is the failure this whole record exists to prevent.
610
+ if isinstance(data, dict) and isinstance(data.get("session_id"), str):
611
+ return {data["session_id"]: data.get("ts")}
612
+ except Exception: # noqa: BLE001 — absent/corrupt record -> no signal
660
613
  pass
661
-
662
-
663
- def _overrides_has_line(root, line):
664
- """True iff `line` already appears in the tail of overrides.log. Bounded to
665
- the last _DEV_PENDING_SCAN_BYTES a replay always happens on the very next
666
- SessionStart, so the line it is looking for is at (or near) the end. An
667
- unreadable log answers False: re-appending a close row is a far smaller
668
- harm than silently dropping one.
669
-
670
- Read in BINARY and decoded here on purpose: a byte offset is only
671
- meaningful to seek() on a binary stream, and the comparison is made on the
672
- stripped line so the platform EOL the append produced never matters."""
673
- needle = line.strip()
674
- if not needle:
675
- return False
676
- try:
677
- path = _overrides_log_path(root)
678
- size = os.path.getsize(path)
679
- with open(path, "rb") as f:
680
- if size > _DEV_PENDING_SCAN_BYTES:
681
- f.seek(size - _DEV_PENDING_SCAN_BYTES)
682
- tail = f.read().decode("utf-8", "replace")
683
- return needle in tail
684
- except Exception: # noqa: BLE001 — cannot confirm -> assume not present
685
- return False
686
-
687
-
688
- def _append_override_line(root, line):
689
- """Append one audit line to overrides.log. True on a confirmed write."""
690
- try:
691
- with open(_overrides_log_path(root), "a", encoding="utf-8") as f:
692
- f.write(line)
693
- return True
694
- except OSError:
695
- return False
696
-
697
-
698
- def _dev_dropped_close_note(count, host_name=None):
699
- """One audit line accounting for close rows the pending-close cap had to
700
- discard. Deliberately NOT a `DEV: exit` row — it closes nothing; it records
701
- that N closes can never be written, so a reader of the append-only trail
702
- can attribute the unmatched entries instead of finding an unexplained gap.
703
- """
704
- ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
705
- return (f"[{ts}] | BY: session-cleanup | HOST: {host_name or 'unknown'} "
706
- f"| DEV: close-dropped | NOTE: {count} owed close row(s) discarded - the "
707
- f"pending-close cap ({_DEV_PENDING_CLOSE_MAX}) was reached while "
708
- f"overrides.log was unwritable; that many maintainer sessions have "
709
- f"no matching close row\n")
710
-
711
-
712
- def _settle_dev_close(root, marker=None, new_line=None, host_name=None):
713
- """Drive the pending-close record to settlement; the single place the owed
714
- DEV: exit is appended and the retry state is cleared.
715
-
716
- `marker` is the dev-active path when one is live (its mtime becomes the
717
- close identity), None when there is no marker to settle. `new_line` is a
718
- freshly minted close line to take on, or None when this is a pure replay of
719
- whatever is already owed. `host_name` only attributes the cap-overflow note
720
- below; the close lines themselves already carry their own HOST field.
721
- Returns the number of close lines appended by THIS call. Never raises."""
722
- if (marker is None and new_line is None
723
- and not os.path.isfile(_dev_pending_close_path(root))):
724
- return 0 # nothing owed, nothing to settle — the overwhelming case
725
- rec = _read_dev_pending_close(root)
726
- owed = list(rec["lines"]) if rec else []
727
- prev_mtime = rec["marker_mtime"] if rec else None
728
- dropped = rec["dropped"] if rec else 0
729
-
730
- marker_mtime = None
731
- if marker:
614
+ return {}
615
+
616
+
617
+ def _write_mode_session_seen(root, session_id, ts):
618
+ """Record that `session_id`'s SessionStart has run, WITHOUT disturbing any
619
+ other session's entry. Never raises a write failure degrades to "not
620
+ seen", i.e. the next compaction clears the mode. That is the safe
621
+ direction: it restores `arbiter` (gates ON) rather than silently retaining
622
+ a gates-off posture on unproven state.
623
+
624
+ VERIFIED, like `_modelib.write_mode`, and for the same reason.
625
+ `write_text_atomic` makes each replace atomic but does not serialize the
626
+ read-modify-write PAIR, so two SessionStarts can read the same map and
627
+ overwrite each other's entry. A lost anchor is not benign here: the next
628
+ compaction finds no record, clears that session's live mode, and mints an
629
+ `exit` row for a mode the user never left — the exact failure this record
630
+ exists to prevent, reached through its own write path. So the write is
631
+ re-read and retried on a lost update.
632
+
633
+ Still never raises. A write that cannot be confirmed after the retries
634
+ degrades to "not seen", which clears to `arbiter` — gates ON. That is the
635
+ safe direction; silently retaining a gates-off posture on state we cannot
636
+ vouch for is not."""
637
+ path = _mode_session_seen_path(root)
638
+ for _attempt in range(_MODE_SEEN_WRITE_ATTEMPTS):
732
639
  try:
733
- marker_mtime = os.path.getmtime(marker)
734
- except OSError:
735
- marker_mtime = None
736
-
737
- # Everything already in `owed` came off disk, so it MAY have reached the
738
- # trail before a crash and has to be dedupe-checked. Anything appended
739
- # below is minted in this process and cannot possibly be there yet.
740
- replays = len(owed)
741
-
742
- if new_line is not None:
743
- # Already closed THIS marker (the append landed, only the removal
744
- # failed) -> do not mint a second row for it; just retry the cleanup.
745
- already_closed = (rec is not None and prev_mtime is not None
746
- and marker_mtime is not None
747
- and prev_mtime == marker_mtime)
748
- if not already_closed:
749
- owed.append(new_line)
750
- if len(owed) > _DEV_PENDING_CLOSE_MAX:
751
- # Bounded, but never SILENT. A permanently-unwritable overrides.log
752
- # would otherwise accumulate owed lines forever, so the oldest are
753
- # discarded — and counted, so the loss is itself auditable rather than
754
- # reintroducing exactly the unmatched `DEV: enter` this record exists
755
- # to prevent.
756
- overflow = len(owed) - _DEV_PENDING_CLOSE_MAX
757
- dropped += overflow
758
- owed = owed[-_DEV_PENDING_CLOSE_MAX:]
759
- replays = max(0, replays - overflow) # the discards come off the front
760
-
761
- if owed or dropped or marker_mtime is not None:
762
- # Write-ahead: the obligation is durable BEFORE the append is tried.
763
- _write_dev_pending_close(root, {"lines": owed,
764
- "marker_mtime": marker_mtime,
765
- "dropped": dropped})
766
-
767
- # The overflow note goes in FIRST — the rows it accounts for are older than
768
- # everything still owed. It is minted fresh each attempt, so it is not
769
- # deduped by the tail scan; a crash between this append and the write-back
770
- # below can repeat it once, which is the same "a duplicate beats a loss"
771
- # trade the close rows themselves make.
772
- if dropped and _append_override_line(root, _dev_dropped_close_note(dropped, host_name)):
773
- dropped = 0
774
-
775
- appended = 0
776
- remaining = []
777
- stalled = False
778
- for idx, line in enumerate(owed):
779
- if stalled:
780
- remaining.append(line) # the log is failing — everything after
781
- continue # the first failure is still owed
782
- if idx < replays and _overrides_has_line(root, line):
783
- continue # crash-after-append: already in the trail
784
- if not _append_override_line(root, line):
785
- stalled = True
786
- remaining.append(line) # still owed — replay on the next session
640
+ seen = _read_mode_session_seen_map(root)
641
+ seen[str(session_id)] = ts
642
+ os.makedirs(os.path.dirname(path), exist_ok=True)
643
+ write_text_atomic(path, json.dumps(seen))
644
+ except Exception: # noqa: BLE001 must never brick session startup
787
645
  continue
788
- appended += 1
646
+ if str(session_id) in _read_mode_session_seen_map(root):
647
+ return
789
648
 
790
- marker_gone = True
791
- if marker:
792
- try:
793
- os.remove(marker)
794
- except OSError:
795
- marker_gone = not os.path.isfile(marker)
796
-
797
- # Keep the record ONLY while it still carries information: a line still
798
- # owed, an unreported cap overflow, or the identity of a marker that
799
- # survived its own removal (the tombstone that stops the next session
800
- # minting a second close for it). A marker that IS gone takes its tombstone
801
- # with it a dead marker's mtime identifies nothing, and leaving it behind
802
- # lets an unrelated future marker collide with it and lose a real close.
803
- if remaining or dropped or (not marker_gone and marker_mtime is not None):
804
- _write_dev_pending_close(root, {"lines": remaining,
805
- "marker_mtime": (None if marker_gone
806
- else marker_mtime),
807
- "dropped": dropped})
808
- else:
809
- _discard_dev_pending_close(root)
810
- return appended
811
-
812
-
813
- def clear_dev_marker(root, host_name=None, session_id=None, now=None):
814
- """Clear the per-session /dev statusline marker on startup. If the marker is
815
- LIVE (a prior session entered /ca:dev and ended without /ca:arbiter), append a
816
- synthetic DEV: exit line to overrides.log BEFORE removing it
817
- (observability-001) otherwise the audit trail keeps an orphaned DEV: enter
818
- with no matching close. Append-only (it never rewrites); best-effort a write
819
- or remove failure must never brick session startup.
820
-
821
- #396: "best-effort" is no longer "best-effort ONCE". The close is routed
822
- through _settle_dev_close, which stages the owed line durably before
823
- attempting the append and clears that retry state only after the append is
824
- confirmed so a locked/failing overrides.log leaves a replayable record
825
- instead of an orphaned DEV: enter. Startup itself still fails OPEN: this
826
- function returns normally on every path, exactly as before.
827
-
828
- `host_name` (observability-001/ADR-0012) is the resolved host's `.name`
829
- ("claude"/"codex"/"unknown"), so the synthetic close line is attributable to
830
- the host that wrote it now that three hosts share one overrides.log
831
- (ADR-0011). Optional and defaults to resolving it here via `get_host()`
832
- (#257) — main() already holds a Host instance and passes its `.name`
833
- through to avoid a second resolution, but any other caller (tests
834
- included) may omit it.
835
-
836
- `session_id` (#271 C-5) is THIS invocation's own session id from the
837
- SessionStart hook payload, when the host supplies one. See the module
838
- comment above `DEV_SESSION_LIVENESS_WINDOW` for the full session-scoping
839
- contract: a live marker is only force-closed when there is no reason to
840
- believe a DIFFERENT, still-running session currently owns it and the
841
- ownership record's timestamp is refreshed ONLY by the owner itself (never
842
- by an unrelated session merely observing the marker), so the liveness
843
- window is anchored to the owner's last activity, not reset by every
844
- passerby SessionStart. `now` (epoch seconds) is injectable for
845
- deterministic tests; defaults to `time.time()`."""
649
+
650
+ # --- #396/T-06: write-ahead ledger machinery extracted to _modelib ----------
651
+ # `_settle_dev_close` and its pending-close record (the durable, retryable
652
+ # exit machinery) moved to `_modelib.py` (mode-plane-deterministic-flip #437,
653
+ # imported at module top) — that module now owns the mode plane's audit-close
654
+ # ledger generally; `clear_mode_marker` below is its SessionStart-specific
655
+ # caller. Pure move, no behavior change to the ledger itself — see
656
+ # `_modelib.py`'s module docstring.
657
+ #
658
+ # T-42/T-47 (#437): `clear_mode_marker` is now the SINGLE SessionStart-time
659
+ # settlement pass over BOTH the mode plane's session-keyed entries (T-42/
660
+ # AC-4) and the legacy repo-global `dev-active` marker (T-47/AC-41) merged
661
+ # into one function DELIBERATELY, not left as two. An earlier draft split
662
+ # them; both independently called `_read_dev_session_owner`/
663
+ # `_write_dev_session_owner`, and because main() had to run one before the
664
+ # other, the FIRST function's write leaked into the SECOND function's read
665
+ # within the same invocation — a first-ever session with an orphaned legacy
666
+ # marker was wrongly recognised as "the confirmed owner, resuming" (self-
667
+ # defeating T-47's own force-close-when-abandoned contract). One function,
668
+ # one read of the owner record per invocation, removes the seam entirely.
669
+ #
670
+ # The owner-liveness heuristic (prev_sid/prev_ts, DEV_SESSION_LIVENESS_WINDOW)
671
+ # is VERBATIM the pre-#437 `clear_dev_marker`'s own contract (see the module
672
+ # comment above DEV_SESSION_LIVENESS_WINDOW) — this function is that
673
+ # function's direct successor, not a new mechanism. Its `is_owner` branch has
674
+ # ONE new consequence: when the confirmed owner resumes and the legacy
675
+ # marker is STILL live, this is exactly when T-47's conversion fires — write
676
+ # a `dangerous` mode entry for the owner and remove the legacy marker,
677
+ # instead of leaving it untouched forever. No audit row is minted for that
678
+ # conversion: the historical `DEV: enter` row already on the trail keeps
679
+ # backing `dangerous` via `_modelib.ledger_backs`'s legacy acceptance
680
+ # (AC-11) minting a fresh `MODE: dangerous enter` would misrepresent a
681
+ # storage-format migration as a new operator-initiated transition. Everything
682
+ # ELSE (force-close on an abandoned marker, the liveness window, the
683
+ # unconditional-clear degrade with no session_id/no prior record) is
684
+ # unchanged and is exercised by the SAME test corpus the pre-#437 code was:
685
+ # `TestDevExitAudit` and `TestDevExitRetryablePendingClose`
686
+ # (plugins/ca/hooks/tests/test_session_start.py) — repointed at this
687
+ # function's new name, with exactly two assertions updated where the
688
+ # observable OUTCOME changed (the owner-resume case now converts+removes
689
+ # instead of leaving the marker untouched see that file for the reasoning
690
+ # on each).
691
+ #
692
+ # A session_id that is NOT recognised as the current owner clears only ITS
693
+ # OWN prior mode-plane entry (T-42), never a different session's — a force-
694
+ # close-other-sessions engine over the MODE PLANE is out of scope (residual:
695
+ # an abandoned foreign session's mode entry can linger indefinitely; nothing
696
+ # UNSAFE follows, since no live session ever reads a dead session_id's mode
697
+ # again). The LEGACY marker's force-close is the one exception, inherited
698
+ # unchanged from the pre-#437 contract: it has no session identity of its
699
+ # own to preserve, so an abandoned marker is safe to close on any session's
700
+ # behalf once the liveness window has genuinely elapsed.
701
+ #
702
+ # CROSS-LANE NOTE for Lane B (prompt-submit.py, AC-23/24/25): this function
703
+ # treats a SessionStart re-fire for the SAME session_id as a "resume/compact
704
+ # heartbeat" and does NOT clear mode-plane state in that case — i.e. mode is
705
+ # designed to SURVIVE compaction for the owning session. No `source` field
706
+ # (startup vs. resume vs. compact) is read from the hook payload anywhere in
707
+ # this repo today (verified by grep before writing this) — the owner-
708
+ # liveness heuristic is a durable proxy for it, not the real signal, so it
709
+ # is imprecise in the same documented way the pre-#437 heuristic always was.
710
+ # If Lane B's AC-25 test seeds a DIFFERENT session_id per "turn" rather than
711
+ # reusing one across a simulated compaction, that test will observe mode
712
+ # reset to arbiter here — please confirm your fixture reuses session_id.
713
+
714
+
715
+ def clear_mode_marker(root, host_name=None, session_id=None, now=None):
716
+ """The single SessionStart-time mode-plane + legacy-dev-active
717
+ settlement pass. See the module comment above for the full contract and
718
+ why this was merged from two functions into one. `now` (epoch seconds)
719
+ is injectable for deterministic tests; defaults to `time.time()`. Never
720
+ raises."""
846
721
  now = time.time() if now is None else now
847
722
  prev_sid, prev_ts = _read_dev_session_owner(root)
848
-
849
723
  marker = os.path.join(root, ".codearbiter", ".markers", "dev-active")
850
724
  marker_live = os.path.isfile(marker)
725
+ is_owner = bool(session_id) and prev_sid == session_id
726
+
727
+ # The mode plane's OWN "have I seen this session" anchor, read before
728
+ # anything below can return, and re-stamped unconditionally afterwards so
729
+ # every exit path records it (several of the branches below return early).
730
+ # It must not be derived from `is_owner`: that answers a different question
731
+ # (does this session own the legacy dev-active marker?), and a bystander
732
+ # session is deliberately NOT allowed to claim that record — which used to
733
+ # leave the mode plane with no anchor and let a compaction clear a live
734
+ # mode. See _read_mode_session_seen.
735
+ mode_seen = bool(session_id) and str(session_id) in _read_mode_session_seen(root)
736
+ if session_id:
737
+ _write_mode_session_seen(root, session_id, now)
738
+
739
+ if is_owner:
740
+ # The confirmed owner, resuming/compacting: heartbeat, and
741
+ # opportunistically CONVERT a still-live legacy marker (T-47) — but
742
+ # ONLY where there is nothing to convert over.
743
+ #
744
+ # An unconditional write here contradicted the claim it sat under. The
745
+ # marker removal below is best-effort, so a marker that survives one
746
+ # pass is still live on the next: an owner who flipped back to
747
+ # `arbiter` mid-session had gates turned OFF again by the next
748
+ # compaction, with no operator action and no audit row — the exact
749
+ # unaudited gates-off transition ADR-0030 forbids. Gating on the
750
+ # arbiter default keeps this a MIGRATION (legacy marker, no mode-plane
751
+ # opinion yet) instead of an override of the user's live choice, and is
752
+ # what actually lets AC-25 re-inject the SAME mode after a compaction.
753
+ _write_dev_session_owner(root, session_id, now)
754
+ # "No opinion yet" is the ABSENCE of an entry, not the arbiter VALUE:
755
+ # `current_mode` answers `arbiter` for both "never flipped" and
756
+ # "deliberately flipped back", and only the first may be migrated over.
757
+ # `session_has_entry` is the only reader that keeps that distinction
758
+ # (it also consults the pre-#681 shared map, so a session that chose
759
+ # before the per-session split still counts as having chosen).
760
+ # A corrupt or unreadable entry answers False WITH a diagnostic, which
761
+ # would otherwise read as "no entry" and migrate `dangerous` back on top
762
+ # of a user who had explicitly returned to `arbiter`. Absence is the
763
+ # only clean "nothing to convert over"; anything we could not read is
764
+ # not evidence of anything, and guessing gates-off from unreadable
765
+ # state is the one direction ADR-0030 forbids.
766
+ has_entry, state_diag = _modelib.session_has_entry(session_id, root=root)
767
+ readable = state_diag in (None, _modelib.MODE_DIAG_ABSENT)
768
+ unconverted = readable and not has_entry
769
+ if marker_live and unconverted and _modelib.write_mode(session_id, "dangerous", root=root):
770
+ try:
771
+ os.remove(marker)
772
+ except OSError:
773
+ # The conversion landed; a leftover legacy file is harmless
774
+ # — the NEXT is_owner pass just retries the removal (write_
775
+ # mode(session_id, "dangerous") is idempotent).
776
+ pass
777
+ return
778
+
779
+ # Not the recognised owner. Two independent settlements follow.
851
780
 
781
+ # (1) T-42/AC-4: clear session_id's OWN mode-plane entry, if any — never
782
+ # a different session's (see the module comment's force-close-scope
783
+ # note). Independent of the legacy marker's liveness window below.
784
+ #
785
+ # `not mode_seen` is what distinguishes a NEW session from this session
786
+ # resuming or compacting. AC-4 is in fact satisfied structurally — the mode
787
+ # file is keyed by session_id, so a genuinely new session has no entry and
788
+ # already reads `arbiter` — which means this clear can only ever fire for a
789
+ # session that previously flipped. Without the guard that is exactly the
790
+ # compaction AC-25 exists to preserve, and the mode would be cleared out
791
+ # from under a live session.
792
+ if session_id and not mode_seen:
793
+ mode, _diag = _modelib.current_mode(session_id, root=root)
794
+ if mode != _modelib.MODES[0]:
795
+ if host_name is None:
796
+ try:
797
+ # get_host() (#257): resolves the SAME Host run(host)
798
+ # injected instead of a second load.
799
+ host_name = get_host().name
800
+ except Exception: # noqa: BLE001 — must never brick session startup
801
+ host_name = "unknown"
802
+ # T-43/AC-35: this line names NO command — unlike the retired
803
+ # clear_dev_marker's `cmd_ref("arbiter")` (which stamped a
804
+ # permanent dangling reference into overrides.log once
805
+ # `/ca:arbiter` was deleted), _modelib._mode_audit_line's NOTE
806
+ # is a bare "—".
807
+ line = _modelib._mode_audit_line("exit", mode, host_name=host_name, now=now,
808
+ session_id=session_id)
809
+ # #396: stage-then-append BEFORE the state mutation below, so an
810
+ # interruption between them leaves the row owed and replayable
811
+ # rather than silently lost.
812
+ _settle_dev_close(root, new_line=line, host_name=host_name)
813
+ _modelib.write_mode(session_id, _modelib.MODES[0], root=root)
814
+
815
+ # (2) T-47/legacy: the dev-active marker's owner-liveness-gated
816
+ # force-close — verbatim the pre-#437 `clear_dev_marker` contract.
852
817
  if not marker_live:
853
- # No live marker: this record is purely "who could next enter /dev" —
854
- # any session refreshing it is harmless and correct. Nothing else to
855
- # do there is no marker to clear, but a close owed by an EARLIER
856
- # session whose append failed is still replayed here (#396); that is
857
- # precisely the case the old code could never recover from.
858
- #
859
- # `host_name` is passed through as-is (it only attributes the
860
- # cap-overflow note) rather than resolved here: main() already hands
861
- # the real host name down, and this branch is the overwhelmingly
862
- # common one — it must not pay for a host resolution on every startup.
818
+ # No live marker: replay anything already owed from an earlier
819
+ # crash (#396), and record this session as a candidate future owner
820
+ # — harmless, and exactly what let a later invocation recognise it.
863
821
  _settle_dev_close(root, host_name=host_name)
864
822
  if session_id:
865
823
  _write_dev_session_owner(root, session_id, now)
866
824
  return
867
825
 
868
826
  if session_id and prev_sid:
869
- if prev_sid == session_id:
870
- # The owner itself, resuming/compacting mid-dev refresh ITS OWN
871
- # heartbeat (this is the only case where a write is safe while the
872
- # marker is live) and leave the marker untouched.
873
- #
874
- # #396: deliberately NO _settle_dev_close here or in the sibling
875
- # branch below. Both return with the marker still LIVE, and a
876
- # pending record naming a live marker doubles as the "this marker
877
- # has already been closed in the trail" tombstone — settling it
878
- # against a marker we are not allowed to touch would discard that
879
- # tombstone and let a later force-close mint a duplicate row. Any
880
- # owed line simply waits for a session that is entitled to act.
881
- _write_dev_session_owner(root, session_id, now)
882
- return
827
+ # is_owner was already False above, so prev_sid != session_id here:
828
+ # a DIFFERENT, possibly still-live session owns this marker.
883
829
  if (now - prev_ts) < DEV_SESSION_LIVENESS_WINDOW:
884
- # A different session, and the OWNER's own clock hasn't elapsed
885
- # yet do NOT touch the record (an unrelated observer must never
886
- # reset a clock it doesn't own) and do not clobber the marker.
830
+ # The owner's own clock hasn't elapsed yet — do not touch the
831
+ # record or the marker.
887
832
  return
888
- # Different session AND the owner's own record is stale beyond the
889
- # window: proceed to the force-close below. Deliberately do not write
890
- # a fresh record here either there is no live owner left to anchor
891
- # a new one to; the write happens naturally next time /dev is entered.
833
+ # Stale beyond the window: proceed to force-close below. Deliberately
834
+ # do not write a fresh owner record here either — there is no live
835
+ # owner left to anchor a new one to.
892
836
 
893
837
  if session_id and not prev_sid:
894
- # No prior record at all (first session ever, or a dropped record) —
895
- # no signal to protect a concurrent owner; seed the record for next
896
- # time and fall through to the pre-#271 unconditional-clear behavior.
838
+ # No prior record at all no signal to protect a concurrent owner;
839
+ # seed the record for next time and fall through to force-close.
897
840
  _write_dev_session_owner(root, session_id, now)
898
841
 
899
842
  # Force-close: either no session_id/no prior record (unconditional-clear
900
843
  # fallback), or a genuinely stale owner beyond the window.
901
844
  if host_name is None:
902
845
  try:
903
- # get_host() (#257), not a direct hostapi.load_host(): resolves
904
- # the SAME Host run(host) injected instead of a second load.
905
846
  host_name = get_host().name
906
847
  except Exception: # noqa: BLE001 — must never brick session startup
907
848
  host_name = "unknown"
908
- try:
909
- arbiter_ref = get_host().cmd_ref("arbiter")
910
- except Exception: # noqa: BLE001 — must never brick session startup
911
- arbiter_ref = "/ca:arbiter"
912
849
  ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
913
850
  line = (f"[{ts}] | BY: session-cleanup | HOST: {host_name} | DEV: exit | NOTE: cleared by "
914
- f"SessionStart (prior session ended mid-dev without {arbiter_ref})\n")
915
- # #396: stage-then-append-then-clear, all inside one settlement step. The
916
- # append is no longer a fire-and-forget `except OSError: pass` followed by
917
- # an unconditional marker delete — the owed line outlives a failed write.
851
+ f"SessionStart (prior session ended mid-dev with no live owner)\n")
852
+ # #396: stage-then-append-then-clear, all inside one settlement step.
918
853
  _settle_dev_close(root, marker=marker, new_line=line, host_name=host_name)
919
854
 
920
855
 
@@ -950,7 +885,7 @@ def update_notice_line(plugin):
950
885
  no network call itself."""
951
886
  try:
952
887
  state = _updatelib.read_state(_updatelib.state_path())
953
- latest = state.get("latest") if isinstance(state, dict) else None
888
+ latest = _updatelib.target_state(state).get("latest")
954
889
  installed = _updatelib.installed_version(plugin)
955
890
  return _updatelib.notice_line(installed, latest) or ""
956
891
  except Exception: # noqa: BLE001 — never crash session startup
@@ -988,6 +923,47 @@ def spawn_background_update_refresh(plugin, spawner=None):
988
923
  return None
989
924
 
990
925
 
926
+ _STDIN_PAYLOAD = None # None = not read yet; a dict once read (possibly empty)
927
+
928
+
929
+ def _stdin_payload():
930
+ """The SessionStart hook's raw JSON payload as a dict, read AT MOST ONCE.
931
+
932
+ stdin is a stream: whoever reads it first consumes it, so the session_id
933
+ reader and the `marker_root` payload cannot each do their own read. This
934
+ caches the parsed dict and both callers share it.
935
+
936
+ Why `marker_root` needs the payload at all (#437 regression): its host
937
+ delegate resolves a linked worktree by SPAWNING GIT when it has no payload
938
+ to resolve from. An argument-less call therefore adds a git subprocess to
939
+ every session start, and on Windows a bare `git` can resolve from the
940
+ current directory — so a repository containing its own `git.exe` gets that
941
+ one executed. When that spawn misbehaves, startup dies BEFORE the
942
+ git-enforcer install below, and the repo silently loses the H-01/H-02
943
+ backstop that closes `--no-verify` (ADR-0015).
944
+
945
+ Returns {} on any failure, absence, or malformed payload — the same silent
946
+ degradation `_session_id_from_stdin` has always had, for the same reason:
947
+ a host that supplies no payload is a normal condition, not an error worth
948
+ a breadcrumb on every session start."""
949
+ global _STDIN_PAYLOAD
950
+ if _STDIN_PAYLOAD is not None:
951
+ return _STDIN_PAYLOAD
952
+ _STDIN_PAYLOAD = {}
953
+ try:
954
+ if sys.stdin.isatty():
955
+ return _STDIN_PAYLOAD
956
+ raw = sys.stdin.read()
957
+ if not raw.strip():
958
+ return _STDIN_PAYLOAD
959
+ data = json.loads(raw)
960
+ if isinstance(data, dict):
961
+ _STDIN_PAYLOAD = data
962
+ except Exception: # noqa: BLE001 — must never brick session startup
963
+ pass
964
+ return _STDIN_PAYLOAD
965
+
966
+
991
967
  def _session_id_from_stdin():
992
968
  """Best-effort session_id from the SessionStart hook's own JSON payload
993
969
  (#271 C-5) — session-start.py has never read its stdin before this. Reads
@@ -1001,34 +977,190 @@ def _session_id_from_stdin():
1001
977
  payload; the caller treats an empty session_id as "unavailable" and
1002
978
  degrades to the pre-#271 unconditional-clear behavior."""
1003
979
  try:
1004
- if sys.stdin.isatty():
1005
- return ""
1006
- raw = sys.stdin.read()
1007
- if not raw.strip():
1008
- return ""
1009
- data = json.loads(raw)
1010
- return str(data.get("session_id") or "") if isinstance(data, dict) else ""
980
+ return str(_stdin_payload().get("session_id") or "")
1011
981
  except Exception: # noqa: BLE001 — must never brick session startup
1012
982
  return ""
1013
983
 
1014
984
 
985
+ # --------------------------------------------------------------------------- #
986
+ # T-44 (#437): startup-state emitters. `session-start.py:1091-1195` (pre-#437)
987
+ # printed everything after the persona unconditionally, gated only on
988
+ # frontmatter — SMARTS ruled (strength `strong`, plan alternatives-considered)
989
+ # against wholesale mode-based suppression and FOR decomposing into per-mode
990
+ # COMPOSABLE emitters instead: the block is eight independent things with
991
+ # different audiences (banner, stage:, CONFIRM-NN, task summary, provenance
992
+ # drift, update notice, trailer, daily briefing), and the lines wholesale
993
+ # suppression would remove are precisely the ones a gates-off session most
994
+ # needs (Securable).
995
+ #
996
+ # Each emitter below is INDIVIDUALLY CALLABLE (AC-30) with its own explicit
997
+ # inputs — no hidden env/global/clock reads inside any of them (a `today`/
998
+ # `now` argument is always accepted for injection) — and prints directly,
999
+ # mirroring this file's pre-existing style (`render_full_briefing` already
1000
+ # printed rather than returning lines; T-44 does not change that convention,
1001
+ # only names and isolates each piece so it can be exercised alone).
1002
+ # --------------------------------------------------------------------------- #
1003
+
1004
+
1005
+ def emit_banner(host_name, mode):
1006
+ """AC-32: host + active mode — emitted in EVERY mode, unconditionally."""
1007
+ print(f"host: {host_name}")
1008
+ print(f"mode: {mode}")
1009
+
1010
+
1011
+ def emit_not_initialized(root, host, mode):
1012
+ """The NOT-INITIALIZED early-exit banner — mode-aware TEXT, not an
1013
+ unconditional print (a non-arbiter mode has no commands, so instructing
1014
+ the user to run {create-context}/{decompose}/{commands} would name a
1015
+ surface that mode cannot use)."""
1016
+ if mode != _modelib.MODES[0]:
1017
+ print("NOT INITIALIZED: this repo has not opted into codeArbiter's "
1018
+ "governance workflow. No commands are available in this mode.")
1019
+ return
1020
+ if has_source(root):
1021
+ print(f"NOT INITIALIZED: source exists but .codearbiter/CONTEXT.md is a stub. "
1022
+ f"Run {host.cmd_ref('create-context')} before any other command.")
1023
+ else:
1024
+ print(f"NOT INITIALIZED: empty project. Run {host.cmd_ref('decompose')} to begin.")
1025
+ print(f"Type {host.cmd_ref('commands')} for the catalog.")
1026
+
1027
+
1028
+ def emit_stage(ctx_text):
1029
+ """AC-32: 'stage: N' — emitted in EVERY mode, unconditionally."""
1030
+ m = STAGE_RE.search(ctx_text)
1031
+ print(f"stage: {m.group(1) if m else '—'}")
1032
+
1033
+
1034
+ def emit_confirm_nn(oq_text):
1035
+ """[CONFIRM-NN] surfacing — pinned ON in every mode (never gated on mode:
1036
+ SMARTS's decisive Securable finding is that this is precisely what a
1037
+ gates-off session most needs). `oq_text` of None (file unreadable/absent)
1038
+ emits nothing, matching the pre-#437 behavior."""
1039
+ if oq_text is None:
1040
+ return
1041
+ confirms = CONFIRM_RE.findall(oq_text)
1042
+ if confirms:
1043
+ print(f"BLOCKING questions (CONFIRM-NN): {len(confirms)} — must resolve before "
1044
+ f"dependent work proceeds:")
1045
+ for ln in oq_text.splitlines():
1046
+ if CONFIRM_RE.search(ln):
1047
+ print(f" {ln}")
1048
+ else:
1049
+ print("open questions: 0")
1050
+
1051
+
1052
+ def emit_task_summary(ot_text, today=None):
1053
+ """The open-tasks summary. `today` is injectable for deterministic tests;
1054
+ defaults to the real local date at the I/O edge. `ot_text` of None
1055
+ (file unreadable/absent) emits nothing, matching the pre-#437 behavior."""
1056
+ if ot_text is None:
1057
+ return
1058
+ today = today if today is not None else datetime.date.today()
1059
+ try:
1060
+ for _line in _taskboardlib.startup_summary(ot_text, today):
1061
+ print(_line)
1062
+ except Exception as _e: # noqa: BLE001 — never crash session startup
1063
+ n = sum(1 for ln in ot_text.splitlines()
1064
+ if ln.startswith("- ") and not ln.startswith("- [x]"))
1065
+ print(f"in-flight tasks: {n}")
1066
+ print(f"codeArbiter: task-board summary degraded ({_e}); "
1067
+ f"check .codearbiter/open-tasks.md", file=sys.stderr)
1068
+
1069
+
1070
+ def emit_provenance_drift(drift_line):
1071
+ """The passive provenance-drift notice — ONE line, or none."""
1072
+ if drift_line:
1073
+ print(drift_line)
1074
+
1075
+
1076
+ def emit_update_notice(update_line):
1077
+ """The update-available notice — ONE line, or none."""
1078
+ if update_line:
1079
+ print(update_line)
1080
+
1081
+
1082
+ def emit_trailer(host):
1083
+ """AC-32: the await-a-command trailer + catalog reference. ARBITER-ONLY —
1084
+ the caller omits this emitter entirely for a non-arbiter startup (a mode
1085
+ with no commands has nothing to 'await')."""
1086
+ print(f"Present this state, then await a {host.command_noun}. "
1087
+ f"Type {host.cmd_ref('commands')} for the catalog.")
1088
+
1089
+
1090
+ def emit_daily_briefing(root, summary, date_iso, marker_present, ctx_text=None,
1091
+ ot_text=None, oq_text=None, host=None):
1092
+ """The daily standup briefing (full/offer/none). AC-32: ARBITER-ONLY —
1093
+ the caller omits this emitter entirely for a non-arbiter startup (every
1094
+ variant of it references {standup}, a command that mode does not have).
1095
+
1096
+ Returns the resolved kind ("full"/"offer"/"none") so the caller knows
1097
+ whether to persist the standup marker — writing that marker is a state
1098
+ mutation kept OUT of this function so its printed output stays a pure
1099
+ function of its inputs (AC-30)."""
1100
+ kind = briefing_mode(marker_present, any_actionable(summary))
1101
+ if kind == "full":
1102
+ print()
1103
+ print(f"=== codeArbiter daily briefing ({date_iso}) ===")
1104
+ print("First session of the day. Daily standup briefing (read-only).")
1105
+ render_full_briefing(root, summary, ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text)
1106
+ elif kind == "offer":
1107
+ print(OFFER_LINE_TEMPLATE.format(standup=(host or get_host()).cmd_ref("standup")))
1108
+ return kind
1109
+
1110
+
1015
1111
  def main():
1016
1112
  utf8_stdio()
1017
1113
  # get_host() (#257): resolves the SAME Host run(host) already primed via
1018
1114
  # set_host(), instead of a second hostapi.load_host() disk/probe.
1019
1115
  host = get_host()
1020
1116
  root = project_root()
1117
+ # [[NEEDS-TRIAGE root-resolution split]] (#437, found by Lane A, closed
1118
+ # here by Lane E): `_modelib.flip()` (prompt-submit.py, Lane B) resolves
1119
+ # BOTH the mode marker and the `MODE: … enter` audit row through
1120
+ # `marker_root`, not `project_root` — `marker_root` exists precisely
1121
+ # because `project_root` splits marker state across linked worktrees
1122
+ # (#604). The close/exit half written by THIS file must resolve the
1123
+ # SAME way, or an `enter` row and its matching `exit` row can land in
1124
+ # two DIFFERENT overrides.log files when this hook runs inside a linked
1125
+ # worktree. `mode_root` is therefore used for every mode-plane read/write
1126
+ # below (clear_mode_marker, current_mode) — `root` (project_root) stays
1127
+ # the source-tree root for everything else (has_source, the task board,
1128
+ # provenance, git hygiene).
1021
1129
  plugin = host.plugin_root()
1022
1130
  ctx = os.path.join(root, ".codearbiter", "CONTEXT.md")
1023
- session_id = _session_id_from_stdin()
1024
1131
 
1025
- # /dev developer-override is per-session: clear its statusline marker on
1026
- # startup a new session restores orchestration. A live marker means a prior
1027
- # session never ran /ca:arbiter, so close the DEV audit pair before clearing.
1028
- # session_id (#271 C-5) lets this distinguish "the same session resuming"
1029
- # and "a different, possibly still-live session" from a genuinely
1030
- # abandoned marker see clear_dev_marker's docstring.
1031
- clear_dev_marker(root, host.name, session_id)
1132
+ # T-42/T-47 (#437): the single mode-plane settlement pass see the
1133
+ # module comment above clear_mode_marker for the full contract
1134
+ # (owner-liveness heuristic, the merged dev-active migration, the
1135
+ # cross-lane note for Lane B's compaction test).
1136
+ #
1137
+ # The whole mode-plane resolution is guarded because NOTHING here may cost
1138
+ # the repository its git-level enforcement backstop. The enforcer install
1139
+ # below is what closes `--no-verify` (ADR-0015, H-01/H-02), and it runs
1140
+ # AFTER this block — so an exception raised here removes that backstop
1141
+ # silently, which is a far worse outcome than an unresolved mode. The raise
1142
+ # is not hypothetical: `marker_root` reaches `hostapi.git_toplevel`, whose
1143
+ # very first statement calls `git_executable()` OUTSIDE its own try, and
1144
+ # `_gitexec._trusted_environment_path` raises RuntimeError on a
1145
+ # CODEARBITER_GIT_EXECUTABLE that is relative or no longer a file.
1146
+ #
1147
+ # The fallback is `arbiter` — gates ON — per ADR-0030's fail direction: a
1148
+ # failed transition INTO dangerous mode is safe, a failed transition out of
1149
+ # it is not, so unresolvable state resolves to the governed posture. The
1150
+ # breadcrumb goes to stderr rather than being swallowed, so a mode plane
1151
+ # that is quietly broken on this host is visible instead of merely absent.
1152
+ mode_root, session_id = root, ""
1153
+ mode, _mode_diag = _modelib.MODES[0], None
1154
+ try:
1155
+ mode_root = marker_root(_stdin_payload())
1156
+ session_id = _session_id_from_stdin()
1157
+ clear_mode_marker(mode_root, host.name, session_id)
1158
+ if session_id:
1159
+ mode, _mode_diag = _modelib.current_mode(session_id, root=mode_root)
1160
+ except Exception as exc: # noqa: BLE001 — startup must survive this
1161
+ print(f"codeArbiter: mode plane unavailable this session ({type(exc).__name__}: "
1162
+ f"{exc}); continuing as '{_modelib.MODES[0]}' with every gate enforced.",
1163
+ file=sys.stderr)
1032
1164
 
1033
1165
  # Self-heal a stale ca-owned statusLine pin before the dormant gate: the
1034
1166
  # statusline is wired GLOBALLY in ~/.claude/settings.json, so a plugin update
@@ -1077,122 +1209,84 @@ def main():
1077
1209
  or os.environ.get("CODEARBITER_PYTHON_EXECUTABLE")):
1078
1210
  raise
1079
1211
 
1080
- # --- Arbiter active: inject persona ---
1081
- orch = os.path.join(plugin, "ORCHESTRATOR.md")
1082
- orch_text = read_text(orch)
1083
- if orch_text is not None:
1084
- sys.stdout.write(orch_text)
1085
- print()
1086
- else:
1087
- print(f"codeArbiter: ORCHESTRATOR.md not found at {orch} persona not injected. "
1088
- f"Check CLAUDE_PLUGIN_ROOT.", file=sys.stderr)
1089
-
1090
- # --- Inject live startup state ---
1212
+ # T-41 (#437, AC-27): persona injection REMOVED from SessionStart. It
1213
+ # moves to the per-turn prompt seam (Lane B, prompt-submit.py) — the
1214
+ # persona is now `safety-core.md` + the active mode's body, composed and
1215
+ # deduped per (session, mode, compaction generation). SessionStart fires
1216
+ # once per session boundary (and on compact), so it cannot react to a
1217
+ # mid-session mode flip; the per-turn seam can. This hook still emits the
1218
+ # startup-state block below, unconditionally of mode (AC-27: "injects no
1219
+ # persona and still emits the startup-state block").
1220
+
1221
+ # --- Startup-state block: per-mode composable emitters (T-44) ---------
1222
+ # AC-30: each emitter below is individually callable with only its own
1223
+ # explicit inputs. AC-32: host/stage/active-mode are unconditional in
1224
+ # every mode; the await-a-command trailer and the daily briefing (which
1225
+ # references {standup}) are ARBITER-ONLY.
1091
1226
  print("=== codeArbiter startup state ===")
1092
1227
  # observability-004 (#268): name the RESOLVED host so a dormant/broken
1093
1228
  # host (FailClosedHost -> name "unknown", #255) is visible right in the
1094
1229
  # banner instead of being indistinguishable from a working install.
1095
- print(f"host: {getattr(host, 'name', 'unknown')}")
1230
+ emit_banner(getattr(host, "name", "unknown"), mode)
1096
1231
 
1097
1232
  ctx_text = read_text(ctx) or ""
1098
1233
  if not INITIALIZED_RE.search(ctx_text):
1099
- if has_source(root):
1100
- print(f"NOT INITIALIZED: source exists but .codearbiter/CONTEXT.md is a stub. "
1101
- f"Run {host.cmd_ref('create-context')} before any other command.")
1102
- else:
1103
- print(f"NOT INITIALIZED: empty project. Run {host.cmd_ref('decompose')} to begin.")
1104
- print(f"Type {host.cmd_ref('commands')} for the catalog.")
1234
+ emit_not_initialized(root, host, mode)
1105
1235
  sys.exit(0)
1106
1236
 
1107
- m = STAGE_RE.search(ctx_text)
1108
- print(f"stage: {m.group(1) if m else '—'}")
1237
+ emit_stage(ctx_text)
1109
1238
 
1110
1239
  oq = os.path.join(root, ".codearbiter", "open-questions.md")
1111
1240
  oq_text = read_text(oq)
1112
- if oq_text is not None:
1113
- confirms = CONFIRM_RE.findall(oq_text)
1114
- if confirms:
1115
- print(f"BLOCKING questions (CONFIRM-NN): {len(confirms)} — must resolve before "
1116
- f"dependent work proceeds:")
1117
- for ln in oq_text.splitlines():
1118
- if CONFIRM_RE.search(ln):
1119
- print(f" {ln}")
1120
- else:
1121
- print("open questions: 0")
1241
+ emit_confirm_nn(oq_text)
1122
1242
 
1123
1243
  ot = os.path.join(root, ".codearbiter", "open-tasks.md")
1124
1244
  ot_text = read_text(ot)
1125
- if ot_text is not None:
1126
- # Shared helper: in-flight count (excludes done) + a stale-in-progress
1127
- # nudge + undated/malformed warnings. Oversize boards degrade to a
1128
- # one-line notice. Guarded: the task board must never take down the
1129
- # linchpin hook — on any unexpected parse error, fail LOUD (stderr
1130
- # breadcrumb) and fall back to the raw count, never go dormant.
1131
- try:
1132
- for _line in _taskboardlib.startup_summary(ot_text, datetime.date.today()):
1133
- print(_line)
1134
- except Exception as _e: # noqa: BLE001 — never crash session startup
1135
- n = sum(1 for ln in ot_text.splitlines()
1136
- if ln.startswith("- ") and not ln.startswith("- [x]"))
1137
- print(f"in-flight tasks: {n}")
1138
- print(f"codeArbiter: task-board summary degraded ({_e}); "
1139
- f"check .codearbiter/open-tasks.md", file=sys.stderr)
1245
+ emit_task_summary(ot_text)
1140
1246
 
1141
1247
  # --- Passive provenance drift notice (T-16, spec pillar 4) ---
1142
- # ONE line emitted only when drift > 0; silent when docs are fresh or on any
1143
- # degrade (wrapper swallows all exceptions — never crashes the linchpin hook).
1144
1248
  _drift = provenance_drift_line(root)
1145
- if _drift:
1146
- print(_drift)
1249
+ emit_provenance_drift(_drift)
1147
1250
 
1148
1251
  # --- Update-available notice (AC-1/AC-2/AC-3) --------------------------
1149
- # ONE line, read from the cache only (no network here); silent when the
1150
- # installed version is current or the cache is absent/stale/corrupt.
1151
1252
  _update = update_notice_line(plugin)
1152
- if _update:
1153
- print(_update)
1253
+ emit_update_notice(_update)
1254
+
1255
+ if mode == _modelib.MODES[0]:
1256
+ emit_trailer(host)
1257
+
1258
+ # --- Standup briefing (SH-1 full / SH-2 offer) ---------------------
1259
+ # Additive, AFTER the startup-state block. Read-only: no git mutation
1260
+ # here. ARBITER-ONLY (AC-32): every variant references {standup}, a
1261
+ # command a non-arbiter mode does not have.
1262
+ # first session of the day (no marker) -> full briefing + drop marker
1263
+ # later session today, actionable -> exactly ONE offer line
1264
+ # later session today, nothing to do -> emit nothing
1265
+ date_iso = local_date_iso()
1266
+ marker_present = not should_emit_briefing(root, date_iso)
1267
+
1268
+ # Read-only git assembly. ahead/behind comes from the LAST COMPLETED
1269
+ # fetch (current local refs); we annotate it as possibly stale and
1270
+ # kick a DETACHED fetch to refresh for NEXT time without blocking
1271
+ # this hook's return.
1272
+ current = head_branch(root)
1273
+ default = os.environ.get("CODEARBITER_BASE_BRANCH") or "main"
1274
+ summary = assemble_summary(root, current=current, default=default)
1275
+ spawn_background_fetch(root) # detached; never awaited
1276
+ spawn_background_update_refresh(plugin) # detached; never awaited (AC-3/AC-4)
1154
1277
 
1155
- print(f"Present this state, then await a {host.command_noun}. "
1156
- f"Type {host.cmd_ref('commands')} for the catalog.")
1157
-
1158
- # --- Standup briefing (SH-1 full / SH-2 offer) ---
1159
- # Additive, AFTER the startup-state block. Read-only: no git mutation here.
1160
- # first session of the day (no marker) -> full briefing + drop marker
1161
- # later session today, actionable -> exactly ONE offer line
1162
- # later session today, nothing to do -> emit nothing
1163
- # The git-derived `summary` (dirty/behind/ahead/prune candidates/worktrees/
1164
- # stashes) is assembled below from read-only git reads; any_actionable(summary)
1165
- # then decides whether a later same-day session emits its single offer line. A
1166
- # clean repo yields an all-quiet summary, so later sessions stay silent — the
1167
- # conservative default.
1168
- date_iso = local_date_iso()
1169
- marker_present = not should_emit_briefing(root, date_iso)
1170
-
1171
- # Read-only git assembly. ahead/behind comes from the LAST COMPLETED fetch
1172
- # (current local refs); we annotate it as possibly stale and kick a DETACHED
1173
- # fetch to refresh for NEXT time without blocking this hook's return.
1174
- current = head_branch(root)
1175
- default = os.environ.get("CODEARBITER_BASE_BRANCH") or "main"
1176
- summary = assemble_summary(root, current=current, default=default)
1177
- spawn_background_fetch(root) # detached; never awaited
1178
- spawn_background_update_refresh(plugin) # detached; never awaited (AC-3/AC-4)
1179
-
1180
- mode = briefing_mode(marker_present, any_actionable(summary))
1181
- if mode == "full":
1182
- print()
1183
- print(f"=== codeArbiter daily briefing ({date_iso}) ===")
1184
- print("First session of the day. Daily standup briefing (read-only).")
1185
1278
  # performance-003 (#194): ctx_text/ot_text/oq_text were already read
1186
1279
  # above for the startup-state block — thread them through so
1187
- # governance_line's arbiter_state() call doesn't re-read the same three
1188
- # files a second time in this same invocation.
1189
- render_full_briefing(root, summary, ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text)
1190
- try:
1191
- write_standup_marker(root, date_iso)
1192
- except Exception: # noqa: BLE001 — must never brick session startup
1193
- pass
1194
- elif mode == "offer":
1195
- print(OFFER_LINE_TEMPLATE.format(standup=get_host().cmd_ref("standup")))
1280
+ # governance_line's arbiter_state() call doesn't re-read the same
1281
+ # three files a second time in this same invocation.
1282
+ briefing_kind = emit_daily_briefing(
1283
+ root, summary, date_iso, marker_present,
1284
+ ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text, host=host)
1285
+ if briefing_kind == "full":
1286
+ try:
1287
+ write_standup_marker(root, date_iso)
1288
+ except Exception: # noqa: BLE001 — must never brick session startup
1289
+ pass
1196
1290
 
1197
1291
  sys.exit(0)
1198
1292