@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.
- package/README.md +21 -17
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +74 -0
- package/plugins/ca-pi/COMMANDS.md +0 -7
- package/plugins/ca-pi/SKILLS.md +0 -2
- package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
- package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +28 -36
- package/plugins/ca-pi/extensions/codearbiter-child.js +2 -2
- package/plugins/ca-pi/extensions/codearbiter.js +766 -26
- package/plugins/ca-pi/generated/command-catalog.json +0 -10
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
- package/plugins/ca-pi/hooks/_bashguardlib.py +12 -1
- package/plugins/ca-pi/hooks/_hooklib.py +58 -17
- package/plugins/ca-pi/hooks/_metricslib.py +20 -0
- package/plugins/ca-pi/hooks/_modelib.py +644 -0
- package/plugins/ca-pi/hooks/_prunelib.py +51 -12
- package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
- package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
- package/plugins/ca-pi/hooks/doctor.py +2 -1
- package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
- package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
- package/plugins/ca-pi/hooks/session-start.py +526 -434
- package/plugins/ca-pi/hooks/statusline.py +27 -9
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
- package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
- package/plugins/ca-pi/includes/ops-mode.md +96 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +14 -3
- package/plugins/ca-pi/includes/redirect.md +12 -1
- package/plugins/ca-pi/includes/routing-table.md +1 -0
- package/plugins/ca-pi/includes/safety-core.md +86 -0
- package/plugins/ca-pi/includes/smarts/core.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +1 -1
- package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
- package/plugins/ca-pi/includes/dev-mode.md +0 -30
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
- 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,
|
|
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
|
-
#
|
|
552
|
-
#
|
|
553
|
-
#
|
|
554
|
-
|
|
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
|
|
610
|
-
return os.path.join(root, ".codearbiter", ".markers", "
|
|
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
|
|
614
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
646
|
+
if str(session_id) in _read_mode_session_seen_map(root):
|
|
647
|
+
return
|
|
789
648
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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:
|
|
854
|
-
#
|
|
855
|
-
#
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
#
|
|
885
|
-
#
|
|
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
|
-
#
|
|
889
|
-
#
|
|
890
|
-
#
|
|
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
|
|
895
|
-
#
|
|
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
|
|
915
|
-
# #396: stage-then-append-then-clear, all inside one settlement step.
|
|
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
|
-
|
|
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
|
-
# /
|
|
1026
|
-
#
|
|
1027
|
-
#
|
|
1028
|
-
#
|
|
1029
|
-
#
|
|
1030
|
-
#
|
|
1031
|
-
|
|
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
|
-
#
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
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
|
|
1188
|
-
# files a second time in this same invocation.
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
|