@arbiterforge/ca-pi 0.6.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +21 -17
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +74 -0
  4. package/plugins/ca-pi/COMMANDS.md +0 -7
  5. package/plugins/ca-pi/SKILLS.md +0 -2
  6. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  7. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +28 -36
  8. package/plugins/ca-pi/extensions/codearbiter-child.js +2 -2
  9. package/plugins/ca-pi/extensions/codearbiter.js +766 -26
  10. package/plugins/ca-pi/generated/command-catalog.json +0 -10
  11. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  12. package/plugins/ca-pi/hooks/_bashguardlib.py +12 -1
  13. package/plugins/ca-pi/hooks/_hooklib.py +58 -17
  14. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  15. package/plugins/ca-pi/hooks/_modelib.py +644 -0
  16. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  17. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  18. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  19. package/plugins/ca-pi/hooks/doctor.py +2 -1
  20. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  21. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  22. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  23. package/plugins/ca-pi/hooks/session-start.py +526 -434
  24. package/plugins/ca-pi/hooks/statusline.py +27 -9
  25. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  26. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  27. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  28. package/plugins/ca-pi/includes/pi-host-notes.md +14 -3
  29. package/plugins/ca-pi/includes/redirect.md +12 -1
  30. package/plugins/ca-pi/includes/routing-table.md +1 -0
  31. package/plugins/ca-pi/includes/safety-core.md +86 -0
  32. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  33. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +1 -1
  34. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  35. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +1 -1
  36. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  37. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  38. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  39. package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
@@ -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