@arbiterforge/ca-pi 0.6.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +41 -98
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +145 -0
  4. package/plugins/ca-pi/COMMANDS.md +138 -68
  5. package/plugins/ca-pi/SKILLS.md +137 -30
  6. package/plugins/ca-pi/agents/INDEX.md +3 -2
  7. package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
  8. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  9. package/plugins/ca-pi/agents/finding-triage.md +31 -14
  10. package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
  11. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +37 -36
  12. package/plugins/ca-pi/extensions/codearbiter.js +844 -19
  13. package/plugins/ca-pi/generated/command-catalog.json +386 -196
  14. package/plugins/ca-pi/generated/roles.json +9 -0
  15. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  16. package/plugins/ca-pi/hooks/_bashguardlib.py +30 -12
  17. package/plugins/ca-pi/hooks/_gitexec.py +23 -0
  18. package/plugins/ca-pi/hooks/_githooks.py +50 -23
  19. package/plugins/ca-pi/hooks/_hooklib.py +148 -20
  20. package/plugins/ca-pi/hooks/_host.py +9 -1
  21. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  22. package/plugins/ca-pi/hooks/_modelib.py +762 -0
  23. package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
  24. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  25. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  26. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  27. package/plugins/ca-pi/hooks/_releaselib.py +278 -48
  28. package/plugins/ca-pi/hooks/_updatelib.py +230 -50
  29. package/plugins/ca-pi/hooks/doctor.py +58 -9
  30. package/plugins/ca-pi/hooks/git-enforce.py +10 -3
  31. package/plugins/ca-pi/hooks/hostapi.py +220 -22
  32. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  33. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  34. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  35. package/plugins/ca-pi/hooks/session-start.py +529 -435
  36. package/plugins/ca-pi/hooks/statusline.py +28 -10
  37. package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
  38. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  39. package/plugins/ca-pi/includes/command-compatibility.md +16 -0
  40. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  41. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  42. package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
  43. package/plugins/ca-pi/includes/redirect.md +12 -1
  44. package/plugins/ca-pi/includes/routing-table.md +14 -5
  45. package/plugins/ca-pi/includes/safety-core.md +86 -0
  46. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  47. package/plugins/ca-pi/routines/INDEX.md +1 -1
  48. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +55 -3
  49. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
  50. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  51. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
  52. package/plugins/ca-pi/routines/release/SKILL.md +1 -1
  53. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
  54. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
  55. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
  56. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
  57. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
  58. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
  59. package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
  60. package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
  61. package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
  62. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  63. package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
  64. package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
  65. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  66. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  67. package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
@@ -0,0 +1,762 @@
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 hashlib
23
+ import json
24
+ import os
25
+ import re
26
+
27
+ from _activationlib import marker_root
28
+ from _hooklib import write_text_atomic
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # T-16 — PERSONA_SENTINEL: a single stable literal embedded in every composed
33
+ # persona injection (T-31, Lane B), so a later transcript-pruning pass
34
+ # (`_prunelib`/`_prunepolicy`, T-49/T-50, R-5) can recognize an injected-
35
+ # persona line and mark it `pinned=True` — protected from folding,
36
+ # condensing, and eviction at EVERY tier, including aggressive (AC-26).
37
+ #
38
+ # Deliberately shaped as an HTML comment (renders invisibly in the persona
39
+ # markdown) and deliberately distinct from `_prunepolicy.MARKER_PREFIX`
40
+ # ("[ca-condensed ") — the two must never collide: one marks "this content
41
+ # was ELIDED by a prior prune pass", the other marks "this content must
42
+ # NEVER be elided". Exported here, not in `_prunelib`/`_prunepolicy`,
43
+ # because the INJECTOR (this module's consumers) is the single source that
44
+ # must emit it — a value redefined in two places is a value that can drift.
45
+ PERSONA_SENTINEL = "<!-- codearbiter:persona-sentinel -->"
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # T-07 — the mode plane: three-value posture, resolved through marker_root
50
+ # ---------------------------------------------------------------------------
51
+ # `MODES` is the ONLY legal-value tuple; `dev` is retired (superseded by
52
+ # `dangerous`, R-3/ADR-0022 supersession). Index 0 is deliberately the safe
53
+ # default every anomaly falls back to.
54
+ MODES = ("arbiter", "dangerous", "ops")
55
+
56
+ # [[never-fold-unreadable-into-absent]] — the house rule this constant set
57
+ # exists to satisfy: a marker file that genuinely does not exist and one that
58
+ # exists but could not be read/parsed are DIFFERENT failure classes and must
59
+ # never collapse onto one diagnostic string. Every non-None diagnostic below
60
+ # still resolves the mode to MODES[0] ("arbiter") — these strings distinguish
61
+ # WHY, not WHAT the fallback is.
62
+ MODE_DIAG_ABSENT = "mode-marker-absent"
63
+ MODE_DIAG_UNREADABLE = "mode-marker-unreadable"
64
+ MODE_DIAG_UNRECOGNIZED = "mode-marker-unrecognized"
65
+
66
+
67
+ def mode_marker_path(root=None, payload=None):
68
+ """Absolute path of the mode marker: `<root>/.codearbiter/.markers/mode`.
69
+
70
+ `root` defaults to `_activationlib.marker_root(payload)` — deliberately
71
+ NOT `project_root(payload)`: `marker_root` exists precisely because
72
+ `project_root` splits marker state across linked worktrees (#604), and
73
+ every other `.codearbiter/.markers/` writer (security-pass.py,
74
+ migration-pass.py, the H-09b/H-10b/H-14 guards) already resolves through
75
+ it. An explicit `root` is accepted as a test-only escape hatch for
76
+ fixture isolation — production callers pass neither and let this resolve
77
+ via the host seam."""
78
+ if root is None:
79
+ root = marker_root(payload)
80
+ return os.path.join(root, ".codearbiter", ".markers", "mode")
81
+
82
+
83
+ def mode_entry_dir(root=None, payload=None):
84
+ """Absolute path of the per-session entry directory:
85
+ `<root>/.codearbiter/.markers/mode.d`.
86
+
87
+ One file per session, never a shared cell. The single-file
88
+ `{session_id: mode}` map this replaces made every flip a read-modify-write
89
+ over state other sessions also own, and `write_text_atomic` serializes the
90
+ replace, not the PAIR. A session holding a map it read moments earlier
91
+ could therefore reinstate a `dangerous` entry another session had
92
+ explicitly left — with that session's own earlier `enter` row still
93
+ backing it in `ledger_backs`, so nothing downstream noticed. Verifying the
94
+ write did not close it: each writer only ever confirmed its OWN key, and
95
+ the victim had already returned success before the clobber landed.
96
+
97
+ Splitting the state removes the shared cell rather than serializing access
98
+ to it, so the interleave has nowhere left to occur. That also matches what
99
+ the plane already is — ADR-0030 position 6 makes it transient and
100
+ session-scoped — and is the direction ADR-0012 named ("session-scoped
101
+ markers") when it deferred this hardening as out of scope for the Codex
102
+ campaign.
103
+
104
+ Locking the map with `_hooklib.acquire_lock` was the real alternative, and
105
+ a close one: the primitive already exists, is OS-owned (process death
106
+ releases it, so there is no stale lock to steal), and would have confined
107
+ the change to this function. It loses on the prompt seam. `acquire_lock`
108
+ fail-softs to None after a bounded contention spin, and the only safe
109
+ reading of None here is failure — so under contention a user's flip would
110
+ stop working rather than serialize, which is the cost taskwrite.py accepts
111
+ for a board write and this path should not. Removing the shared cell has no
112
+ contention state at all.
113
+
114
+ Resolves through `marker_root` exactly as `mode_marker_path` does."""
115
+ if root is None:
116
+ root = marker_root(payload)
117
+ return os.path.join(root, ".codearbiter", ".markers", "mode.d")
118
+
119
+
120
+ def _mode_entry_name(session_id):
121
+ """The entry filename for `session_id`: its SHA-256 hex digest.
122
+
123
+ Hashed rather than sanitized because a session id arrives from host-
124
+ supplied hook input and is therefore untrusted for path construction. A
125
+ substitution sanitizer has to be injective to be correct, and on this
126
+ Windows-primary project it cannot be: NTFS is case-insensitive, so ids
127
+ differing only in case collapse onto one file — reintroducing the shared
128
+ cell this design exists to remove, on the platform most sessions run on. A
129
+ lowercase hex digest is fixed-length, path-safe, case-stable, cannot
130
+ traverse, and cannot collide with a reserved device name.
131
+
132
+ The cost is a directory of opaque names, paid back by the record itself:
133
+ each entry stores the session id it belongs to, so the mapping is
134
+ verifiable by reading one file rather than trusted."""
135
+ return hashlib.sha256(str(session_id).encode("utf-8")).hexdigest()
136
+
137
+
138
+ def mode_entry_path(session_id, root=None, payload=None):
139
+ """Absolute path of `session_id`'s own mode entry."""
140
+ return os.path.join(mode_entry_dir(root, payload), _mode_entry_name(session_id))
141
+
142
+
143
+ def _read_session_entry(session_id, root=None, payload=None):
144
+ """(value, diagnostic) off `session_id`'s OWN entry file.
145
+
146
+ `value` is the raw recorded mode, or None whenever a diagnostic is set —
147
+ validating it against MODES is `current_mode`'s job, as it was for the map.
148
+ Diagnostics carry the same three-way distinction the map reader draws
149
+ ([[never-fold-unreadable-into-absent]]), and `os.path.exists` gates the
150
+ absent check for the same reason: a directory at that path must report
151
+ UNREADABLE, not ABSENT.
152
+
153
+ An entry whose recorded `session` is not the one asked for is UNRECOGNIZED,
154
+ never silently honoured. That makes the hash mapping checked rather than
155
+ assumed — a hand-edited file, a restored backup, or (astronomically) a
156
+ digest collision resolves toward `arbiter` with a diagnostic instead of
157
+ handing one session another's posture."""
158
+ path = mode_entry_path(session_id, root, payload)
159
+ if not os.path.exists(path):
160
+ return None, MODE_DIAG_ABSENT
161
+ try:
162
+ with open(path, encoding="utf-8") as f:
163
+ text = f.read()
164
+ except Exception: # noqa: BLE001 — exists but could not be read
165
+ return None, MODE_DIAG_UNREADABLE
166
+ text = text.strip()
167
+ if not text:
168
+ return None, MODE_DIAG_UNRECOGNIZED
169
+ try:
170
+ data = json.loads(text)
171
+ except Exception: # noqa: BLE001 — not valid JSON
172
+ return None, MODE_DIAG_UNRECOGNIZED
173
+ if not isinstance(data, dict) or data.get("session") != str(session_id):
174
+ return None, MODE_DIAG_UNRECOGNIZED
175
+ return data.get("mode"), None
176
+
177
+
178
+ def session_has_entry(session_id, root=None, payload=None):
179
+ """(has_entry, diagnostic) — whether `session_id` has recorded ANY mode-
180
+ plane opinion yet, including a deliberate `arbiter`.
181
+
182
+ The distinction `current_mode` cannot express: it answers `arbiter` both
183
+ for a session that never flipped and for one that flipped back, and the
184
+ SessionStart legacy conversion (T-47) may only migrate over the first.
185
+ Absence is the only clean "nothing to convert over" — so an unreadable
186
+ entry reports its diagnostic and `False`, and the caller must treat that
187
+ as "could not tell", never as absence."""
188
+ _value, diag = _read_session_entry(session_id, root=root, payload=payload)
189
+ if diag is None:
190
+ return True, None
191
+ if diag != MODE_DIAG_ABSENT:
192
+ return False, diag
193
+ state, legacy_diag = _read_mode_state(root, payload)
194
+ if legacy_diag is not None and legacy_diag != MODE_DIAG_ABSENT:
195
+ return False, legacy_diag
196
+ return str(session_id) in {str(k) for k in state}, None
197
+
198
+
199
+ def _read_mode_state(root=None, payload=None):
200
+ """(state, diagnostic) off the LEGACY single-file mode marker.
201
+
202
+ Read-only since #681 split the plane into per-session entries
203
+ (`mode_entry_dir`); nothing writes this file any more. It is still read so
204
+ a session live across the upgrade keeps the posture it chose, and — more
205
+ importantly — so `session_has_entry` keeps seeing an explicit `arbiter`
206
+ that predates the split. Dropping the read would fail SAFE for the mode
207
+ itself (absent resolves to `arbiter`) but not for that second question: the
208
+ entry would read as "never flipped", re-arming the legacy `dev-active`
209
+ conversion to turn gates off under a session that had already chosen.
210
+
211
+ `state` is the RAW dict mapping session_id -> whatever value was on disk
212
+ for it (validation of an individual session's value is `current_mode`'s
213
+ job, not this function's — a per-session bad value must still be visible
214
+ to the caller so it can be reported, not silently dropped). Always a
215
+ dict, never None, so callers never need a None-check. A file that is
216
+ itself absent, unreadable, empty, or not a JSON object returns `{}` plus
217
+ a diagnostic.
218
+
219
+ `diagnostic` is None on a clean read (the file parses as a JSON object —
220
+ individual bad entries inside it do not taint this diagnostic). Otherwise
221
+ exactly one of MODE_DIAG_ABSENT / MODE_DIAG_UNREADABLE /
222
+ MODE_DIAG_UNRECOGNIZED — `os.path.exists` (not `os.path.isfile`) gates the
223
+ absent check, so a path that exists but cannot be opened as a normal file
224
+ (a directory sitting at that path, or a real permissions error) falls
225
+ through to the `open()` call and is correctly reported UNREADABLE rather
226
+ than ABSENT. This is the portable, no-chmod-needed shape of the
227
+ distinction: a directory path always fails `open()` (IsADirectoryError on
228
+ POSIX, PermissionError on Windows — both are OSError) without depending
229
+ on host-specific permission semantics."""
230
+ path = mode_marker_path(root, payload)
231
+ if not os.path.exists(path):
232
+ return {}, MODE_DIAG_ABSENT
233
+ try:
234
+ with open(path, encoding="utf-8") as f:
235
+ text = f.read()
236
+ except Exception: # noqa: BLE001 — exists but could not be read
237
+ return {}, MODE_DIAG_UNREADABLE
238
+ text = text.strip()
239
+ if not text:
240
+ return {}, MODE_DIAG_UNRECOGNIZED
241
+ try:
242
+ data = json.loads(text)
243
+ except Exception: # noqa: BLE001 — not valid JSON
244
+ return {}, MODE_DIAG_UNRECOGNIZED
245
+ if not isinstance(data, dict):
246
+ return {}, MODE_DIAG_UNRECOGNIZED
247
+ return data, None
248
+
249
+
250
+ def current_mode(session_id, root=None, payload=None):
251
+ """(mode, diagnostic) for `session_id`.
252
+
253
+ Resolves MODES[0] ("arbiter") whenever the marker file is absent, empty,
254
+ unreadable, or unrecognized (AC-2) — WITH a diagnostic distinguishing
255
+ which — and also when the file is clean but simply has no entry yet for
256
+ this session (a fresh session legitimately starts arbiter; that is not an
257
+ anomaly, so diagnostic is None). When the file is clean but THIS
258
+ session's own recorded value is not a legal mode, that is reported the
259
+ same as a file-level unrecognized value (MODE_DIAG_UNRECOGNIZED) — a
260
+ garbage per-session entry is exactly as much an anomaly as a garbage
261
+ file, and must not be swallowed silently.
262
+
263
+ Reads `session_id`'s own entry first and falls back to the legacy map ONLY
264
+ when that entry is absent (never when it is unreadable — an entry we could
265
+ not read is not evidence that the legacy value is current). Once a session
266
+ has written once, its own entry is authoritative and the legacy file can
267
+ never revive a superseded posture."""
268
+ value, diag = _read_session_entry(session_id, root=root, payload=payload)
269
+ if diag is None:
270
+ if value not in MODES:
271
+ return MODES[0], MODE_DIAG_UNRECOGNIZED
272
+ return value, None
273
+ if diag != MODE_DIAG_ABSENT:
274
+ return MODES[0], diag
275
+
276
+ state, diag = _read_mode_state(root, payload)
277
+ if diag is not None:
278
+ return MODES[0], diag
279
+ if session_id not in state:
280
+ return MODES[0], None
281
+ value = state.get(session_id)
282
+ if value not in MODES:
283
+ return MODES[0], MODE_DIAG_UNRECOGNIZED
284
+ return value, None
285
+
286
+
287
+ def write_mode(session_id, mode, root=None, payload=None):
288
+ """Persist `mode` for `session_id` (T-08, AC-1) in that session's OWN entry.
289
+
290
+ The write is delegated ENTIRELY to `write_text_atomic` — this function does
291
+ no `open()`/`write()` of its own — so an interrupted write can only ever
292
+ land in write_text_atomic's own guarantee: a sibling temp file, then
293
+ `os.replace()`; on any failure the temp is removed and `path` is left
294
+ exactly as it was (untouched if it existed, absent if it did not). Returns
295
+ True on a confirmed write, False on failure. Never raises — the caller
296
+ (`flip`, T-11/T-14) decides what failure means.
297
+
298
+ There is no read-modify-write and therefore no retry loop. Writing one
299
+ session's posture no longer requires reading state other sessions own, so
300
+ the interleave that made the old loop necessary — and that the loop could
301
+ not actually close, because each writer verified only its OWN key while the
302
+ victim had already returned success — has nowhere to occur. See
303
+ `mode_entry_dir` for why the shared cell was removed rather than locked.
304
+
305
+ VERIFIED, not merely attempted: the entry is re-read and must come back as
306
+ this session's, carrying this value. ADR-0030 position 5 requires the
307
+ return path out of `dangerous` to be "a verified write" that "must surface
308
+ its failure"; a write that cannot be confirmed returns False rather than
309
+ reporting a success it cannot demonstrate."""
310
+ path = mode_entry_path(session_id, root, payload)
311
+ record = json.dumps({"session": str(session_id), "mode": mode})
312
+ try:
313
+ os.makedirs(os.path.dirname(path), exist_ok=True)
314
+ write_text_atomic(path, record, newline="\n")
315
+ except OSError:
316
+ return False
317
+ observed, diag = _read_session_entry(session_id, root=root, payload=payload)
318
+ return diag is None and observed == mode
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # T-11 — flip(): the deterministic token-flip primitive every host caller
323
+ # (prompt-submit.py, pi-bridge.py) drives through. Three distinct sentinels,
324
+ # never a bare bool — a caller has to tell "already there" from "the write
325
+ # failed" apart to report either correctly to the user.
326
+ # ---------------------------------------------------------------------------
327
+ FLIP_FLIPPED = "flipped"
328
+ FLIP_NOOP = "noop"
329
+ FLIP_FAILED = "failed"
330
+
331
+
332
+ def _mode_audit_line(verb, mode, host_name=None, now=None, session_id=None):
333
+ """One `MODE: <name> enter|exit` audit row (Decided parameters: Audit
334
+ verb). `now` (epoch seconds) is injectable for deterministic tests;
335
+ defaults to the real current time.
336
+
337
+ Carries `SESSION:` because the row is an AUTHORIZATION, not just a record:
338
+ `ledger_backs` reads it to decide whether a gates-off marker is allowed to
339
+ take effect. Without the field that check is repo-wide, so one session's
340
+ `enter` row authorizes ANOTHER session's marker — the mode plane is keyed
341
+ per session everywhere else, and an unkeyed authorization defeats that
342
+ isolation (AC-3)."""
343
+ ts = (datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc)
344
+ if now is not None
345
+ else datetime.datetime.now(datetime.timezone.utc))
346
+ ts_str = ts.strftime("%Y-%m-%dT%H:%M:%SZ")
347
+ return (f"[{ts_str}] | BY: session-mode | HOST: {host_name or 'unknown'} "
348
+ f"| SESSION: {session_id or 'unknown'} "
349
+ f"| MODE: {mode} {verb} | NOTE: —\n")
350
+
351
+
352
+ def flip(session_id, mode, root=None, payload=None, host_name=None, now=None):
353
+ """Attempt to set `session_id`'s mode to `mode`. Returns one of
354
+ FLIP_FLIPPED / FLIP_NOOP / FLIP_FAILED. Never raises.
355
+
356
+ AC-6: a flip TO THE ALREADY-ACTIVE mode is a no-op — no write is even
357
+ attempted and no audit row is appended, so `overrides.log` is left
358
+ byte-identical. This is also the mechanism behind T-14's fail-direction
359
+ asymmetry: once a failed flip has left the session's resolved mode
360
+ unchanged, any LATER flip back to that same resolved mode is a no-op
361
+ under ANY filesystem state — including a markers directory that cannot
362
+ be written to at all — because a no-op never touches disk.
363
+
364
+ On a genuine transition the order is deliberate: write first, audit row
365
+ ONLY on a confirmed write. AC-11's `ledger_backs` exists to catch exactly
366
+ the opposite ordering — a ledger row minted for a flip that never
367
+ actually landed, which would let an unbacked marker masquerade as an
368
+ audited one."""
369
+ root = root if root is not None else marker_root(payload)
370
+ current, _diag = current_mode(session_id, root=root, payload=payload)
371
+ if current == mode:
372
+ return FLIP_NOOP
373
+ if not write_mode(session_id, mode, root=root, payload=payload):
374
+ return FLIP_FAILED
375
+ # ADR-0030 position 4: EVERY transition row is staged through the #396
376
+ # write-ahead ledger, never a bare append. The exit half already complied;
377
+ # this one did not, so an unwritable overrides.log dropped the `MODE: …
378
+ # enter` row with no replay while `flip` still reported success — an
379
+ # unaudited entry into a gates-off posture, which is the one transition
380
+ # that must never be silent.
381
+ #
382
+ # Reporting FLIP_FAILED when the row is not confirmed is consistent rather
383
+ # than pessimistic: `ledger_backs` (AC-11) already refuses to compose a
384
+ # body whose mode has no matching `enter` row, so an unaudited marker is
385
+ # not in effect anyway. Saying so out loud beats leaving the user believing
386
+ # a flip took that the injector will ignore.
387
+ #
388
+ # Confirmed by looking for the row itself, not by the settle count: a
389
+ # settle can append an OLDER owed line and stall on this one, which would
390
+ # read as success from the count alone.
391
+ line = _mode_audit_line("enter", mode, host_name=host_name, now=now,
392
+ session_id=session_id)
393
+ _settle_dev_close(root, new_line=line, host_name=host_name)
394
+ if not _overrides_has_line(root, line):
395
+ return FLIP_FAILED
396
+ return FLIP_FLIPPED
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # T-12 — the token table: `mode --arbiter|--dangerous|--ops`, matched
401
+ # WHOLE-PROMPT, never substring (Decided parameters: Token). Pure text logic,
402
+ # no I/O — every host's prompt-seam interceptor (Claude/Codex/Pi) imports
403
+ # this so the matching rule can never drift between hosts.
404
+ # ---------------------------------------------------------------------------
405
+ MODE_TOKEN_REPORT = "report" # bare `mode`: report current + legal values, write nothing
406
+
407
+ _MODE_TOKEN_RE = re.compile(r"mode(?:\s+--(arbiter|dangerous|ops))?", re.I)
408
+
409
+
410
+ def match_mode_token(prompt):
411
+ """Classify `prompt` against the mode control-token table.
412
+
413
+ Returns one of MODES (a flip request), MODE_TOKEN_REPORT (bare `mode`),
414
+ or None (not a control token at all — the prompt reaches the model
415
+ unmodified).
416
+
417
+ Whole-prompt only: `re.fullmatch` against the prompt after stripping
418
+ SURROUNDING whitespace (never internal) means a token embedded anywhere
419
+ in a longer prompt — before, after, or on another line — cannot match,
420
+ because fullmatch requires the ENTIRE stripped string to be consumed by
421
+ the pattern and the pattern contains no `\\n`. Case-insensitive (`re.I`);
422
+ surrounding whitespace of any kind (spaces, tabs, newlines) is
423
+ insensitive because it is stripped before matching."""
424
+ if not isinstance(prompt, str):
425
+ return None
426
+ stripped = prompt.strip()
427
+ if not stripped:
428
+ return None
429
+ m = _MODE_TOKEN_RE.fullmatch(stripped)
430
+ if not m:
431
+ return None
432
+ name = m.group(1)
433
+ if name is None:
434
+ return MODE_TOKEN_REPORT
435
+ return name.lower()
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # T-13 — ledger_backs(): the AC-11 compensating control. The deterministic
440
+ # flip removes ADR-0022's tier-2 confirmation for dangerous-mode entry (its
441
+ # supersession, per the spec's ADR conflict note); this is the load-bearing
442
+ # replacement — the injector refuses to compose a non-arbiter body the audit
443
+ # trail does not back.
444
+ # ---------------------------------------------------------------------------
445
+ _LEGACY_DEV_ENTER_RE = re.compile(r"\|\s*DEV:\s*enter\s*(?:\||$)", re.M)
446
+
447
+
448
+ def ledger_backs(root, mode, session_id=None):
449
+ """True iff the audit trail (at `root`) holds a matching
450
+ `MODE: <mode> enter` row FOR `session_id`.
451
+
452
+ Session-scoped, because this row is an authorization rather than a
453
+ record: it decides whether a gates-off marker takes effect. A repo-wide
454
+ match let one session's `enter` row authorize a DIFFERENT session's marker
455
+ — every other part of the mode plane is keyed per session, and an unkeyed
456
+ authorization defeats that isolation (AC-3). Pass `session_id` at every
457
+ production call site; omitting it keeps the older repo-wide question,
458
+ which is only ever the right one for a caller that has no session.
459
+
460
+ A row written before this field existed carries no session and therefore
461
+ backs NO session-scoped query. That fails toward `arbiter` — gates ON, one
462
+ re-flip — which is the direction ADR-0030 requires; the alternative would
463
+ reopen the hole for exactly the history that cannot be checked.
464
+
465
+ A legacy `DEV: enter` row backs `mode == "dangerous"` ONLY — dev was
466
+ retired INTO dangerous (T-47 converts a live `dev-active` marker to
467
+ `dangerous` exactly once), so a pre-mode-plane audit trail's DEV: enter
468
+ rows must continue to authorize it. A legacy row must NEVER back `ops`:
469
+ `ops` did not exist when any DEV: row could have been written, so
470
+ accepting one there would be a fail-OPEN into a mode the operator never
471
+ actually requested — the exact failure this function exists to prevent,
472
+ just relocated to a different mode.
473
+
474
+ Read-only and tolerant: an absent or unreadable log answers False,
475
+ never raises — consistent with this module's fail-toward-arbiter
476
+ convention (a missing ledger can never AUTHORIZE anything)."""
477
+ try:
478
+ with open(_overrides_log_path(root), encoding="utf-8", errors="replace") as f:
479
+ text = f.read()
480
+ except Exception: # noqa: BLE001 — absent/unreadable log -> nothing backs it
481
+ return False
482
+ enter_re = re.compile(r"\|\s*MODE:\s*" + re.escape(mode) + r"\s+enter\s*(?:\||$)", re.M)
483
+ if session_id is None:
484
+ if enter_re.search(text):
485
+ return True
486
+ else:
487
+ session_re = re.compile(r"\|\s*SESSION:\s*" + re.escape(str(session_id)) + r"\s*\|")
488
+ for line in text.splitlines():
489
+ if session_re.search(line) and enter_re.search(line):
490
+ return True
491
+ # The legacy exception, deliberately session-blind: `dev` was retired INTO
492
+ # `dangerous` (T-47 converts a live `dev-active` marker exactly once), and a
493
+ # pre-mode-plane `DEV: enter` row predates session attribution entirely, so
494
+ # requiring one would break the migration it exists to serve. Bounded to
495
+ # `dangerous` on a repo that already has DEV history — `ops` never gets it,
496
+ # since accepting a legacy row there would authorize a mode the operator
497
+ # could not have requested when that row was written.
498
+ if mode == "dangerous" and _LEGACY_DEV_ENTER_RE.search(text):
499
+ return True
500
+ return False
501
+
502
+
503
+ # --- #396: a durable, retryable DEV: exit -----------------------------------
504
+ # The synthetic close line is the ONLY thing that keeps the append-only audit
505
+ # trail's DEV: enter/exit pairs matched after an abandoned maintainer session.
506
+ # It used to be written best-effort ("except OSError: pass") and the marker was
507
+ # then removed regardless — so a locked file, a full disk, or a permission blip
508
+ # permanently erased the obligation and left an orphaned DEV: enter that no
509
+ # later session could know about.
510
+ #
511
+ # The fix is a small write-ahead record: the owed line is staged on disk BEFORE
512
+ # the append is attempted, and the record is deleted only once BOTH the append
513
+ # is confirmed AND the marker it settles is gone. That single record therefore
514
+ # carries three facts at once:
515
+ #
516
+ # "lines" — close lines still owed to overrides.log. Emptied one at a
517
+ # time as each append is confirmed.
518
+ # "marker_mtime" — the identity of the dev-active marker this close belongs
519
+ # to. While the record still names a LIVE marker, the
520
+ # force-close path knows that marker has already been
521
+ # closed in the audit trail and refuses to mint a second
522
+ # row for it — which is what makes a failed `os.remove`
523
+ # idempotent rather than duplicating the close. It is
524
+ # cleared the moment that marker is gone: an mtime only
525
+ # identifies a file that still EXISTS, and a stale one is
526
+ # free to collide with an unrelated future marker (2s
527
+ # granularity on FAT32/exFAT/SMB/WSL mounts makes that a
528
+ # real event, not a theoretical one) and suppress a close
529
+ # that is genuinely owed.
530
+ # "dropped" — how many owed close lines the bound below has discarded.
531
+ # The cap keeps the record small, but the loss must not be
532
+ # silent: the count is written to the trail as one
533
+ # attributable note the moment overrides.log accepts writes.
534
+ #
535
+ # Replayed lines carry the timestamp they were MINTED with, not the time they
536
+ # land, so a delayed replay leaves overrides.log non-chronological. Enter/exit
537
+ # pairing is by timestamp, so that is correct — but an audit reader must not
538
+ # assume file order is time order.
539
+ #
540
+ # Every boundary is covered:
541
+ # crash before the append -> record present, line owed -> replayed
542
+ # crash after the append -> record present, line owed -> the bounded
543
+ # tail scan sees the line already landed and
544
+ # drops it instead of appending a duplicate
545
+ # marker removal fails -> record present, no line owed -> the next
546
+ # session only retries the removal
547
+ #
548
+ # That tail scan is applied ONLY to lines read back off the record — the ones
549
+ # that might have landed before a crash. A line minted in THIS process cannot
550
+ # already be on the trail, and must never be dedupe-checked: close rows are
551
+ # timestamped to the second, so two distinct closes minted in the same second
552
+ # are byte-identical, and checking the fresh one against an owed copy of itself
553
+ # would silently swallow a close that is genuinely owed.
554
+ #
555
+ # Everything here is best-effort by the module's standing convention: session
556
+ # startup must never be bricked by audit bookkeeping, so nothing raises.
557
+ _DEV_PENDING_CLOSE_MAX = 8 # bounded: never accumulate owed lines forever
558
+ _DEV_PENDING_SCAN_BYTES = 64 * 1024 # bounded tail scan for the dedupe check
559
+
560
+
561
+ def _dev_pending_close_path(root):
562
+ return os.path.join(root, ".codearbiter", ".markers", "dev-close-pending.json")
563
+
564
+
565
+ def _overrides_log_path(root):
566
+ return os.path.join(root, ".codearbiter", "overrides.log")
567
+
568
+
569
+ def _read_dev_pending_close(root):
570
+ """The pending-close record as
571
+ {"lines": [...], "marker_mtime": float|None, "dropped": int}, or None when
572
+ there is nothing usable on disk. A record that exists but carries no
573
+ replayable line, no marker identity and no unreported drop is reported as
574
+ None so the caller discards it — a corrupt record must never wedge the
575
+ mechanism shut. Never raises."""
576
+ try:
577
+ with open(_dev_pending_close_path(root), encoding="utf-8") as f:
578
+ data = json.load(f)
579
+ if not isinstance(data, dict):
580
+ return None
581
+ lines = [ln for ln in (data.get("lines") or [])
582
+ if isinstance(ln, str) and ln.strip()][:_DEV_PENDING_CLOSE_MAX]
583
+ mtime = data.get("marker_mtime")
584
+ mtime = float(mtime) if isinstance(mtime, (int, float)) else None
585
+ dropped = data.get("dropped")
586
+ # `isinstance(True, int)` is True, so booleans are excluded explicitly.
587
+ dropped = (int(dropped) if isinstance(dropped, int)
588
+ and not isinstance(dropped, bool) and dropped > 0 else 0)
589
+ if not lines and mtime is None and not dropped:
590
+ return None
591
+ return {"lines": lines, "marker_mtime": mtime, "dropped": dropped}
592
+ except Exception: # noqa: BLE001 — absent/corrupt record -> no signal
593
+ return None
594
+
595
+
596
+ def _write_dev_pending_close(root, rec):
597
+ """Atomically persist the pending-close record. Never raises — a write
598
+ failure only costs the retry signal this call was trying to create, which
599
+ is exactly the pre-#396 behavior and still must not brick startup."""
600
+ try:
601
+ path = _dev_pending_close_path(root)
602
+ os.makedirs(os.path.dirname(path), exist_ok=True)
603
+ write_text_atomic(path, json.dumps(rec), newline="\n")
604
+ except Exception: # noqa: BLE001 — must never brick session startup
605
+ pass
606
+
607
+
608
+ def _discard_dev_pending_close(root):
609
+ try:
610
+ os.remove(_dev_pending_close_path(root))
611
+ except OSError:
612
+ pass
613
+
614
+
615
+ def _overrides_has_line(root, line):
616
+ """True iff `line` already appears in the tail of overrides.log. Bounded to
617
+ the last _DEV_PENDING_SCAN_BYTES — a replay always happens on the very next
618
+ SessionStart, so the line it is looking for is at (or near) the end. An
619
+ unreadable log answers False: re-appending a close row is a far smaller
620
+ harm than silently dropping one.
621
+
622
+ Read in BINARY and decoded here on purpose: a byte offset is only
623
+ meaningful to seek() on a binary stream, and the comparison is made on the
624
+ stripped line so the platform EOL the append produced never matters."""
625
+ needle = line.strip()
626
+ if not needle:
627
+ return False
628
+ try:
629
+ path = _overrides_log_path(root)
630
+ size = os.path.getsize(path)
631
+ with open(path, "rb") as f:
632
+ if size > _DEV_PENDING_SCAN_BYTES:
633
+ f.seek(size - _DEV_PENDING_SCAN_BYTES)
634
+ tail = f.read().decode("utf-8", "replace")
635
+ return needle in tail
636
+ except Exception: # noqa: BLE001 — cannot confirm -> assume not present
637
+ return False
638
+
639
+
640
+ def _append_override_line(root, line):
641
+ """Append one audit line to overrides.log. True on a confirmed write."""
642
+ try:
643
+ with open(_overrides_log_path(root), "a", encoding="utf-8") as f:
644
+ f.write(line)
645
+ return True
646
+ except OSError:
647
+ return False
648
+
649
+
650
+ def _dev_dropped_close_note(count, host_name=None):
651
+ """One audit line accounting for close rows the pending-close cap had to
652
+ discard. Deliberately NOT a `DEV: exit` row — it closes nothing; it records
653
+ that N closes can never be written, so a reader of the append-only trail
654
+ can attribute the unmatched entries instead of finding an unexplained gap.
655
+ """
656
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
657
+ return (f"[{ts}] | BY: session-cleanup | HOST: {host_name or 'unknown'} "
658
+ f"| DEV: close-dropped | NOTE: {count} owed close row(s) discarded - the "
659
+ f"pending-close cap ({_DEV_PENDING_CLOSE_MAX}) was reached while "
660
+ f"overrides.log was unwritable; that many maintainer sessions have "
661
+ f"no matching close row\n")
662
+
663
+
664
+ def _settle_dev_close(root, marker=None, new_line=None, host_name=None):
665
+ """Drive the pending-close record to settlement; the single place the owed
666
+ DEV: exit is appended and the retry state is cleared.
667
+
668
+ `marker` is the dev-active path when one is live (its mtime becomes the
669
+ close identity), None when there is no marker to settle. `new_line` is a
670
+ freshly minted close line to take on, or None when this is a pure replay of
671
+ whatever is already owed. `host_name` only attributes the cap-overflow note
672
+ below; the close lines themselves already carry their own HOST field.
673
+ Returns the number of close lines appended by THIS call. Never raises."""
674
+ if (marker is None and new_line is None
675
+ and not os.path.isfile(_dev_pending_close_path(root))):
676
+ return 0 # nothing owed, nothing to settle — the overwhelming case
677
+ rec = _read_dev_pending_close(root)
678
+ owed = list(rec["lines"]) if rec else []
679
+ prev_mtime = rec["marker_mtime"] if rec else None
680
+ dropped = rec["dropped"] if rec else 0
681
+
682
+ marker_mtime = None
683
+ if marker:
684
+ try:
685
+ marker_mtime = os.path.getmtime(marker)
686
+ except OSError:
687
+ marker_mtime = None
688
+
689
+ # Everything already in `owed` came off disk, so it MAY have reached the
690
+ # trail before a crash and has to be dedupe-checked. Anything appended
691
+ # below is minted in this process and cannot possibly be there yet.
692
+ replays = len(owed)
693
+
694
+ if new_line is not None:
695
+ # Already closed THIS marker (the append landed, only the removal
696
+ # failed) -> do not mint a second row for it; just retry the cleanup.
697
+ already_closed = (rec is not None and prev_mtime is not None
698
+ and marker_mtime is not None
699
+ and prev_mtime == marker_mtime)
700
+ if not already_closed:
701
+ owed.append(new_line)
702
+ if len(owed) > _DEV_PENDING_CLOSE_MAX:
703
+ # Bounded, but never SILENT. A permanently-unwritable overrides.log
704
+ # would otherwise accumulate owed lines forever, so the oldest are
705
+ # discarded — and counted, so the loss is itself auditable rather than
706
+ # reintroducing exactly the unmatched `DEV: enter` this record exists
707
+ # to prevent.
708
+ overflow = len(owed) - _DEV_PENDING_CLOSE_MAX
709
+ dropped += overflow
710
+ owed = owed[-_DEV_PENDING_CLOSE_MAX:]
711
+ replays = max(0, replays - overflow) # the discards come off the front
712
+
713
+ if owed or dropped or marker_mtime is not None:
714
+ # Write-ahead: the obligation is durable BEFORE the append is tried.
715
+ _write_dev_pending_close(root, {"lines": owed,
716
+ "marker_mtime": marker_mtime,
717
+ "dropped": dropped})
718
+
719
+ # The overflow note goes in FIRST — the rows it accounts for are older than
720
+ # everything still owed. It is minted fresh each attempt, so it is not
721
+ # deduped by the tail scan; a crash between this append and the write-back
722
+ # below can repeat it once, which is the same "a duplicate beats a loss"
723
+ # trade the close rows themselves make.
724
+ if dropped and _append_override_line(root, _dev_dropped_close_note(dropped, host_name)):
725
+ dropped = 0
726
+
727
+ appended = 0
728
+ remaining = []
729
+ stalled = False
730
+ for idx, line in enumerate(owed):
731
+ if stalled:
732
+ remaining.append(line) # the log is failing — everything after
733
+ continue # the first failure is still owed
734
+ if idx < replays and _overrides_has_line(root, line):
735
+ continue # crash-after-append: already in the trail
736
+ if not _append_override_line(root, line):
737
+ stalled = True
738
+ remaining.append(line) # still owed — replay on the next session
739
+ continue
740
+ appended += 1
741
+
742
+ marker_gone = True
743
+ if marker:
744
+ try:
745
+ os.remove(marker)
746
+ except OSError:
747
+ marker_gone = not os.path.isfile(marker)
748
+
749
+ # Keep the record ONLY while it still carries information: a line still
750
+ # owed, an unreported cap overflow, or the identity of a marker that
751
+ # survived its own removal (the tombstone that stops the next session
752
+ # minting a second close for it). A marker that IS gone takes its tombstone
753
+ # with it — a dead marker's mtime identifies nothing, and leaving it behind
754
+ # lets an unrelated future marker collide with it and lose a real close.
755
+ if remaining or dropped or (not marker_gone and marker_mtime is not None):
756
+ _write_dev_pending_close(root, {"lines": remaining,
757
+ "marker_mtime": (None if marker_gone
758
+ else marker_mtime),
759
+ "dropped": dropped})
760
+ else:
761
+ _discard_dev_pending_close(root)
762
+ return appended