@arbiterforge/ca-pi 0.6.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +18 -14
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +68 -0
  4. package/plugins/ca-pi/COMMANDS.md +0 -7
  5. package/plugins/ca-pi/SKILLS.md +0 -2
  6. package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
  7. package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +28 -36
  8. package/plugins/ca-pi/extensions/codearbiter.js +758 -18
  9. package/plugins/ca-pi/generated/command-catalog.json +0 -10
  10. package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
  11. package/plugins/ca-pi/hooks/_bashguardlib.py +12 -1
  12. package/plugins/ca-pi/hooks/_hooklib.py +58 -17
  13. package/plugins/ca-pi/hooks/_metricslib.py +20 -0
  14. package/plugins/ca-pi/hooks/_modelib.py +644 -0
  15. package/plugins/ca-pi/hooks/_prunelib.py +51 -12
  16. package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
  17. package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
  18. package/plugins/ca-pi/hooks/doctor.py +2 -1
  19. package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
  20. package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
  21. package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
  22. package/plugins/ca-pi/hooks/session-start.py +526 -434
  23. package/plugins/ca-pi/hooks/statusline.py +27 -9
  24. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
  25. package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
  26. package/plugins/ca-pi/includes/ops-mode.md +96 -0
  27. package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
  28. package/plugins/ca-pi/includes/redirect.md +12 -1
  29. package/plugins/ca-pi/includes/routing-table.md +1 -0
  30. package/plugins/ca-pi/includes/safety-core.md +86 -0
  31. package/plugins/ca-pi/includes/smarts/core.md +1 -1
  32. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +1 -1
  33. package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
  34. package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
  35. package/plugins/ca-pi/includes/dev-mode.md +0 -30
  36. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
  37. 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
- # dev_active(root) -> bool True when the /dev marker is present
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"), r"^(?!\s*#)(?!\s*$).+")
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 dev_active(root):
200
- """True when /dev developer-override mode is on signalled by a transient marker
201
- the orchestrator drops on /dev and clears on /arbiter (a local UI flag, not a log).
226
+ def current_mode(session_id, root=None, payload=None):
227
+ """The session's current orchestration postureone 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
- Presence-only by design, unaffected by #271 C-5's session-scoped clearing:
204
- the marker still means "dev mode is on for SOMEONE" regardless of which
205
- session owns it. Session-scoping only changes WHEN SessionStart is willing
206
- to clear a live marker (never a different, possibly still-live session's
207
- own marker) it does not change what "present" means to this reader."""
208
- return os.path.exists(os.path.join(root, ".codearbiter", ".markers", "dev-active"))
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
- exec_root = _gitlib.project_root(payload if isinstance(payload, dict) else {})
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 /dev and /sprint have a persistent "in-progress" marker today
536
- # (.codearbiter/.markers/dev-active and .codearbiter/sprint-active — the same
537
- # state _arbiterstatelib.dev_active()/arbiter_state() already read). /override
538
- # is a single synchronous action (announce-then-log in one turn, per
539
- # override.md) with no analogous "still in progress" marker anywhere in the
540
- # framework, so per CONFIRM-09's own "do not invent new state" constraint it
541
- # is not tracked here there is no existing signal to detect it from.
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 mtime vs. an
544
- # audit-log write), which is unaffected by session-start.py's newer
545
- # session-scoped CLEARING decision for the SAME dev-active marker — the two
546
- # consumers ask different questions ("has this sat around too long with no
547
- # matching log activity?" vs. "am I sure enough this belongs to nobody live
548
- # right now that I should force-close it?") and neither needs to agree with
549
- # the other's answer. A dev marker owned by a still-live different session
550
- # can legitimately trip THIS warning (it really has been open a while) even
551
- # though session-start.py correctly declines to clobber it.
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
- ("dev", (".markers", "dev-active"), ("overrides.log",)),
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: