@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
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — mode plane: the three-value runtime posture (arbiter/dangerous/
|
|
3
|
+
# ops), its deterministic token flip, and the write-ahead audit-close ledger
|
|
4
|
+
# that backs it (#437, mode-plane-deterministic-flip).
|
|
5
|
+
#
|
|
6
|
+
# T-06 (pure refactor, no behavior change): the write-ahead ledger machinery —
|
|
7
|
+
# `_settle_dev_close` and its pending-close record — moved here verbatim from
|
|
8
|
+
# `core/pysrc/session-start.py` (formerly ~lines 551-810). `session-start.py`
|
|
9
|
+
# now imports `_settle_dev_close` (and the `_DEV_PENDING_CLOSE_MAX` constant,
|
|
10
|
+
# which a pre-existing test reads off the session-start module) from here;
|
|
11
|
+
# `clear_dev_marker` itself stays in session-start.py — this module owns the
|
|
12
|
+
# ledger MECHANISM, not the SessionStart-specific policy of when to invoke it.
|
|
13
|
+
#
|
|
14
|
+
# The proof this introduced no behavior change: the pre-existing
|
|
15
|
+
# `TestDevExitRetryablePendingClose` cases in
|
|
16
|
+
# `plugins/ca/hooks/tests/test_session_start.py` pass UNMODIFIED against the
|
|
17
|
+
# regenerated (sync-core.py) vendored copy, which now imports from this file.
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from _activationlib import marker_root
|
|
27
|
+
from _hooklib import write_text_atomic
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# T-16 — PERSONA_SENTINEL: a single stable literal embedded in every composed
|
|
32
|
+
# persona injection (T-31, Lane B), so a later transcript-pruning pass
|
|
33
|
+
# (`_prunelib`/`_prunepolicy`, T-49/T-50, R-5) can recognize an injected-
|
|
34
|
+
# persona line and mark it `pinned=True` — protected from folding,
|
|
35
|
+
# condensing, and eviction at EVERY tier, including aggressive (AC-26).
|
|
36
|
+
#
|
|
37
|
+
# Deliberately shaped as an HTML comment (renders invisibly in the persona
|
|
38
|
+
# markdown) and deliberately distinct from `_prunepolicy.MARKER_PREFIX`
|
|
39
|
+
# ("[ca-condensed ") — the two must never collide: one marks "this content
|
|
40
|
+
# was ELIDED by a prior prune pass", the other marks "this content must
|
|
41
|
+
# NEVER be elided". Exported here, not in `_prunelib`/`_prunepolicy`,
|
|
42
|
+
# because the INJECTOR (this module's consumers) is the single source that
|
|
43
|
+
# must emit it — a value redefined in two places is a value that can drift.
|
|
44
|
+
PERSONA_SENTINEL = "<!-- codearbiter:persona-sentinel -->"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# T-07 — the mode plane: three-value posture, resolved through marker_root
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# `MODES` is the ONLY legal-value tuple; `dev` is retired (superseded by
|
|
51
|
+
# `dangerous`, R-3/ADR-0022 supersession). Index 0 is deliberately the safe
|
|
52
|
+
# default every anomaly falls back to.
|
|
53
|
+
MODES = ("arbiter", "dangerous", "ops")
|
|
54
|
+
|
|
55
|
+
# [[never-fold-unreadable-into-absent]] — the house rule this constant set
|
|
56
|
+
# exists to satisfy: a marker file that genuinely does not exist and one that
|
|
57
|
+
# exists but could not be read/parsed are DIFFERENT failure classes and must
|
|
58
|
+
# never collapse onto one diagnostic string. Every non-None diagnostic below
|
|
59
|
+
# still resolves the mode to MODES[0] ("arbiter") — these strings distinguish
|
|
60
|
+
# WHY, not WHAT the fallback is.
|
|
61
|
+
MODE_DIAG_ABSENT = "mode-marker-absent"
|
|
62
|
+
MODE_DIAG_UNREADABLE = "mode-marker-unreadable"
|
|
63
|
+
MODE_DIAG_UNRECOGNIZED = "mode-marker-unrecognized"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mode_marker_path(root=None, payload=None):
|
|
67
|
+
"""Absolute path of the mode marker: `<root>/.codearbiter/.markers/mode`.
|
|
68
|
+
|
|
69
|
+
`root` defaults to `_activationlib.marker_root(payload)` — deliberately
|
|
70
|
+
NOT `project_root(payload)`: `marker_root` exists precisely because
|
|
71
|
+
`project_root` splits marker state across linked worktrees (#604), and
|
|
72
|
+
every other `.codearbiter/.markers/` writer (security-pass.py,
|
|
73
|
+
migration-pass.py, the H-09b/H-10b/H-14 guards) already resolves through
|
|
74
|
+
it. An explicit `root` is accepted as a test-only escape hatch for
|
|
75
|
+
fixture isolation — production callers pass neither and let this resolve
|
|
76
|
+
via the host seam."""
|
|
77
|
+
if root is None:
|
|
78
|
+
root = marker_root(payload)
|
|
79
|
+
return os.path.join(root, ".codearbiter", ".markers", "mode")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_mode_state(root=None, payload=None):
|
|
83
|
+
"""(state, diagnostic) off the mode marker file.
|
|
84
|
+
|
|
85
|
+
`state` is the RAW dict mapping session_id -> whatever value was on disk
|
|
86
|
+
for it (validation of an individual session's value is `current_mode`'s
|
|
87
|
+
job, not this function's — a per-session bad value must still be visible
|
|
88
|
+
to the caller so it can be reported, not silently dropped). Always a
|
|
89
|
+
dict, never None, so callers never need a None-check. A file that is
|
|
90
|
+
itself absent, unreadable, empty, or not a JSON object returns `{}` plus
|
|
91
|
+
a diagnostic.
|
|
92
|
+
|
|
93
|
+
`diagnostic` is None on a clean read (the file parses as a JSON object —
|
|
94
|
+
individual bad entries inside it do not taint this diagnostic). Otherwise
|
|
95
|
+
exactly one of MODE_DIAG_ABSENT / MODE_DIAG_UNREADABLE /
|
|
96
|
+
MODE_DIAG_UNRECOGNIZED — `os.path.exists` (not `os.path.isfile`) gates the
|
|
97
|
+
absent check, so a path that exists but cannot be opened as a normal file
|
|
98
|
+
(a directory sitting at that path, or a real permissions error) falls
|
|
99
|
+
through to the `open()` call and is correctly reported UNREADABLE rather
|
|
100
|
+
than ABSENT. This is the portable, no-chmod-needed shape of the
|
|
101
|
+
distinction: a directory path always fails `open()` (IsADirectoryError on
|
|
102
|
+
POSIX, PermissionError on Windows — both are OSError) without depending
|
|
103
|
+
on host-specific permission semantics."""
|
|
104
|
+
path = mode_marker_path(root, payload)
|
|
105
|
+
if not os.path.exists(path):
|
|
106
|
+
return {}, MODE_DIAG_ABSENT
|
|
107
|
+
try:
|
|
108
|
+
with open(path, encoding="utf-8") as f:
|
|
109
|
+
text = f.read()
|
|
110
|
+
except Exception: # noqa: BLE001 — exists but could not be read
|
|
111
|
+
return {}, MODE_DIAG_UNREADABLE
|
|
112
|
+
text = text.strip()
|
|
113
|
+
if not text:
|
|
114
|
+
return {}, MODE_DIAG_UNRECOGNIZED
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(text)
|
|
117
|
+
except Exception: # noqa: BLE001 — not valid JSON
|
|
118
|
+
return {}, MODE_DIAG_UNRECOGNIZED
|
|
119
|
+
if not isinstance(data, dict):
|
|
120
|
+
return {}, MODE_DIAG_UNRECOGNIZED
|
|
121
|
+
return data, None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def current_mode(session_id, root=None, payload=None):
|
|
125
|
+
"""(mode, diagnostic) for `session_id`.
|
|
126
|
+
|
|
127
|
+
Resolves MODES[0] ("arbiter") whenever the marker file is absent, empty,
|
|
128
|
+
unreadable, or unrecognized (AC-2) — WITH a diagnostic distinguishing
|
|
129
|
+
which — and also when the file is clean but simply has no entry yet for
|
|
130
|
+
this session (a fresh session legitimately starts arbiter; that is not an
|
|
131
|
+
anomaly, so diagnostic is None). When the file is clean but THIS
|
|
132
|
+
session's own recorded value is not a legal mode, that is reported the
|
|
133
|
+
same as a file-level unrecognized value (MODE_DIAG_UNRECOGNIZED) — a
|
|
134
|
+
garbage per-session entry is exactly as much an anomaly as a garbage
|
|
135
|
+
file, and must not be swallowed silently."""
|
|
136
|
+
state, diag = _read_mode_state(root, payload)
|
|
137
|
+
if diag is not None:
|
|
138
|
+
return MODES[0], diag
|
|
139
|
+
if session_id not in state:
|
|
140
|
+
return MODES[0], None
|
|
141
|
+
value = state.get(session_id)
|
|
142
|
+
if value not in MODES:
|
|
143
|
+
return MODES[0], MODE_DIAG_UNRECOGNIZED
|
|
144
|
+
return value, None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# How many times `write_mode` will re-read-modify-write before giving up. Small
|
|
148
|
+
# on purpose: this runs on the prompt seam, and the contention it exists for is
|
|
149
|
+
# two sessions writing DIFFERENT keys of one small map — a case that converges
|
|
150
|
+
# immediately, not one that needs backoff. A genuinely unwritable path fails on
|
|
151
|
+
# the first attempt and the rest cost nothing.
|
|
152
|
+
_WRITE_MODE_ATTEMPTS = 3
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_mode(session_id, mode, root=None, payload=None):
|
|
156
|
+
"""Persist `mode` for `session_id` (T-08, AC-1).
|
|
157
|
+
|
|
158
|
+
Read-modify-write over the marker's `{session_id: mode}` JSON object.
|
|
159
|
+
The write itself is delegated ENTIRELY to `write_text_atomic` — this
|
|
160
|
+
function does no `open()`/`write()` of its own — so an interrupted write
|
|
161
|
+
can only ever land in write_text_atomic's own guarantee: a sibling temp
|
|
162
|
+
file, then `os.replace()`; on any failure the temp is removed and `path`
|
|
163
|
+
is left exactly as it was (untouched if it existed, absent if it did
|
|
164
|
+
not). Returns True on a confirmed write, False on failure. Never
|
|
165
|
+
raises — the caller (`flip`, T-11/T-14) decides what failure means.
|
|
166
|
+
|
|
167
|
+
VERIFIED, not merely attempted. `write_text_atomic` makes each individual
|
|
168
|
+
replace atomic but does not serialize the read-modify-write PAIR, so two
|
|
169
|
+
sessions sharing one `.codearbiter/` store interleave: A reads
|
|
170
|
+
`{A: dangerous}`, B reads the same map and writes `{A: dangerous, B: …}`,
|
|
171
|
+
then A's write of `{A: arbiter}` is overwritten by B's — or lands and
|
|
172
|
+
loses B. A silently keeps a `dangerous` entry it explicitly left, and
|
|
173
|
+
`ledger_backs` does not compensate because A's own earlier `enter` row
|
|
174
|
+
still authorizes it. This repo runs worktree agents against one store, so
|
|
175
|
+
the interleaving is reachable rather than theoretical.
|
|
176
|
+
|
|
177
|
+
So the write is confirmed by re-reading it, and a lost update is retried.
|
|
178
|
+
ADR-0030 position 5 requires the return path out of `dangerous` to be "a
|
|
179
|
+
verified write" that "must surface its failure" — an unverified write that
|
|
180
|
+
is then overwritten surfaces nothing, which is the one direction the ADR
|
|
181
|
+
names as unsafe. A write that cannot be confirmed after the retries returns
|
|
182
|
+
False rather than reporting a success it cannot demonstrate."""
|
|
183
|
+
path = mode_marker_path(root, payload)
|
|
184
|
+
last_error = None
|
|
185
|
+
for _attempt in range(_WRITE_MODE_ATTEMPTS):
|
|
186
|
+
state, _diag = _read_mode_state(root, payload)
|
|
187
|
+
state = dict(state)
|
|
188
|
+
state[session_id] = mode
|
|
189
|
+
try:
|
|
190
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
191
|
+
write_text_atomic(path, json.dumps(state), newline="\n")
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
last_error = exc
|
|
194
|
+
continue
|
|
195
|
+
# Re-read rather than trusting the write: a concurrent writer's own
|
|
196
|
+
# replace may have landed after ours, which is invisible from here.
|
|
197
|
+
observed, _diag = _read_mode_state(root, payload)
|
|
198
|
+
if observed.get(session_id) == mode:
|
|
199
|
+
return True
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# T-11 — flip(): the deterministic token-flip primitive every host caller
|
|
205
|
+
# (prompt-submit.py, pi-bridge.py) drives through. Three distinct sentinels,
|
|
206
|
+
# never a bare bool — a caller has to tell "already there" from "the write
|
|
207
|
+
# failed" apart to report either correctly to the user.
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
FLIP_FLIPPED = "flipped"
|
|
210
|
+
FLIP_NOOP = "noop"
|
|
211
|
+
FLIP_FAILED = "failed"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _mode_audit_line(verb, mode, host_name=None, now=None, session_id=None):
|
|
215
|
+
"""One `MODE: <name> enter|exit` audit row (Decided parameters: Audit
|
|
216
|
+
verb). `now` (epoch seconds) is injectable for deterministic tests;
|
|
217
|
+
defaults to the real current time.
|
|
218
|
+
|
|
219
|
+
Carries `SESSION:` because the row is an AUTHORIZATION, not just a record:
|
|
220
|
+
`ledger_backs` reads it to decide whether a gates-off marker is allowed to
|
|
221
|
+
take effect. Without the field that check is repo-wide, so one session's
|
|
222
|
+
`enter` row authorizes ANOTHER session's marker — the mode plane is keyed
|
|
223
|
+
per session everywhere else, and an unkeyed authorization defeats that
|
|
224
|
+
isolation (AC-3)."""
|
|
225
|
+
ts = (datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc)
|
|
226
|
+
if now is not None
|
|
227
|
+
else datetime.datetime.now(datetime.timezone.utc))
|
|
228
|
+
ts_str = ts.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
229
|
+
return (f"[{ts_str}] | BY: session-mode | HOST: {host_name or 'unknown'} "
|
|
230
|
+
f"| SESSION: {session_id or 'unknown'} "
|
|
231
|
+
f"| MODE: {mode} {verb} | NOTE: —\n")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def flip(session_id, mode, root=None, payload=None, host_name=None, now=None):
|
|
235
|
+
"""Attempt to set `session_id`'s mode to `mode`. Returns one of
|
|
236
|
+
FLIP_FLIPPED / FLIP_NOOP / FLIP_FAILED. Never raises.
|
|
237
|
+
|
|
238
|
+
AC-6: a flip TO THE ALREADY-ACTIVE mode is a no-op — no write is even
|
|
239
|
+
attempted and no audit row is appended, so `overrides.log` is left
|
|
240
|
+
byte-identical. This is also the mechanism behind T-14's fail-direction
|
|
241
|
+
asymmetry: once a failed flip has left the session's resolved mode
|
|
242
|
+
unchanged, any LATER flip back to that same resolved mode is a no-op
|
|
243
|
+
under ANY filesystem state — including a markers directory that cannot
|
|
244
|
+
be written to at all — because a no-op never touches disk.
|
|
245
|
+
|
|
246
|
+
On a genuine transition the order is deliberate: write first, audit row
|
|
247
|
+
ONLY on a confirmed write. AC-11's `ledger_backs` exists to catch exactly
|
|
248
|
+
the opposite ordering — a ledger row minted for a flip that never
|
|
249
|
+
actually landed, which would let an unbacked marker masquerade as an
|
|
250
|
+
audited one."""
|
|
251
|
+
root = root if root is not None else marker_root(payload)
|
|
252
|
+
current, _diag = current_mode(session_id, root=root, payload=payload)
|
|
253
|
+
if current == mode:
|
|
254
|
+
return FLIP_NOOP
|
|
255
|
+
if not write_mode(session_id, mode, root=root, payload=payload):
|
|
256
|
+
return FLIP_FAILED
|
|
257
|
+
# ADR-0030 position 4: EVERY transition row is staged through the #396
|
|
258
|
+
# write-ahead ledger, never a bare append. The exit half already complied;
|
|
259
|
+
# this one did not, so an unwritable overrides.log dropped the `MODE: …
|
|
260
|
+
# enter` row with no replay while `flip` still reported success — an
|
|
261
|
+
# unaudited entry into a gates-off posture, which is the one transition
|
|
262
|
+
# that must never be silent.
|
|
263
|
+
#
|
|
264
|
+
# Reporting FLIP_FAILED when the row is not confirmed is consistent rather
|
|
265
|
+
# than pessimistic: `ledger_backs` (AC-11) already refuses to compose a
|
|
266
|
+
# body whose mode has no matching `enter` row, so an unaudited marker is
|
|
267
|
+
# not in effect anyway. Saying so out loud beats leaving the user believing
|
|
268
|
+
# a flip took that the injector will ignore.
|
|
269
|
+
#
|
|
270
|
+
# Confirmed by looking for the row itself, not by the settle count: a
|
|
271
|
+
# settle can append an OLDER owed line and stall on this one, which would
|
|
272
|
+
# read as success from the count alone.
|
|
273
|
+
line = _mode_audit_line("enter", mode, host_name=host_name, now=now,
|
|
274
|
+
session_id=session_id)
|
|
275
|
+
_settle_dev_close(root, new_line=line, host_name=host_name)
|
|
276
|
+
if not _overrides_has_line(root, line):
|
|
277
|
+
return FLIP_FAILED
|
|
278
|
+
return FLIP_FLIPPED
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# T-12 — the token table: `mode --arbiter|--dangerous|--ops`, matched
|
|
283
|
+
# WHOLE-PROMPT, never substring (Decided parameters: Token). Pure text logic,
|
|
284
|
+
# no I/O — every host's prompt-seam interceptor (Claude/Codex/Pi) imports
|
|
285
|
+
# this so the matching rule can never drift between hosts.
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
MODE_TOKEN_REPORT = "report" # bare `mode`: report current + legal values, write nothing
|
|
288
|
+
|
|
289
|
+
_MODE_TOKEN_RE = re.compile(r"mode(?:\s+--(arbiter|dangerous|ops))?", re.I)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def match_mode_token(prompt):
|
|
293
|
+
"""Classify `prompt` against the mode control-token table.
|
|
294
|
+
|
|
295
|
+
Returns one of MODES (a flip request), MODE_TOKEN_REPORT (bare `mode`),
|
|
296
|
+
or None (not a control token at all — the prompt reaches the model
|
|
297
|
+
unmodified).
|
|
298
|
+
|
|
299
|
+
Whole-prompt only: `re.fullmatch` against the prompt after stripping
|
|
300
|
+
SURROUNDING whitespace (never internal) means a token embedded anywhere
|
|
301
|
+
in a longer prompt — before, after, or on another line — cannot match,
|
|
302
|
+
because fullmatch requires the ENTIRE stripped string to be consumed by
|
|
303
|
+
the pattern and the pattern contains no `\\n`. Case-insensitive (`re.I`);
|
|
304
|
+
surrounding whitespace of any kind (spaces, tabs, newlines) is
|
|
305
|
+
insensitive because it is stripped before matching."""
|
|
306
|
+
if not isinstance(prompt, str):
|
|
307
|
+
return None
|
|
308
|
+
stripped = prompt.strip()
|
|
309
|
+
if not stripped:
|
|
310
|
+
return None
|
|
311
|
+
m = _MODE_TOKEN_RE.fullmatch(stripped)
|
|
312
|
+
if not m:
|
|
313
|
+
return None
|
|
314
|
+
name = m.group(1)
|
|
315
|
+
if name is None:
|
|
316
|
+
return MODE_TOKEN_REPORT
|
|
317
|
+
return name.lower()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# T-13 — ledger_backs(): the AC-11 compensating control. The deterministic
|
|
322
|
+
# flip removes ADR-0022's tier-2 confirmation for dangerous-mode entry (its
|
|
323
|
+
# supersession, per the spec's ADR conflict note); this is the load-bearing
|
|
324
|
+
# replacement — the injector refuses to compose a non-arbiter body the audit
|
|
325
|
+
# trail does not back.
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
_LEGACY_DEV_ENTER_RE = re.compile(r"\|\s*DEV:\s*enter\s*(?:\||$)", re.M)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def ledger_backs(root, mode, session_id=None):
|
|
331
|
+
"""True iff the audit trail (at `root`) holds a matching
|
|
332
|
+
`MODE: <mode> enter` row FOR `session_id`.
|
|
333
|
+
|
|
334
|
+
Session-scoped, because this row is an authorization rather than a
|
|
335
|
+
record: it decides whether a gates-off marker takes effect. A repo-wide
|
|
336
|
+
match let one session's `enter` row authorize a DIFFERENT session's marker
|
|
337
|
+
— every other part of the mode plane is keyed per session, and an unkeyed
|
|
338
|
+
authorization defeats that isolation (AC-3). Pass `session_id` at every
|
|
339
|
+
production call site; omitting it keeps the older repo-wide question,
|
|
340
|
+
which is only ever the right one for a caller that has no session.
|
|
341
|
+
|
|
342
|
+
A row written before this field existed carries no session and therefore
|
|
343
|
+
backs NO session-scoped query. That fails toward `arbiter` — gates ON, one
|
|
344
|
+
re-flip — which is the direction ADR-0030 requires; the alternative would
|
|
345
|
+
reopen the hole for exactly the history that cannot be checked.
|
|
346
|
+
|
|
347
|
+
A legacy `DEV: enter` row backs `mode == "dangerous"` ONLY — dev was
|
|
348
|
+
retired INTO dangerous (T-47 converts a live `dev-active` marker to
|
|
349
|
+
`dangerous` exactly once), so a pre-mode-plane audit trail's DEV: enter
|
|
350
|
+
rows must continue to authorize it. A legacy row must NEVER back `ops`:
|
|
351
|
+
`ops` did not exist when any DEV: row could have been written, so
|
|
352
|
+
accepting one there would be a fail-OPEN into a mode the operator never
|
|
353
|
+
actually requested — the exact failure this function exists to prevent,
|
|
354
|
+
just relocated to a different mode.
|
|
355
|
+
|
|
356
|
+
Read-only and tolerant: an absent or unreadable log answers False,
|
|
357
|
+
never raises — consistent with this module's fail-toward-arbiter
|
|
358
|
+
convention (a missing ledger can never AUTHORIZE anything)."""
|
|
359
|
+
try:
|
|
360
|
+
with open(_overrides_log_path(root), encoding="utf-8", errors="replace") as f:
|
|
361
|
+
text = f.read()
|
|
362
|
+
except Exception: # noqa: BLE001 — absent/unreadable log -> nothing backs it
|
|
363
|
+
return False
|
|
364
|
+
enter_re = re.compile(r"\|\s*MODE:\s*" + re.escape(mode) + r"\s+enter\s*(?:\||$)", re.M)
|
|
365
|
+
if session_id is None:
|
|
366
|
+
if enter_re.search(text):
|
|
367
|
+
return True
|
|
368
|
+
else:
|
|
369
|
+
session_re = re.compile(r"\|\s*SESSION:\s*" + re.escape(str(session_id)) + r"\s*\|")
|
|
370
|
+
for line in text.splitlines():
|
|
371
|
+
if session_re.search(line) and enter_re.search(line):
|
|
372
|
+
return True
|
|
373
|
+
# The legacy exception, deliberately session-blind: `dev` was retired INTO
|
|
374
|
+
# `dangerous` (T-47 converts a live `dev-active` marker exactly once), and a
|
|
375
|
+
# pre-mode-plane `DEV: enter` row predates session attribution entirely, so
|
|
376
|
+
# requiring one would break the migration it exists to serve. Bounded to
|
|
377
|
+
# `dangerous` on a repo that already has DEV history — `ops` never gets it,
|
|
378
|
+
# since accepting a legacy row there would authorize a mode the operator
|
|
379
|
+
# could not have requested when that row was written.
|
|
380
|
+
if mode == "dangerous" and _LEGACY_DEV_ENTER_RE.search(text):
|
|
381
|
+
return True
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# --- #396: a durable, retryable DEV: exit -----------------------------------
|
|
386
|
+
# The synthetic close line is the ONLY thing that keeps the append-only audit
|
|
387
|
+
# trail's DEV: enter/exit pairs matched after an abandoned maintainer session.
|
|
388
|
+
# It used to be written best-effort ("except OSError: pass") and the marker was
|
|
389
|
+
# then removed regardless — so a locked file, a full disk, or a permission blip
|
|
390
|
+
# permanently erased the obligation and left an orphaned DEV: enter that no
|
|
391
|
+
# later session could know about.
|
|
392
|
+
#
|
|
393
|
+
# The fix is a small write-ahead record: the owed line is staged on disk BEFORE
|
|
394
|
+
# the append is attempted, and the record is deleted only once BOTH the append
|
|
395
|
+
# is confirmed AND the marker it settles is gone. That single record therefore
|
|
396
|
+
# carries three facts at once:
|
|
397
|
+
#
|
|
398
|
+
# "lines" — close lines still owed to overrides.log. Emptied one at a
|
|
399
|
+
# time as each append is confirmed.
|
|
400
|
+
# "marker_mtime" — the identity of the dev-active marker this close belongs
|
|
401
|
+
# to. While the record still names a LIVE marker, the
|
|
402
|
+
# force-close path knows that marker has already been
|
|
403
|
+
# closed in the audit trail and refuses to mint a second
|
|
404
|
+
# row for it — which is what makes a failed `os.remove`
|
|
405
|
+
# idempotent rather than duplicating the close. It is
|
|
406
|
+
# cleared the moment that marker is gone: an mtime only
|
|
407
|
+
# identifies a file that still EXISTS, and a stale one is
|
|
408
|
+
# free to collide with an unrelated future marker (2s
|
|
409
|
+
# granularity on FAT32/exFAT/SMB/WSL mounts makes that a
|
|
410
|
+
# real event, not a theoretical one) and suppress a close
|
|
411
|
+
# that is genuinely owed.
|
|
412
|
+
# "dropped" — how many owed close lines the bound below has discarded.
|
|
413
|
+
# The cap keeps the record small, but the loss must not be
|
|
414
|
+
# silent: the count is written to the trail as one
|
|
415
|
+
# attributable note the moment overrides.log accepts writes.
|
|
416
|
+
#
|
|
417
|
+
# Replayed lines carry the timestamp they were MINTED with, not the time they
|
|
418
|
+
# land, so a delayed replay leaves overrides.log non-chronological. Enter/exit
|
|
419
|
+
# pairing is by timestamp, so that is correct — but an audit reader must not
|
|
420
|
+
# assume file order is time order.
|
|
421
|
+
#
|
|
422
|
+
# Every boundary is covered:
|
|
423
|
+
# crash before the append -> record present, line owed -> replayed
|
|
424
|
+
# crash after the append -> record present, line owed -> the bounded
|
|
425
|
+
# tail scan sees the line already landed and
|
|
426
|
+
# drops it instead of appending a duplicate
|
|
427
|
+
# marker removal fails -> record present, no line owed -> the next
|
|
428
|
+
# session only retries the removal
|
|
429
|
+
#
|
|
430
|
+
# That tail scan is applied ONLY to lines read back off the record — the ones
|
|
431
|
+
# that might have landed before a crash. A line minted in THIS process cannot
|
|
432
|
+
# already be on the trail, and must never be dedupe-checked: close rows are
|
|
433
|
+
# timestamped to the second, so two distinct closes minted in the same second
|
|
434
|
+
# are byte-identical, and checking the fresh one against an owed copy of itself
|
|
435
|
+
# would silently swallow a close that is genuinely owed.
|
|
436
|
+
#
|
|
437
|
+
# Everything here is best-effort by the module's standing convention: session
|
|
438
|
+
# startup must never be bricked by audit bookkeeping, so nothing raises.
|
|
439
|
+
_DEV_PENDING_CLOSE_MAX = 8 # bounded: never accumulate owed lines forever
|
|
440
|
+
_DEV_PENDING_SCAN_BYTES = 64 * 1024 # bounded tail scan for the dedupe check
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _dev_pending_close_path(root):
|
|
444
|
+
return os.path.join(root, ".codearbiter", ".markers", "dev-close-pending.json")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _overrides_log_path(root):
|
|
448
|
+
return os.path.join(root, ".codearbiter", "overrides.log")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _read_dev_pending_close(root):
|
|
452
|
+
"""The pending-close record as
|
|
453
|
+
{"lines": [...], "marker_mtime": float|None, "dropped": int}, or None when
|
|
454
|
+
there is nothing usable on disk. A record that exists but carries no
|
|
455
|
+
replayable line, no marker identity and no unreported drop is reported as
|
|
456
|
+
None so the caller discards it — a corrupt record must never wedge the
|
|
457
|
+
mechanism shut. Never raises."""
|
|
458
|
+
try:
|
|
459
|
+
with open(_dev_pending_close_path(root), encoding="utf-8") as f:
|
|
460
|
+
data = json.load(f)
|
|
461
|
+
if not isinstance(data, dict):
|
|
462
|
+
return None
|
|
463
|
+
lines = [ln for ln in (data.get("lines") or [])
|
|
464
|
+
if isinstance(ln, str) and ln.strip()][:_DEV_PENDING_CLOSE_MAX]
|
|
465
|
+
mtime = data.get("marker_mtime")
|
|
466
|
+
mtime = float(mtime) if isinstance(mtime, (int, float)) else None
|
|
467
|
+
dropped = data.get("dropped")
|
|
468
|
+
# `isinstance(True, int)` is True, so booleans are excluded explicitly.
|
|
469
|
+
dropped = (int(dropped) if isinstance(dropped, int)
|
|
470
|
+
and not isinstance(dropped, bool) and dropped > 0 else 0)
|
|
471
|
+
if not lines and mtime is None and not dropped:
|
|
472
|
+
return None
|
|
473
|
+
return {"lines": lines, "marker_mtime": mtime, "dropped": dropped}
|
|
474
|
+
except Exception: # noqa: BLE001 — absent/corrupt record -> no signal
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _write_dev_pending_close(root, rec):
|
|
479
|
+
"""Atomically persist the pending-close record. Never raises — a write
|
|
480
|
+
failure only costs the retry signal this call was trying to create, which
|
|
481
|
+
is exactly the pre-#396 behavior and still must not brick startup."""
|
|
482
|
+
try:
|
|
483
|
+
path = _dev_pending_close_path(root)
|
|
484
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
485
|
+
write_text_atomic(path, json.dumps(rec), newline="\n")
|
|
486
|
+
except Exception: # noqa: BLE001 — must never brick session startup
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _discard_dev_pending_close(root):
|
|
491
|
+
try:
|
|
492
|
+
os.remove(_dev_pending_close_path(root))
|
|
493
|
+
except OSError:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _overrides_has_line(root, line):
|
|
498
|
+
"""True iff `line` already appears in the tail of overrides.log. Bounded to
|
|
499
|
+
the last _DEV_PENDING_SCAN_BYTES — a replay always happens on the very next
|
|
500
|
+
SessionStart, so the line it is looking for is at (or near) the end. An
|
|
501
|
+
unreadable log answers False: re-appending a close row is a far smaller
|
|
502
|
+
harm than silently dropping one.
|
|
503
|
+
|
|
504
|
+
Read in BINARY and decoded here on purpose: a byte offset is only
|
|
505
|
+
meaningful to seek() on a binary stream, and the comparison is made on the
|
|
506
|
+
stripped line so the platform EOL the append produced never matters."""
|
|
507
|
+
needle = line.strip()
|
|
508
|
+
if not needle:
|
|
509
|
+
return False
|
|
510
|
+
try:
|
|
511
|
+
path = _overrides_log_path(root)
|
|
512
|
+
size = os.path.getsize(path)
|
|
513
|
+
with open(path, "rb") as f:
|
|
514
|
+
if size > _DEV_PENDING_SCAN_BYTES:
|
|
515
|
+
f.seek(size - _DEV_PENDING_SCAN_BYTES)
|
|
516
|
+
tail = f.read().decode("utf-8", "replace")
|
|
517
|
+
return needle in tail
|
|
518
|
+
except Exception: # noqa: BLE001 — cannot confirm -> assume not present
|
|
519
|
+
return False
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _append_override_line(root, line):
|
|
523
|
+
"""Append one audit line to overrides.log. True on a confirmed write."""
|
|
524
|
+
try:
|
|
525
|
+
with open(_overrides_log_path(root), "a", encoding="utf-8") as f:
|
|
526
|
+
f.write(line)
|
|
527
|
+
return True
|
|
528
|
+
except OSError:
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _dev_dropped_close_note(count, host_name=None):
|
|
533
|
+
"""One audit line accounting for close rows the pending-close cap had to
|
|
534
|
+
discard. Deliberately NOT a `DEV: exit` row — it closes nothing; it records
|
|
535
|
+
that N closes can never be written, so a reader of the append-only trail
|
|
536
|
+
can attribute the unmatched entries instead of finding an unexplained gap.
|
|
537
|
+
"""
|
|
538
|
+
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
539
|
+
return (f"[{ts}] | BY: session-cleanup | HOST: {host_name or 'unknown'} "
|
|
540
|
+
f"| DEV: close-dropped | NOTE: {count} owed close row(s) discarded - the "
|
|
541
|
+
f"pending-close cap ({_DEV_PENDING_CLOSE_MAX}) was reached while "
|
|
542
|
+
f"overrides.log was unwritable; that many maintainer sessions have "
|
|
543
|
+
f"no matching close row\n")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _settle_dev_close(root, marker=None, new_line=None, host_name=None):
|
|
547
|
+
"""Drive the pending-close record to settlement; the single place the owed
|
|
548
|
+
DEV: exit is appended and the retry state is cleared.
|
|
549
|
+
|
|
550
|
+
`marker` is the dev-active path when one is live (its mtime becomes the
|
|
551
|
+
close identity), None when there is no marker to settle. `new_line` is a
|
|
552
|
+
freshly minted close line to take on, or None when this is a pure replay of
|
|
553
|
+
whatever is already owed. `host_name` only attributes the cap-overflow note
|
|
554
|
+
below; the close lines themselves already carry their own HOST field.
|
|
555
|
+
Returns the number of close lines appended by THIS call. Never raises."""
|
|
556
|
+
if (marker is None and new_line is None
|
|
557
|
+
and not os.path.isfile(_dev_pending_close_path(root))):
|
|
558
|
+
return 0 # nothing owed, nothing to settle — the overwhelming case
|
|
559
|
+
rec = _read_dev_pending_close(root)
|
|
560
|
+
owed = list(rec["lines"]) if rec else []
|
|
561
|
+
prev_mtime = rec["marker_mtime"] if rec else None
|
|
562
|
+
dropped = rec["dropped"] if rec else 0
|
|
563
|
+
|
|
564
|
+
marker_mtime = None
|
|
565
|
+
if marker:
|
|
566
|
+
try:
|
|
567
|
+
marker_mtime = os.path.getmtime(marker)
|
|
568
|
+
except OSError:
|
|
569
|
+
marker_mtime = None
|
|
570
|
+
|
|
571
|
+
# Everything already in `owed` came off disk, so it MAY have reached the
|
|
572
|
+
# trail before a crash and has to be dedupe-checked. Anything appended
|
|
573
|
+
# below is minted in this process and cannot possibly be there yet.
|
|
574
|
+
replays = len(owed)
|
|
575
|
+
|
|
576
|
+
if new_line is not None:
|
|
577
|
+
# Already closed THIS marker (the append landed, only the removal
|
|
578
|
+
# failed) -> do not mint a second row for it; just retry the cleanup.
|
|
579
|
+
already_closed = (rec is not None and prev_mtime is not None
|
|
580
|
+
and marker_mtime is not None
|
|
581
|
+
and prev_mtime == marker_mtime)
|
|
582
|
+
if not already_closed:
|
|
583
|
+
owed.append(new_line)
|
|
584
|
+
if len(owed) > _DEV_PENDING_CLOSE_MAX:
|
|
585
|
+
# Bounded, but never SILENT. A permanently-unwritable overrides.log
|
|
586
|
+
# would otherwise accumulate owed lines forever, so the oldest are
|
|
587
|
+
# discarded — and counted, so the loss is itself auditable rather than
|
|
588
|
+
# reintroducing exactly the unmatched `DEV: enter` this record exists
|
|
589
|
+
# to prevent.
|
|
590
|
+
overflow = len(owed) - _DEV_PENDING_CLOSE_MAX
|
|
591
|
+
dropped += overflow
|
|
592
|
+
owed = owed[-_DEV_PENDING_CLOSE_MAX:]
|
|
593
|
+
replays = max(0, replays - overflow) # the discards come off the front
|
|
594
|
+
|
|
595
|
+
if owed or dropped or marker_mtime is not None:
|
|
596
|
+
# Write-ahead: the obligation is durable BEFORE the append is tried.
|
|
597
|
+
_write_dev_pending_close(root, {"lines": owed,
|
|
598
|
+
"marker_mtime": marker_mtime,
|
|
599
|
+
"dropped": dropped})
|
|
600
|
+
|
|
601
|
+
# The overflow note goes in FIRST — the rows it accounts for are older than
|
|
602
|
+
# everything still owed. It is minted fresh each attempt, so it is not
|
|
603
|
+
# deduped by the tail scan; a crash between this append and the write-back
|
|
604
|
+
# below can repeat it once, which is the same "a duplicate beats a loss"
|
|
605
|
+
# trade the close rows themselves make.
|
|
606
|
+
if dropped and _append_override_line(root, _dev_dropped_close_note(dropped, host_name)):
|
|
607
|
+
dropped = 0
|
|
608
|
+
|
|
609
|
+
appended = 0
|
|
610
|
+
remaining = []
|
|
611
|
+
stalled = False
|
|
612
|
+
for idx, line in enumerate(owed):
|
|
613
|
+
if stalled:
|
|
614
|
+
remaining.append(line) # the log is failing — everything after
|
|
615
|
+
continue # the first failure is still owed
|
|
616
|
+
if idx < replays and _overrides_has_line(root, line):
|
|
617
|
+
continue # crash-after-append: already in the trail
|
|
618
|
+
if not _append_override_line(root, line):
|
|
619
|
+
stalled = True
|
|
620
|
+
remaining.append(line) # still owed — replay on the next session
|
|
621
|
+
continue
|
|
622
|
+
appended += 1
|
|
623
|
+
|
|
624
|
+
marker_gone = True
|
|
625
|
+
if marker:
|
|
626
|
+
try:
|
|
627
|
+
os.remove(marker)
|
|
628
|
+
except OSError:
|
|
629
|
+
marker_gone = not os.path.isfile(marker)
|
|
630
|
+
|
|
631
|
+
# Keep the record ONLY while it still carries information: a line still
|
|
632
|
+
# owed, an unreported cap overflow, or the identity of a marker that
|
|
633
|
+
# survived its own removal (the tombstone that stops the next session
|
|
634
|
+
# minting a second close for it). A marker that IS gone takes its tombstone
|
|
635
|
+
# with it — a dead marker's mtime identifies nothing, and leaving it behind
|
|
636
|
+
# lets an unrelated future marker collide with it and lose a real close.
|
|
637
|
+
if remaining or dropped or (not marker_gone and marker_mtime is not None):
|
|
638
|
+
_write_dev_pending_close(root, {"lines": remaining,
|
|
639
|
+
"marker_mtime": (None if marker_gone
|
|
640
|
+
else marker_mtime),
|
|
641
|
+
"dropped": dropped})
|
|
642
|
+
else:
|
|
643
|
+
_discard_dev_pending_close(root)
|
|
644
|
+
return appended
|