@arbiterforge/ca-pi 0.6.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +74 -0
- package/plugins/ca-pi/COMMANDS.md +0 -7
- package/plugins/ca-pi/SKILLS.md +0 -2
- package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
- package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +28 -36
- package/plugins/ca-pi/extensions/codearbiter-child.js +2 -2
- package/plugins/ca-pi/extensions/codearbiter.js +766 -26
- package/plugins/ca-pi/generated/command-catalog.json +0 -10
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
- package/plugins/ca-pi/hooks/_bashguardlib.py +12 -1
- package/plugins/ca-pi/hooks/_hooklib.py +58 -17
- package/plugins/ca-pi/hooks/_metricslib.py +20 -0
- package/plugins/ca-pi/hooks/_modelib.py +644 -0
- package/plugins/ca-pi/hooks/_prunelib.py +51 -12
- package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
- package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
- package/plugins/ca-pi/hooks/doctor.py +2 -1
- package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
- package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
- package/plugins/ca-pi/hooks/session-start.py +526 -434
- package/plugins/ca-pi/hooks/statusline.py +27 -9
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
- package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
- package/plugins/ca-pi/includes/ops-mode.md +96 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +14 -3
- package/plugins/ca-pi/includes/redirect.md +12 -1
- package/plugins/ca-pi/includes/routing-table.md +1 -0
- package/plugins/ca-pi/includes/safety-core.md +86 -0
- package/plugins/ca-pi/includes/smarts/core.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +1 -1
- package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
- package/plugins/ca-pi/includes/dev-mode.md +0 -30
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
|
@@ -14,11 +14,6 @@
|
|
|
14
14
|
"description": "Report the health of Architecture Decision Records — aged, unchallenged, supersession candidates, unresolved CONFIRM-NN. Read-only.",
|
|
15
15
|
"skillPath": "skills/ca-adr-status/SKILL.md"
|
|
16
16
|
},
|
|
17
|
-
{
|
|
18
|
-
"name": "arbiter",
|
|
19
|
-
"description": "Exit maintainer dev mode — restore orchestration, remove the dev marker, log the exit.",
|
|
20
|
-
"skillPath": "skills/ca-arbiter/SKILL.md"
|
|
21
|
-
},
|
|
22
17
|
{
|
|
23
18
|
"name": "audit",
|
|
24
19
|
"description": "Assemble the governance record for a range — commits, overrides, ADRs, sprint auto-decisions, open questions, checkpoint findings — into one dated audit packet. Read-only.",
|
|
@@ -79,11 +74,6 @@
|
|
|
79
74
|
"description": "Greenfield decomposition interview — a layered interview that populates .codearbiter/ and locks it initialized.",
|
|
80
75
|
"skillPath": "skills/ca-decompose/SKILL.md"
|
|
81
76
|
},
|
|
82
|
-
{
|
|
83
|
-
"name": "dev",
|
|
84
|
-
"description": "Maintainer override — suspend orchestration to edit codeArbiter itself. Env-gated (CODEARBITER_DEV=1), entry/exit logged to overrides.log.",
|
|
85
|
-
"skillPath": "skills/ca-dev/SKILL.md"
|
|
86
|
-
},
|
|
87
77
|
{
|
|
88
78
|
"name": "doctor",
|
|
89
79
|
"description": "Verify the active host install, package, command ownership, enforcement, wrapper self-test, and active-dispatch coverage gap. Read-only.",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
# count_matches_text(text, pattern) -> int same count, given already-read text (performance-003)
|
|
28
28
|
# arbiter_state(root, count_in_flight=None, read_board=None, frontmatter_enabled=None,
|
|
29
29
|
# ctx_text=None, ot_text=None, oq_text=None) -> dict|None
|
|
30
|
-
#
|
|
30
|
+
# current_mode(session_id, root=None, payload=None) -> str one of _modelib.MODES
|
|
31
|
+
# (#437, mode-plane-deterministic-flip; replaces
|
|
32
|
+
# the retired dev_active(root) presence check)
|
|
31
33
|
#
|
|
32
34
|
# performance-003 (#194): SessionStart's main() already reads CONTEXT.md,
|
|
33
35
|
# open-tasks.md, and open-questions.md before the display-only governance line
|
|
@@ -42,6 +44,15 @@
|
|
|
42
44
|
import os
|
|
43
45
|
import re
|
|
44
46
|
|
|
47
|
+
# #437 (mode-plane-deterministic-flip, T-51): the mode plane replaces the old
|
|
48
|
+
# presence-only 'dev-active' marker this module used to read. Imported
|
|
49
|
+
# unguarded (like every other sibling import in this file) — statusline.py,
|
|
50
|
+
# this module's only external caller, already wraps `import _arbiterstatelib`
|
|
51
|
+
# in its own try/except, so a failure here degrades the whole module import,
|
|
52
|
+
# exactly like a failure to import `os` would. No circularity risk: _modelib
|
|
53
|
+
# imports _activationlib and _hooklib, neither of which imports this module.
|
|
54
|
+
from _modelib import current_mode as _modelib_current_mode
|
|
55
|
+
|
|
45
56
|
# mtime-keyed memo: statusline.py is a short-lived subprocess, but a single render
|
|
46
57
|
# can resolve arbiter_state more than once (safe() probes), and the StopHook fires
|
|
47
58
|
# the whole script on every tool-call completion. Caching on max(input mtime) makes
|
|
@@ -155,6 +166,22 @@ def arbiter_state(root, count_in_flight=None, read_board=None, frontmatter_enabl
|
|
|
155
166
|
return result
|
|
156
167
|
|
|
157
168
|
|
|
169
|
+
# AC-40: MODE: <name> enter|exit and legacy DEV: enter|exit rows are ledger
|
|
170
|
+
# bookkeeping (the #437 mode-plane audit trail), not overrides — they must not
|
|
171
|
+
# inflate the statusline's "over:" counter. Mode transitions are about to
|
|
172
|
+
# become routine traffic in overrides.log, so an uncorrected counter would
|
|
173
|
+
# turn from noisy into actively WRONG. Mirrors _metricslib._MODE_TRANSITION_RE's
|
|
174
|
+
# exact matched fragment (kept as an independent literal, not an import: this
|
|
175
|
+
# module has no other reason to depend on _metricslib, and one small regex
|
|
176
|
+
# fragment is cheaper to duplicate than to add a cross-module coupling for).
|
|
177
|
+
_MODE_TRANSITION_FRAGMENT = (
|
|
178
|
+
r"\|\s*(?:MODE:\s*\S+\s+(?:enter|exit)|DEV:\s*(?:enter|exit))\s*(?:\||$)"
|
|
179
|
+
)
|
|
180
|
+
_OVERRIDE_LINE_RE = (
|
|
181
|
+
r"^(?!\s*#)(?!\s*$)(?!.*" + _MODE_TRANSITION_FRAGMENT + r").+"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
158
185
|
def _arbiter_state_uncached(cad, count_in_flight=None, read_board=None, frontmatter_enabled=None,
|
|
159
186
|
ctx_text=None, ot_text=None, oq_text=None):
|
|
160
187
|
ctx_path = os.path.join(cad, "CONTEXT.md")
|
|
@@ -164,7 +191,7 @@ def _arbiter_state_uncached(cad, count_in_flight=None, read_board=None, frontmat
|
|
|
164
191
|
# open-questions.md text when supplied, instead of a second disk read. `None`
|
|
165
192
|
# (the default) preserves the exact original read-from-disk behavior.
|
|
166
193
|
fm = frontmatter_text(ctx_text) if ctx_text is not None else frontmatter(ctx_path)
|
|
167
|
-
total_over = count_matches(os.path.join(cad, "overrides.log"),
|
|
194
|
+
total_over = count_matches(os.path.join(cad, "overrides.log"), _OVERRIDE_LINE_RE)
|
|
168
195
|
# last-checkpoint holds the override COUNT at the last /ca:checkpoint. A value
|
|
169
196
|
# outside [0, total] is not a valid count (e.g. a timestamp from a stale writer)
|
|
170
197
|
# -> fail safe to 0 so overrides are surfaced, never silently hidden.
|
|
@@ -196,13 +223,34 @@ def _arbiter_state_uncached(cad, count_in_flight=None, read_board=None, frontmat
|
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
|
|
199
|
-
def
|
|
200
|
-
"""
|
|
201
|
-
|
|
226
|
+
def current_mode(session_id, root=None, payload=None):
|
|
227
|
+
"""The session's current orchestration posture — one of `_modelib.MODES`
|
|
228
|
+
('arbiter' | 'dangerous' | 'ops') — read off the mode plane (#437,
|
|
229
|
+
mode-plane-deterministic-flip). Replaces the retired `dev_active(root)`
|
|
230
|
+
presence check: the 'dev-active' marker is not dual-written (Decided
|
|
231
|
+
parameters: "Single source of truth" — every reader migrates to the mode
|
|
232
|
+
file), so a reader that kept checking for that marker would silently and
|
|
233
|
+
permanently report inactive the moment nothing writes it anymore.
|
|
234
|
+
|
|
235
|
+
Deliberately resolves through `_activationlib.marker_root` — via
|
|
236
|
+
`_modelib.current_mode`'s own `root=None` contract — rather than this
|
|
237
|
+
module's OTHER readers' pre-resolved `root` (arbiter_state's caller
|
|
238
|
+
passes a project root suitable for reading CONTEXT.md/overrides.log
|
|
239
|
+
in-place). `.codearbiter/.markers/` is gitignored, so a linked
|
|
240
|
+
worktree's own checkout never has a fresh copy of it, and every other
|
|
241
|
+
`.markers/` writer (security-pass.py, migration-pass.py, the mode flip
|
|
242
|
+
itself) already resolves through marker_root — the mode marker must
|
|
243
|
+
agree with them or a linked-worktree session silently reads the wrong
|
|
244
|
+
file (#604, AC-5).
|
|
245
|
+
|
|
246
|
+
Pass `payload` (the raw hook/status-line JSON) for production-correct
|
|
247
|
+
resolution; `root` is a test-only escape hatch that mirrors
|
|
248
|
+
`_modelib.current_mode`'s own contract exactly — an explicit root
|
|
249
|
+
bypasses payload/host resolution entirely and is read verbatim.
|
|
202
250
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return
|
|
251
|
+
Never raises: an absent/unreadable/malformed marker, or an unrecognized
|
|
252
|
+
per-session value, degrades to 'arbiter' — the diagnostic half of
|
|
253
|
+
`_modelib.current_mode`'s return is a maintainer/audit concern, not this
|
|
254
|
+
render-facing reader's; callers here need only the mode, never why."""
|
|
255
|
+
mode, _diag = _modelib_current_mode(session_id, root=root, payload=payload)
|
|
256
|
+
return mode
|
|
@@ -804,6 +804,11 @@ def git_cwd(cmd, root):
|
|
|
804
804
|
return acc
|
|
805
805
|
|
|
806
806
|
|
|
807
|
+
_CODEX_EXPLICIT_WORKDIR_TOOLS = frozenset({
|
|
808
|
+
"shell_command", "exec_command", "unified_exec",
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
|
|
807
812
|
def _effective_exec_root(payload, root):
|
|
808
813
|
"""The git root that a `-C`-less git command in THIS Bash call actually
|
|
809
814
|
runs against — the command's effective cwd — rather than always the
|
|
@@ -841,7 +846,13 @@ def _effective_exec_root(payload, root):
|
|
|
841
846
|
unchanged — the overwhelmingly common (non-worktree) case sees zero
|
|
842
847
|
behavioral difference. It returns the climbed root only when it names a
|
|
843
848
|
genuinely DIFFERENT filesystem location."""
|
|
844
|
-
|
|
849
|
+
data = payload if isinstance(payload, dict) else {}
|
|
850
|
+
tool_input = data.get("tool_input")
|
|
851
|
+
workdir = tool_input.get("workdir") if isinstance(tool_input, dict) else None
|
|
852
|
+
if (data.get("tool_name") in _CODEX_EXPLICIT_WORKDIR_TOOLS
|
|
853
|
+
and isinstance(workdir, str) and os.path.isdir(workdir)):
|
|
854
|
+
data = {"cwd": workdir}
|
|
855
|
+
exec_root = _gitlib.project_root(data)
|
|
845
856
|
if os.path.normpath(os.path.abspath(exec_root)) == os.path.normpath(os.path.abspath(root)):
|
|
846
857
|
return root
|
|
847
858
|
return exec_root
|
|
@@ -532,30 +532,69 @@ def warn(msg):
|
|
|
532
532
|
# active long-running flow's marker has sat around past `window_minutes` with
|
|
533
533
|
# no matching activity in its expected audit log.
|
|
534
534
|
#
|
|
535
|
-
# Only
|
|
536
|
-
# (.codearbiter/.markers/
|
|
537
|
-
# state _arbiterstatelib.
|
|
538
|
-
# is
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
535
|
+
# Only the mode plane and /sprint have a persistent "in-progress" marker
|
|
536
|
+
# today (.codearbiter/.markers/mode and .codearbiter/sprint-active — the same
|
|
537
|
+
# state _arbiterstatelib.current_mode()/arbiter_state() already read; the
|
|
538
|
+
# mode marker is #437's direct successor to the retired dev-active marker
|
|
539
|
+
# this comment originally described). /override is a single synchronous
|
|
540
|
+
# action (announce-then-log in one turn, per override.md) with no analogous
|
|
541
|
+
# "still in progress" marker anywhere in the framework, so per CONFIRM-09's
|
|
542
|
+
# own "do not invent new state" constraint it is not tracked here — there is
|
|
543
|
+
# no existing signal to detect it from.
|
|
542
544
|
#
|
|
543
|
-
# #271 C-5: this staleness WARN is presence + age based (marker
|
|
544
|
-
# audit-log write), which
|
|
545
|
-
# session-scoped CLEARING decision for the SAME dev-active
|
|
546
|
-
# consumers ask different questions ("has this sat around
|
|
547
|
-
# matching log activity?" vs. "am I sure enough this
|
|
548
|
-
# right now that I should force-close it?") and
|
|
549
|
-
# the other's answer.
|
|
550
|
-
#
|
|
551
|
-
#
|
|
545
|
+
# #271 C-5 (pre-#437): this staleness WARN is presence + age based (marker
|
|
546
|
+
# mtime vs. an audit-log write), which was unaffected by session-start.py's
|
|
547
|
+
# then-newer session-scoped CLEARING decision for the SAME dev-active
|
|
548
|
+
# marker — the two consumers ask different questions ("has this sat around
|
|
549
|
+
# too long with no matching log activity?" vs. "am I sure enough this
|
|
550
|
+
# belongs to nobody live right now that I should force-close it?") and
|
|
551
|
+
# neither needs to agree with the other's answer. The same reasoning now
|
|
552
|
+
# applies to the mode marker: a non-arbiter entry owned by a still-live
|
|
553
|
+
# different session can legitimately trip THIS warning even though
|
|
554
|
+
# SessionStart correctly declines to clobber a live session's own entry.
|
|
552
555
|
_STALE_FLOWS = (
|
|
553
556
|
# (flow name, marker path parts, expected-log path parts)
|
|
554
|
-
|
|
557
|
+
#
|
|
558
|
+
# #437 (mode-plane-deterministic-flip): the 'dev'/'dev-active' entry this
|
|
559
|
+
# tuple used to carry is RENAMED, not removed — the mode marker is the
|
|
560
|
+
# direct successor of dev-active (Decided parameters: dev-active is not
|
|
561
|
+
# dual-written; every reader migrates to the mode file). Missing this
|
|
562
|
+
# rename is the one hazard this whole registry exists to avoid: it is a
|
|
563
|
+
# WARN, not a gate, so a stale matcher fails PERMANENTLY SILENT with an
|
|
564
|
+
# otherwise green suite — nothing else in the repo would ever notice.
|
|
565
|
+
("mode", (".markers", "mode"), ("overrides.log",)),
|
|
555
566
|
("sprint", ("sprint-active",), ("sprint-log.md",)),
|
|
556
567
|
)
|
|
557
568
|
|
|
558
569
|
|
|
570
|
+
def _mode_marker_has_non_arbiter_entry(marker):
|
|
571
|
+
"""AC-36: True iff the mode marker JSON names at least one session in a
|
|
572
|
+
non-'arbiter' posture. Presence alone is NOT "active" for this flow the
|
|
573
|
+
way it was for the old boolean dev-active marker — the mode file is a
|
|
574
|
+
persistent `{session_id: mode}` map (#437's State: decided parameter)
|
|
575
|
+
that legitimately keeps existing, with plenty of purely-'arbiter'
|
|
576
|
+
entries, long after every non-arbiter session has flipped back or
|
|
577
|
+
ended. Warning on mere file presence would trip AC-36's negative arm
|
|
578
|
+
(never warn for arbiter) permanently, for every repo that has ever used
|
|
579
|
+
the mode plane at all — exactly the "warns on everything" matcher the
|
|
580
|
+
negative-arm assertion exists to catch.
|
|
581
|
+
|
|
582
|
+
Deliberately duplicates the literal 'arbiter' value here rather than
|
|
583
|
+
importing `_modelib.MODES[0]`: `_modelib` imports `write_text_atomic`
|
|
584
|
+
FROM this module, so importing back the other way would be circular.
|
|
585
|
+
Never raises: an absent/corrupt/malformed marker answers False — the
|
|
586
|
+
same fail-toward-silent convention `staleness_warning`'s own try/except
|
|
587
|
+
already applies to every other stat/read in this loop."""
|
|
588
|
+
try:
|
|
589
|
+
with open(marker, encoding="utf-8") as f:
|
|
590
|
+
data = json.load(f)
|
|
591
|
+
except Exception: # noqa: BLE001 — unreadable/corrupt -> nothing provably active
|
|
592
|
+
return False
|
|
593
|
+
if not isinstance(data, dict):
|
|
594
|
+
return False
|
|
595
|
+
return any(v != "arbiter" for v in data.values())
|
|
596
|
+
|
|
597
|
+
|
|
559
598
|
def staleness_warning(root, now=None, window_minutes=30):
|
|
560
599
|
"""(CONFIRM-09) One WARN message per active flow (see _STALE_FLOWS) whose
|
|
561
600
|
marker has existed for at least `window_minutes` with no audit-log
|
|
@@ -575,6 +614,8 @@ def staleness_warning(root, now=None, window_minutes=30):
|
|
|
575
614
|
marker = os.path.join(cad, *marker_parts)
|
|
576
615
|
if not os.path.isfile(marker):
|
|
577
616
|
continue
|
|
617
|
+
if name == "mode" and not _mode_marker_has_non_arbiter_entry(marker):
|
|
618
|
+
continue # AC-36: never warn for arbiter — presence alone isn't "active"
|
|
578
619
|
marker_mtime = os.path.getmtime(marker)
|
|
579
620
|
if now - marker_mtime < window_minutes * 60:
|
|
580
621
|
continue # flow started too recently to call it stale yet
|
|
@@ -247,6 +247,17 @@ def commit_timeline(root):
|
|
|
247
247
|
import re as _re
|
|
248
248
|
_OVERRIDE_TS_RE = _re.compile(r"^\[([^\]]+)\]")
|
|
249
249
|
|
|
250
|
+
# Mode-transition rows are ledger bookkeeping, not overrides, and must be
|
|
251
|
+
# excluded from override_rate (AC-40). Two forms appear in overrides.log:
|
|
252
|
+
# - new: "MODE: <name> enter" / "MODE: <name> exit"
|
|
253
|
+
# - legacy: "DEV: enter" / "DEV: exit"
|
|
254
|
+
# Both are matched as a pipe-delimited field (bounded by "|" or end-of-line),
|
|
255
|
+
# the same shape as _SMALL_LANE_RE's field match below, so a REASON/NOTE
|
|
256
|
+
# sentence that merely mentions "mode" or "dev" cannot false-positive.
|
|
257
|
+
_MODE_TRANSITION_RE = _re.compile(
|
|
258
|
+
r"\|\s*(?:MODE:\s*\S+\s+(?:enter|exit)|DEV:\s*(?:enter|exit))\s*(?:\||$)"
|
|
259
|
+
)
|
|
260
|
+
|
|
250
261
|
|
|
251
262
|
def override_rate(lines_or_text, windows):
|
|
252
263
|
"""Compute the override rate for the current and prior windows.
|
|
@@ -274,6 +285,10 @@ def override_rate(lines_or_text, windows):
|
|
|
274
285
|
- Lines that start with "#" (after stripping leading whitespace) are
|
|
275
286
|
comment lines and are excluded.
|
|
276
287
|
- Blank and whitespace-only lines are excluded.
|
|
288
|
+
- Mode-transition rows are excluded: the new form
|
|
289
|
+
"MODE: <name> enter|exit" and the legacy form "DEV: enter|exit"
|
|
290
|
+
(AC-40). These are ledger bookkeeping, not overrides — genuine
|
|
291
|
+
"GATE:" / "SECURITY-OVERRIDE" / other override rows still count.
|
|
277
292
|
- Entries whose parsed timestamp maps to BEFORE_HISTORY are excluded
|
|
278
293
|
from all window counts.
|
|
279
294
|
"""
|
|
@@ -300,6 +315,11 @@ def override_rate(lines_or_text, windows):
|
|
|
300
315
|
if not line or line.startswith("#"):
|
|
301
316
|
continue
|
|
302
317
|
|
|
318
|
+
# Skip mode-transition rows (new MODE: and legacy DEV: forms) — see
|
|
319
|
+
# AC-40 and _MODE_TRANSITION_RE above.
|
|
320
|
+
if _MODE_TRANSITION_RE.search(line):
|
|
321
|
+
continue
|
|
322
|
+
|
|
303
323
|
# Extract the leading timestamp token "[<ts>]".
|
|
304
324
|
m = _OVERRIDE_TS_RE.match(line)
|
|
305
325
|
if not m:
|