@arbiterforge/ca-pi 0.6.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +21 -17
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +74 -0
  4. package/plugins/ca-pi/COMMANDS.md +0 -7
  5. package/plugins/ca-pi/SKILLS.md +0 -2
  6. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  7. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +28 -36
  8. package/plugins/ca-pi/extensions/codearbiter-child.js +2 -2
  9. package/plugins/ca-pi/extensions/codearbiter.js +766 -26
  10. package/plugins/ca-pi/generated/command-catalog.json +0 -10
  11. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  12. package/plugins/ca-pi/hooks/_bashguardlib.py +12 -1
  13. package/plugins/ca-pi/hooks/_hooklib.py +58 -17
  14. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  15. package/plugins/ca-pi/hooks/_modelib.py +644 -0
  16. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  17. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  18. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  19. package/plugins/ca-pi/hooks/doctor.py +2 -1
  20. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  21. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  22. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  23. package/plugins/ca-pi/hooks/session-start.py +526 -434
  24. package/plugins/ca-pi/hooks/statusline.py +27 -9
  25. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  26. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  27. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  28. package/plugins/ca-pi/includes/pi-host-notes.md +14 -3
  29. package/plugins/ca-pi/includes/redirect.md +12 -1
  30. package/plugins/ca-pi/includes/routing-table.md +1 -0
  31. package/plugins/ca-pi/includes/safety-core.md +86 -0
  32. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  33. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +1 -1
  34. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  35. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +1 -1
  36. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  37. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  38. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  39. 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,298 @@ 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
+ # The raw state map is the only place that distinction survives.
758
+ # A corrupt or unreadable state file returns `{}` WITH a diagnostic,
759
+ # which reads as "no entry" and would migrate `dangerous` back on top
760
+ # of a user who had explicitly returned to `arbiter`. Absence is the
761
+ # only clean "nothing to convert over"; anything we could not read is
762
+ # not evidence of anything, and guessing gates-off from unreadable
763
+ # state is the one direction ADR-0030 forbids.
764
+ mode_state, state_diag = _modelib._read_mode_state(root)
765
+ readable = state_diag in (None, _modelib.MODE_DIAG_ABSENT)
766
+ unconverted = readable and str(session_id) not in mode_state
767
+ if marker_live and unconverted and _modelib.write_mode(session_id, "dangerous", root=root):
768
+ try:
769
+ os.remove(marker)
770
+ except OSError:
771
+ # The conversion landed; a leftover legacy file is harmless
772
+ # — the NEXT is_owner pass just retries the removal (write_
773
+ # mode(session_id, "dangerous") is idempotent).
774
+ pass
775
+ return
776
+
777
+ # Not the recognised owner. Two independent settlements follow.
851
778
 
779
+ # (1) T-42/AC-4: clear session_id's OWN mode-plane entry, if any — never
780
+ # a different session's (see the module comment's force-close-scope
781
+ # note). Independent of the legacy marker's liveness window below.
782
+ #
783
+ # `not mode_seen` is what distinguishes a NEW session from this session
784
+ # resuming or compacting. AC-4 is in fact satisfied structurally — the mode
785
+ # file is keyed by session_id, so a genuinely new session has no entry and
786
+ # already reads `arbiter` — which means this clear can only ever fire for a
787
+ # session that previously flipped. Without the guard that is exactly the
788
+ # compaction AC-25 exists to preserve, and the mode would be cleared out
789
+ # from under a live session.
790
+ if session_id and not mode_seen:
791
+ mode, _diag = _modelib.current_mode(session_id, root=root)
792
+ if mode != _modelib.MODES[0]:
793
+ if host_name is None:
794
+ try:
795
+ # get_host() (#257): resolves the SAME Host run(host)
796
+ # injected instead of a second load.
797
+ host_name = get_host().name
798
+ except Exception: # noqa: BLE001 — must never brick session startup
799
+ host_name = "unknown"
800
+ # T-43/AC-35: this line names NO command — unlike the retired
801
+ # clear_dev_marker's `cmd_ref("arbiter")` (which stamped a
802
+ # permanent dangling reference into overrides.log once
803
+ # `/ca:arbiter` was deleted), _modelib._mode_audit_line's NOTE
804
+ # is a bare "—".
805
+ line = _modelib._mode_audit_line("exit", mode, host_name=host_name, now=now,
806
+ session_id=session_id)
807
+ # #396: stage-then-append BEFORE the state mutation below, so an
808
+ # interruption between them leaves the row owed and replayable
809
+ # rather than silently lost.
810
+ _settle_dev_close(root, new_line=line, host_name=host_name)
811
+ _modelib.write_mode(session_id, _modelib.MODES[0], root=root)
812
+
813
+ # (2) T-47/legacy: the dev-active marker's owner-liveness-gated
814
+ # force-close — verbatim the pre-#437 `clear_dev_marker` contract.
852
815
  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.
816
+ # No live marker: replay anything already owed from an earlier
817
+ # crash (#396), and record this session as a candidate future owner
818
+ # — harmless, and exactly what let a later invocation recognise it.
863
819
  _settle_dev_close(root, host_name=host_name)
864
820
  if session_id:
865
821
  _write_dev_session_owner(root, session_id, now)
866
822
  return
867
823
 
868
824
  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
825
+ # is_owner was already False above, so prev_sid != session_id here:
826
+ # a DIFFERENT, possibly still-live session owns this marker.
883
827
  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.
828
+ # The owner's own clock hasn't elapsed yet — do not touch the
829
+ # record or the marker.
887
830
  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.
831
+ # Stale beyond the window: proceed to force-close below. Deliberately
832
+ # do not write a fresh owner record here either — there is no live
833
+ # owner left to anchor a new one to.
892
834
 
893
835
  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.
836
+ # No prior record at all no signal to protect a concurrent owner;
837
+ # seed the record for next time and fall through to force-close.
897
838
  _write_dev_session_owner(root, session_id, now)
898
839
 
899
840
  # Force-close: either no session_id/no prior record (unconditional-clear
900
841
  # fallback), or a genuinely stale owner beyond the window.
901
842
  if host_name is None:
902
843
  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
844
  host_name = get_host().name
906
845
  except Exception: # noqa: BLE001 — must never brick session startup
907
846
  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
847
  ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
913
848
  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.
849
+ f"SessionStart (prior session ended mid-dev with no live owner)\n")
850
+ # #396: stage-then-append-then-clear, all inside one settlement step.
918
851
  _settle_dev_close(root, marker=marker, new_line=line, host_name=host_name)
919
852
 
920
853
 
@@ -988,6 +921,47 @@ def spawn_background_update_refresh(plugin, spawner=None):
988
921
  return None
989
922
 
990
923
 
924
+ _STDIN_PAYLOAD = None # None = not read yet; a dict once read (possibly empty)
925
+
926
+
927
+ def _stdin_payload():
928
+ """The SessionStart hook's raw JSON payload as a dict, read AT MOST ONCE.
929
+
930
+ stdin is a stream: whoever reads it first consumes it, so the session_id
931
+ reader and the `marker_root` payload cannot each do their own read. This
932
+ caches the parsed dict and both callers share it.
933
+
934
+ Why `marker_root` needs the payload at all (#437 regression): its host
935
+ delegate resolves a linked worktree by SPAWNING GIT when it has no payload
936
+ to resolve from. An argument-less call therefore adds a git subprocess to
937
+ every session start, and on Windows a bare `git` can resolve from the
938
+ current directory — so a repository containing its own `git.exe` gets that
939
+ one executed. When that spawn misbehaves, startup dies BEFORE the
940
+ git-enforcer install below, and the repo silently loses the H-01/H-02
941
+ backstop that closes `--no-verify` (ADR-0015).
942
+
943
+ Returns {} on any failure, absence, or malformed payload — the same silent
944
+ degradation `_session_id_from_stdin` has always had, for the same reason:
945
+ a host that supplies no payload is a normal condition, not an error worth
946
+ a breadcrumb on every session start."""
947
+ global _STDIN_PAYLOAD
948
+ if _STDIN_PAYLOAD is not None:
949
+ return _STDIN_PAYLOAD
950
+ _STDIN_PAYLOAD = {}
951
+ try:
952
+ if sys.stdin.isatty():
953
+ return _STDIN_PAYLOAD
954
+ raw = sys.stdin.read()
955
+ if not raw.strip():
956
+ return _STDIN_PAYLOAD
957
+ data = json.loads(raw)
958
+ if isinstance(data, dict):
959
+ _STDIN_PAYLOAD = data
960
+ except Exception: # noqa: BLE001 — must never brick session startup
961
+ pass
962
+ return _STDIN_PAYLOAD
963
+
964
+
991
965
  def _session_id_from_stdin():
992
966
  """Best-effort session_id from the SessionStart hook's own JSON payload
993
967
  (#271 C-5) — session-start.py has never read its stdin before this. Reads
@@ -1001,34 +975,190 @@ def _session_id_from_stdin():
1001
975
  payload; the caller treats an empty session_id as "unavailable" and
1002
976
  degrades to the pre-#271 unconditional-clear behavior."""
1003
977
  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 ""
978
+ return str(_stdin_payload().get("session_id") or "")
1011
979
  except Exception: # noqa: BLE001 — must never brick session startup
1012
980
  return ""
1013
981
 
1014
982
 
983
+ # --------------------------------------------------------------------------- #
984
+ # T-44 (#437): startup-state emitters. `session-start.py:1091-1195` (pre-#437)
985
+ # printed everything after the persona unconditionally, gated only on
986
+ # frontmatter — SMARTS ruled (strength `strong`, plan alternatives-considered)
987
+ # against wholesale mode-based suppression and FOR decomposing into per-mode
988
+ # COMPOSABLE emitters instead: the block is eight independent things with
989
+ # different audiences (banner, stage:, CONFIRM-NN, task summary, provenance
990
+ # drift, update notice, trailer, daily briefing), and the lines wholesale
991
+ # suppression would remove are precisely the ones a gates-off session most
992
+ # needs (Securable).
993
+ #
994
+ # Each emitter below is INDIVIDUALLY CALLABLE (AC-30) with its own explicit
995
+ # inputs — no hidden env/global/clock reads inside any of them (a `today`/
996
+ # `now` argument is always accepted for injection) — and prints directly,
997
+ # mirroring this file's pre-existing style (`render_full_briefing` already
998
+ # printed rather than returning lines; T-44 does not change that convention,
999
+ # only names and isolates each piece so it can be exercised alone).
1000
+ # --------------------------------------------------------------------------- #
1001
+
1002
+
1003
+ def emit_banner(host_name, mode):
1004
+ """AC-32: host + active mode — emitted in EVERY mode, unconditionally."""
1005
+ print(f"host: {host_name}")
1006
+ print(f"mode: {mode}")
1007
+
1008
+
1009
+ def emit_not_initialized(root, host, mode):
1010
+ """The NOT-INITIALIZED early-exit banner — mode-aware TEXT, not an
1011
+ unconditional print (a non-arbiter mode has no commands, so instructing
1012
+ the user to run {create-context}/{decompose}/{commands} would name a
1013
+ surface that mode cannot use)."""
1014
+ if mode != _modelib.MODES[0]:
1015
+ print("NOT INITIALIZED: this repo has not opted into codeArbiter's "
1016
+ "governance workflow. No commands are available in this mode.")
1017
+ return
1018
+ if has_source(root):
1019
+ print(f"NOT INITIALIZED: source exists but .codearbiter/CONTEXT.md is a stub. "
1020
+ f"Run {host.cmd_ref('create-context')} before any other command.")
1021
+ else:
1022
+ print(f"NOT INITIALIZED: empty project. Run {host.cmd_ref('decompose')} to begin.")
1023
+ print(f"Type {host.cmd_ref('commands')} for the catalog.")
1024
+
1025
+
1026
+ def emit_stage(ctx_text):
1027
+ """AC-32: 'stage: N' — emitted in EVERY mode, unconditionally."""
1028
+ m = STAGE_RE.search(ctx_text)
1029
+ print(f"stage: {m.group(1) if m else '—'}")
1030
+
1031
+
1032
+ def emit_confirm_nn(oq_text):
1033
+ """[CONFIRM-NN] surfacing — pinned ON in every mode (never gated on mode:
1034
+ SMARTS's decisive Securable finding is that this is precisely what a
1035
+ gates-off session most needs). `oq_text` of None (file unreadable/absent)
1036
+ emits nothing, matching the pre-#437 behavior."""
1037
+ if oq_text is None:
1038
+ return
1039
+ confirms = CONFIRM_RE.findall(oq_text)
1040
+ if confirms:
1041
+ print(f"BLOCKING questions (CONFIRM-NN): {len(confirms)} — must resolve before "
1042
+ f"dependent work proceeds:")
1043
+ for ln in oq_text.splitlines():
1044
+ if CONFIRM_RE.search(ln):
1045
+ print(f" {ln}")
1046
+ else:
1047
+ print("open questions: 0")
1048
+
1049
+
1050
+ def emit_task_summary(ot_text, today=None):
1051
+ """The open-tasks summary. `today` is injectable for deterministic tests;
1052
+ defaults to the real local date at the I/O edge. `ot_text` of None
1053
+ (file unreadable/absent) emits nothing, matching the pre-#437 behavior."""
1054
+ if ot_text is None:
1055
+ return
1056
+ today = today if today is not None else datetime.date.today()
1057
+ try:
1058
+ for _line in _taskboardlib.startup_summary(ot_text, today):
1059
+ print(_line)
1060
+ except Exception as _e: # noqa: BLE001 — never crash session startup
1061
+ n = sum(1 for ln in ot_text.splitlines()
1062
+ if ln.startswith("- ") and not ln.startswith("- [x]"))
1063
+ print(f"in-flight tasks: {n}")
1064
+ print(f"codeArbiter: task-board summary degraded ({_e}); "
1065
+ f"check .codearbiter/open-tasks.md", file=sys.stderr)
1066
+
1067
+
1068
+ def emit_provenance_drift(drift_line):
1069
+ """The passive provenance-drift notice — ONE line, or none."""
1070
+ if drift_line:
1071
+ print(drift_line)
1072
+
1073
+
1074
+ def emit_update_notice(update_line):
1075
+ """The update-available notice — ONE line, or none."""
1076
+ if update_line:
1077
+ print(update_line)
1078
+
1079
+
1080
+ def emit_trailer(host):
1081
+ """AC-32: the await-a-command trailer + catalog reference. ARBITER-ONLY —
1082
+ the caller omits this emitter entirely for a non-arbiter startup (a mode
1083
+ with no commands has nothing to 'await')."""
1084
+ print(f"Present this state, then await a {host.command_noun}. "
1085
+ f"Type {host.cmd_ref('commands')} for the catalog.")
1086
+
1087
+
1088
+ def emit_daily_briefing(root, summary, date_iso, marker_present, ctx_text=None,
1089
+ ot_text=None, oq_text=None, host=None):
1090
+ """The daily standup briefing (full/offer/none). AC-32: ARBITER-ONLY —
1091
+ the caller omits this emitter entirely for a non-arbiter startup (every
1092
+ variant of it references {standup}, a command that mode does not have).
1093
+
1094
+ Returns the resolved kind ("full"/"offer"/"none") so the caller knows
1095
+ whether to persist the standup marker — writing that marker is a state
1096
+ mutation kept OUT of this function so its printed output stays a pure
1097
+ function of its inputs (AC-30)."""
1098
+ kind = briefing_mode(marker_present, any_actionable(summary))
1099
+ if kind == "full":
1100
+ print()
1101
+ print(f"=== codeArbiter daily briefing ({date_iso}) ===")
1102
+ print("First session of the day. Daily standup briefing (read-only).")
1103
+ render_full_briefing(root, summary, ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text)
1104
+ elif kind == "offer":
1105
+ print(OFFER_LINE_TEMPLATE.format(standup=(host or get_host()).cmd_ref("standup")))
1106
+ return kind
1107
+
1108
+
1015
1109
  def main():
1016
1110
  utf8_stdio()
1017
1111
  # get_host() (#257): resolves the SAME Host run(host) already primed via
1018
1112
  # set_host(), instead of a second hostapi.load_host() disk/probe.
1019
1113
  host = get_host()
1020
1114
  root = project_root()
1115
+ # [[NEEDS-TRIAGE root-resolution split]] (#437, found by Lane A, closed
1116
+ # here by Lane E): `_modelib.flip()` (prompt-submit.py, Lane B) resolves
1117
+ # BOTH the mode marker and the `MODE: … enter` audit row through
1118
+ # `marker_root`, not `project_root` — `marker_root` exists precisely
1119
+ # because `project_root` splits marker state across linked worktrees
1120
+ # (#604). The close/exit half written by THIS file must resolve the
1121
+ # SAME way, or an `enter` row and its matching `exit` row can land in
1122
+ # two DIFFERENT overrides.log files when this hook runs inside a linked
1123
+ # worktree. `mode_root` is therefore used for every mode-plane read/write
1124
+ # below (clear_mode_marker, current_mode) — `root` (project_root) stays
1125
+ # the source-tree root for everything else (has_source, the task board,
1126
+ # provenance, git hygiene).
1021
1127
  plugin = host.plugin_root()
1022
1128
  ctx = os.path.join(root, ".codearbiter", "CONTEXT.md")
1023
- session_id = _session_id_from_stdin()
1024
1129
 
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)
1130
+ # T-42/T-47 (#437): the single mode-plane settlement pass see the
1131
+ # module comment above clear_mode_marker for the full contract
1132
+ # (owner-liveness heuristic, the merged dev-active migration, the
1133
+ # cross-lane note for Lane B's compaction test).
1134
+ #
1135
+ # The whole mode-plane resolution is guarded because NOTHING here may cost
1136
+ # the repository its git-level enforcement backstop. The enforcer install
1137
+ # below is what closes `--no-verify` (ADR-0015, H-01/H-02), and it runs
1138
+ # AFTER this block — so an exception raised here removes that backstop
1139
+ # silently, which is a far worse outcome than an unresolved mode. The raise
1140
+ # is not hypothetical: `marker_root` reaches `hostapi.git_toplevel`, whose
1141
+ # very first statement calls `git_executable()` OUTSIDE its own try, and
1142
+ # `_gitexec._trusted_environment_path` raises RuntimeError on a
1143
+ # CODEARBITER_GIT_EXECUTABLE that is relative or no longer a file.
1144
+ #
1145
+ # The fallback is `arbiter` — gates ON — per ADR-0030's fail direction: a
1146
+ # failed transition INTO dangerous mode is safe, a failed transition out of
1147
+ # it is not, so unresolvable state resolves to the governed posture. The
1148
+ # breadcrumb goes to stderr rather than being swallowed, so a mode plane
1149
+ # that is quietly broken on this host is visible instead of merely absent.
1150
+ mode_root, session_id = root, ""
1151
+ mode, _mode_diag = _modelib.MODES[0], None
1152
+ try:
1153
+ mode_root = marker_root(_stdin_payload())
1154
+ session_id = _session_id_from_stdin()
1155
+ clear_mode_marker(mode_root, host.name, session_id)
1156
+ if session_id:
1157
+ mode, _mode_diag = _modelib.current_mode(session_id, root=mode_root)
1158
+ except Exception as exc: # noqa: BLE001 — startup must survive this
1159
+ print(f"codeArbiter: mode plane unavailable this session ({type(exc).__name__}: "
1160
+ f"{exc}); continuing as '{_modelib.MODES[0]}' with every gate enforced.",
1161
+ file=sys.stderr)
1032
1162
 
1033
1163
  # Self-heal a stale ca-owned statusLine pin before the dormant gate: the
1034
1164
  # statusline is wired GLOBALLY in ~/.claude/settings.json, so a plugin update
@@ -1077,122 +1207,84 @@ def main():
1077
1207
  or os.environ.get("CODEARBITER_PYTHON_EXECUTABLE")):
1078
1208
  raise
1079
1209
 
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 ---
1210
+ # T-41 (#437, AC-27): persona injection REMOVED from SessionStart. It
1211
+ # moves to the per-turn prompt seam (Lane B, prompt-submit.py) — the
1212
+ # persona is now `safety-core.md` + the active mode's body, composed and
1213
+ # deduped per (session, mode, compaction generation). SessionStart fires
1214
+ # once per session boundary (and on compact), so it cannot react to a
1215
+ # mid-session mode flip; the per-turn seam can. This hook still emits the
1216
+ # startup-state block below, unconditionally of mode (AC-27: "injects no
1217
+ # persona and still emits the startup-state block").
1218
+
1219
+ # --- Startup-state block: per-mode composable emitters (T-44) ---------
1220
+ # AC-30: each emitter below is individually callable with only its own
1221
+ # explicit inputs. AC-32: host/stage/active-mode are unconditional in
1222
+ # every mode; the await-a-command trailer and the daily briefing (which
1223
+ # references {standup}) are ARBITER-ONLY.
1091
1224
  print("=== codeArbiter startup state ===")
1092
1225
  # observability-004 (#268): name the RESOLVED host so a dormant/broken
1093
1226
  # host (FailClosedHost -> name "unknown", #255) is visible right in the
1094
1227
  # banner instead of being indistinguishable from a working install.
1095
- print(f"host: {getattr(host, 'name', 'unknown')}")
1228
+ emit_banner(getattr(host, "name", "unknown"), mode)
1096
1229
 
1097
1230
  ctx_text = read_text(ctx) or ""
1098
1231
  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.")
1232
+ emit_not_initialized(root, host, mode)
1105
1233
  sys.exit(0)
1106
1234
 
1107
- m = STAGE_RE.search(ctx_text)
1108
- print(f"stage: {m.group(1) if m else '—'}")
1235
+ emit_stage(ctx_text)
1109
1236
 
1110
1237
  oq = os.path.join(root, ".codearbiter", "open-questions.md")
1111
1238
  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")
1239
+ emit_confirm_nn(oq_text)
1122
1240
 
1123
1241
  ot = os.path.join(root, ".codearbiter", "open-tasks.md")
1124
1242
  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)
1243
+ emit_task_summary(ot_text)
1140
1244
 
1141
1245
  # --- 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
1246
  _drift = provenance_drift_line(root)
1145
- if _drift:
1146
- print(_drift)
1247
+ emit_provenance_drift(_drift)
1147
1248
 
1148
1249
  # --- 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
1250
  _update = update_notice_line(plugin)
1152
- if _update:
1153
- print(_update)
1251
+ emit_update_notice(_update)
1252
+
1253
+ if mode == _modelib.MODES[0]:
1254
+ emit_trailer(host)
1255
+
1256
+ # --- Standup briefing (SH-1 full / SH-2 offer) ---------------------
1257
+ # Additive, AFTER the startup-state block. Read-only: no git mutation
1258
+ # here. ARBITER-ONLY (AC-32): every variant references {standup}, a
1259
+ # command a non-arbiter mode does not have.
1260
+ # first session of the day (no marker) -> full briefing + drop marker
1261
+ # later session today, actionable -> exactly ONE offer line
1262
+ # later session today, nothing to do -> emit nothing
1263
+ date_iso = local_date_iso()
1264
+ marker_present = not should_emit_briefing(root, date_iso)
1265
+
1266
+ # Read-only git assembly. ahead/behind comes from the LAST COMPLETED
1267
+ # fetch (current local refs); we annotate it as possibly stale and
1268
+ # kick a DETACHED fetch to refresh for NEXT time without blocking
1269
+ # this hook's return.
1270
+ current = head_branch(root)
1271
+ default = os.environ.get("CODEARBITER_BASE_BRANCH") or "main"
1272
+ summary = assemble_summary(root, current=current, default=default)
1273
+ spawn_background_fetch(root) # detached; never awaited
1274
+ spawn_background_update_refresh(plugin) # detached; never awaited (AC-3/AC-4)
1154
1275
 
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
1276
  # performance-003 (#194): ctx_text/ot_text/oq_text were already read
1186
1277
  # 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")))
1278
+ # governance_line's arbiter_state() call doesn't re-read the same
1279
+ # three files a second time in this same invocation.
1280
+ briefing_kind = emit_daily_briefing(
1281
+ root, summary, date_iso, marker_present,
1282
+ ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text, host=host)
1283
+ if briefing_kind == "full":
1284
+ try:
1285
+ write_standup_marker(root, date_iso)
1286
+ except Exception: # noqa: BLE001 — must never brick session startup
1287
+ pass
1196
1288
 
1197
1289
  sys.exit(0)
1198
1290