@arbiterforge/ca-pi 0.8.1 → 0.10.2
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 +29 -90
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +89 -0
- package/plugins/ca-pi/COMMANDS.md +141 -64
- package/plugins/ca-pi/SKILLS.md +137 -28
- package/plugins/ca-pi/agents/INDEX.md +3 -2
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
- package/plugins/ca-pi/agents/finding-triage.md +31 -14
- package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
- package/plugins/ca-pi/arbiter.md +12 -3
- package/plugins/ca-pi/extensions/codearbiter.js +137 -15
- package/plugins/ca-pi/generated/command-catalog.json +386 -186
- package/plugins/ca-pi/generated/roles.json +9 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +33 -16
- package/plugins/ca-pi/hooks/_gitexec.py +23 -0
- package/plugins/ca-pi/hooks/_githooks.py +50 -23
- package/plugins/ca-pi/hooks/_hooklib.py +94 -7
- package/plugins/ca-pi/hooks/_host.py +9 -1
- package/plugins/ca-pi/hooks/_modelib.py +173 -55
- package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
- package/plugins/ca-pi/hooks/_releaselib.py +278 -48
- package/plugins/ca-pi/hooks/_updatelib.py +230 -50
- package/plugins/ca-pi/hooks/doctor.py +56 -8
- package/plugins/ca-pi/hooks/git-enforce.py +10 -3
- package/plugins/ca-pi/hooks/hostapi.py +220 -22
- package/plugins/ca-pi/hooks/session-start.py +8 -6
- package/plugins/ca-pi/hooks/statusline.py +1 -1
- package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
- package/plugins/ca-pi/includes/command-compatibility.md +16 -0
- package/plugins/ca-pi/includes/routing-table.md +13 -5
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +54 -2
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
- package/plugins/ca-pi/routines/release/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
- package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
- package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import datetime
|
|
22
|
+
import hashlib
|
|
22
23
|
import json
|
|
23
24
|
import os
|
|
24
25
|
import re
|
|
@@ -79,8 +80,133 @@ def mode_marker_path(root=None, payload=None):
|
|
|
79
80
|
return os.path.join(root, ".codearbiter", ".markers", "mode")
|
|
80
81
|
|
|
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
|
+
|
|
82
199
|
def _read_mode_state(root=None, payload=None):
|
|
83
|
-
"""(state, diagnostic) off the mode marker
|
|
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.
|
|
84
210
|
|
|
85
211
|
`state` is the RAW dict mapping session_id -> whatever value was on disk
|
|
86
212
|
for it (validation of an individual session's value is `current_mode`'s
|
|
@@ -132,7 +258,21 @@ def current_mode(session_id, root=None, payload=None):
|
|
|
132
258
|
session's own recorded value is not a legal mode, that is reported the
|
|
133
259
|
same as a file-level unrecognized value (MODE_DIAG_UNRECOGNIZED) — a
|
|
134
260
|
garbage per-session entry is exactly as much an anomaly as a garbage
|
|
135
|
-
file, and must not be swallowed silently.
|
|
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
|
+
|
|
136
276
|
state, diag = _read_mode_state(root, payload)
|
|
137
277
|
if diag is not None:
|
|
138
278
|
return MODES[0], diag
|
|
@@ -144,60 +284,38 @@ def current_mode(session_id, root=None, payload=None):
|
|
|
144
284
|
return value, None
|
|
145
285
|
|
|
146
286
|
|
|
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
287
|
def write_mode(session_id, mode, root=None, payload=None):
|
|
156
|
-
"""Persist `mode` for `session_id` (T-08, AC-1).
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
201
319
|
|
|
202
320
|
|
|
203
321
|
# ---------------------------------------------------------------------------
|
|
@@ -89,9 +89,17 @@ AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "s
|
|
|
89
89
|
# from the H-11 set below leaves the append blocked, because classify_protected
|
|
90
90
|
# reports every class a path hits and pre-write checks them independently.
|
|
91
91
|
DECISION_LOG_BASENAME = "decision-log.md"
|
|
92
|
+
ADR_LIFECYCLE_LOG_BASENAME = "adr-lifecycle.jsonl"
|
|
93
|
+
DECISION_AUDIT_LOG_BASENAMES = (DECISION_LOG_BASENAME, ADR_LIFECYCLE_LOG_BASENAME)
|
|
94
|
+
DECISION_AUDIT_LOG_NAMES = "(?:" + "|".join(
|
|
95
|
+
re.escape(n) for n in DECISION_AUDIT_LOG_BASENAMES) + ")"
|
|
92
96
|
DECISION_LOG_RE = re.compile(
|
|
93
97
|
r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
|
|
94
98
|
)
|
|
99
|
+
ADR_LIFECYCLE_LOG_RE = re.compile(
|
|
100
|
+
r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(ADR_LIFECYCLE_LOG_BASENAME) + r"$",
|
|
101
|
+
re.I,
|
|
102
|
+
)
|
|
95
103
|
# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
|
|
96
104
|
# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
|
|
97
105
|
# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
|
|
@@ -99,7 +107,7 @@ DECISION_LOG_RE = re.compile(
|
|
|
99
107
|
# alternation below would sail past that pre-filter and leave the log deletable
|
|
100
108
|
# from the shell. (Caught by test_hook_guards.py, which the comment on that
|
|
101
109
|
# pre-filter predicted verbatim.)
|
|
102
|
-
AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES +
|
|
110
|
+
AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + DECISION_AUDIT_LOG_BASENAMES
|
|
103
111
|
AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
|
|
104
112
|
# The path anchor stays scoped to the FLAT logs — those sit directly under
|
|
105
113
|
# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
|
|
@@ -148,9 +156,10 @@ GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
|
|
|
148
156
|
def is_audit_log(rel):
|
|
149
157
|
"""True iff `rel` is one of the append-only .codearbiter audit logs
|
|
150
158
|
(overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
|
|
151
|
-
|
|
159
|
+
decision ledgers under decisions/ — the H-05 guard set."""
|
|
152
160
|
n = norm_path(rel)
|
|
153
|
-
return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n)
|
|
161
|
+
return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n)
|
|
162
|
+
or ADR_LIFECYCLE_LOG_RE.search(n))
|
|
154
163
|
|
|
155
164
|
|
|
156
165
|
def is_tail_append(current, old, new):
|
|
@@ -190,7 +199,7 @@ def is_decisions_path(rel):
|
|
|
190
199
|
cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
|
|
191
200
|
NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
|
|
192
201
|
n = norm_path(rel)
|
|
193
|
-
if DECISION_LOG_RE.search(n):
|
|
202
|
+
if DECISION_LOG_RE.search(n) or ADR_LIFECYCLE_LOG_RE.search(n):
|
|
194
203
|
return False
|
|
195
204
|
return bool(DECISIONS_PATH_RE.search(n))
|
|
196
205
|
|