@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.
Files changed (46) hide show
  1. package/README.md +29 -90
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +89 -0
  4. package/plugins/ca-pi/COMMANDS.md +141 -64
  5. package/plugins/ca-pi/SKILLS.md +137 -28
  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/finding-triage.md +31 -14
  9. package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
  10. package/plugins/ca-pi/arbiter.md +12 -3
  11. package/plugins/ca-pi/extensions/codearbiter.js +137 -15
  12. package/plugins/ca-pi/generated/command-catalog.json +386 -186
  13. package/plugins/ca-pi/generated/roles.json +9 -0
  14. package/plugins/ca-pi/hooks/_bashguardlib.py +33 -16
  15. package/plugins/ca-pi/hooks/_gitexec.py +23 -0
  16. package/plugins/ca-pi/hooks/_githooks.py +50 -23
  17. package/plugins/ca-pi/hooks/_hooklib.py +94 -7
  18. package/plugins/ca-pi/hooks/_host.py +9 -1
  19. package/plugins/ca-pi/hooks/_modelib.py +173 -55
  20. package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
  21. package/plugins/ca-pi/hooks/_releaselib.py +278 -48
  22. package/plugins/ca-pi/hooks/_updatelib.py +230 -50
  23. package/plugins/ca-pi/hooks/doctor.py +56 -8
  24. package/plugins/ca-pi/hooks/git-enforce.py +10 -3
  25. package/plugins/ca-pi/hooks/hostapi.py +220 -22
  26. package/plugins/ca-pi/hooks/session-start.py +8 -6
  27. package/plugins/ca-pi/hooks/statusline.py +1 -1
  28. package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
  29. package/plugins/ca-pi/includes/command-compatibility.md +16 -0
  30. package/plugins/ca-pi/includes/routing-table.md +13 -5
  31. package/plugins/ca-pi/routines/INDEX.md +1 -1
  32. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +54 -2
  33. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
  34. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
  35. package/plugins/ca-pi/routines/release/SKILL.md +1 -1
  36. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
  37. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
  38. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
  39. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
  40. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
  41. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
  42. package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
  43. package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
  44. package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
  45. package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
  46. 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 file.
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
- 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
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 + (DECISION_LOG_BASENAME,)
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
- arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
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