@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
@@ -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
@@ -81,7 +81,8 @@ import subprocess
81
81
  import sys
82
82
 
83
83
  from _hooklib import (
84
- AUDIT_LOG_BASENAMES, AUDIT_LOG_NAMES, CRYPTO_RE, DECISION_LOG_BASENAME, DECISIONS_DIR_RE,
84
+ ADR_LIFECYCLE_LOG_BASENAME, AUDIT_LOG_BASENAMES, AUDIT_LOG_FLAT_BASENAMES,
85
+ AUDIT_LOG_NAMES, CRYPTO_RE, DECISION_AUDIT_LOG_NAMES, DECISION_LOG_BASENAME, DECISIONS_DIR_RE,
85
86
  GATE_MARKER_NAMES, MARKER_FRESHNESS_MINUTES, SECRET_RE, SECURITY_DIFF_GIT_ARGS, block,
86
87
  content_digest, is_migration_path, line_digest, marker_fresh, sensitive_scan_added_lines,
87
88
  )
@@ -317,7 +318,7 @@ PROTECTED_DEST_RE = re.compile(r"(?:\S+:|:)?(?:refs/heads/)?(?:main|master)")
317
318
  # (the /sprint decision record). The bare-name alternation is centralized in
318
319
  # _hooklib.AUDIT_LOG_NAMES so the Write/Edit and shell flanks never drift.
319
320
  LOG_NAMES = AUDIT_LOG_NAMES
320
- LOG_TRUNC_RE = re.compile(r"(?<!>)>(?!>)\|?\s*\S*" + LOG_NAMES)
321
+ LOG_TRUNC_RE = re.compile(r"(?<!>)>(?!>)\|?\s*\S*" + LOG_NAMES, re.I)
321
322
  LOG_DESTROY_RE = re.compile(
322
323
  r"\b(rm|del|mv|cp|copy|dd|tee|sed|truncate|sponge"
323
324
  # #528: `New-Item -Force` TRUNCATES an existing file (verified in PowerShell:
@@ -345,20 +346,20 @@ DECISIONS = DECISIONS_DIR_RE + r"\b"
345
346
  # #528: the one path under decisions/ that H-11 must NOT claim — see
346
347
  # _check_h11_decisions. Matched on the raw command, so both separators.
347
348
  #
348
- # DELIBERATELY CASE-SENSITIVE. H-05, which takes over for this file, is itself
349
- # case-sensitive on both flanks: _check_h05_audit_log pre-filters with a plain
350
- # `in` test over AUDIT_LOG_BASENAMES, and LOG_TRUNC_RE carries no re.I. An re.I
351
- # here therefore stripped `Decision-Log.md` out of H-11's view and handed it to a
352
- # guard that could not see it — and on Windows/NTFS and default macOS/APFS that
353
- # spelling resolves to the real file, so `rm …/Decision-Log.md` destroyed the
354
- # append-only log with nothing firing at all. The two flanks must agree on case.
355
- #
356
349
  # The right edge is anchored so this path cannot SHIELD a sibling token: without
357
350
  # it, `touch …/decision-log.md.evil.md` was stripped to a harmless remainder and
358
351
  # H-11 stopped seeing a decisions/ write at all.
359
352
  DECISION_LOG_SHELL_RE = re.compile(
360
353
  DECISIONS_DIR_RE + r"[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"""(?=$|[\s>|;&"'])""",
361
354
  )
355
+ # The lifecycle ledger is new and explicitly enrolled case-insensitively on
356
+ # supported case-folding filesystems. Keep this separate from decision-log.md:
357
+ # historical mixed-case decision-log spellings remain H-11, as #528 requires.
358
+ ADR_LIFECYCLE_LOG_SHELL_RE = re.compile(
359
+ DECISIONS_DIR_RE + r"[\\/]+" + re.escape(ADR_LIFECYCLE_LOG_BASENAME) +
360
+ r"""(?=$|[\s>|;&"'])""",
361
+ re.I,
362
+ )
362
363
  # `>>?\|?` covers `>`, `>>`, and the `>|` force-clobber form into decisions/.
363
364
  DECISIONS_REDIRECT_RE = re.compile(r">>?\|?\s*\S*" + DECISIONS, re.I)
364
365
  DECISIONS_WRITE_RE = re.compile(
@@ -804,6 +805,12 @@ def git_cwd(cmd, root):
804
805
  return acc
805
806
 
806
807
 
808
+ _CODEX_EXPLICIT_WORKDIR_TOOLS = frozenset({
809
+ "shell_command", "exec_command", "unified_exec",
810
+ "functions.exec",
811
+ })
812
+
813
+
807
814
  def _effective_exec_root(payload, root):
808
815
  """The git root that a `-C`-less git command in THIS Bash call actually
809
816
  runs against — the command's effective cwd — rather than always the
@@ -841,7 +848,13 @@ def _effective_exec_root(payload, root):
841
848
  unchanged — the overwhelmingly common (non-worktree) case sees zero
842
849
  behavioral difference. It returns the climbed root only when it names a
843
850
  genuinely DIFFERENT filesystem location."""
844
- exec_root = _gitlib.project_root(payload if isinstance(payload, dict) else {})
851
+ data = payload if isinstance(payload, dict) else {}
852
+ tool_input = data.get("tool_input")
853
+ workdir = tool_input.get("workdir") if isinstance(tool_input, dict) else None
854
+ if (data.get("tool_name") in _CODEX_EXPLICIT_WORKDIR_TOOLS
855
+ and isinstance(workdir, str) and os.path.isdir(workdir)):
856
+ data = {"cwd": workdir}
857
+ exec_root = _gitlib.project_root(data)
845
858
  if os.path.normpath(os.path.abspath(exec_root)) == os.path.normpath(os.path.abspath(root)):
846
859
  return root
847
860
  return exec_root
@@ -1329,7 +1342,11 @@ def _check_h05_audit_log(cmd):
1329
1342
  # (`python3 -c "open('.codearbiter/overrides.log','w')..."`) — the
1330
1343
  # verb-list and redirect legs above never look for an interpreter token
1331
1344
  # at all, so this shape walked past both.
1332
- if any(n in cmd for n in AUDIT_LOG_BASENAMES) and (
1345
+ folded = cmd.casefold()
1346
+ legacy_names = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
1347
+ enrolled_name = (any(n in cmd for n in legacy_names) or
1348
+ ADR_LIFECYCLE_LOG_BASENAME.casefold() in folded)
1349
+ if enrolled_name and (
1333
1350
  LOG_TRUNC_RE.search(cmd) or LOG_DESTROY_RE.search(cmd)
1334
1351
  or LOG_GIT_RESTORE_RE.search(cmd) or LOG_INTERP_RE.search(cmd)):
1335
1352
  block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, "
@@ -1356,6 +1373,7 @@ def _check_h11_decisions(cmd):
1356
1373
  # append to decisions/decision-log.md stays the #528 carve-out's to
1357
1374
  # police (via H-05's own LOG_INTERP_RE), not a false H-11 block.
1358
1375
  cmd = DECISION_LOG_SHELL_RE.sub(" ", cmd)
1376
+ cmd = ADR_LIFECYCLE_LOG_SHELL_RE.sub(" ", cmd)
1359
1377
  if (DECISIONS_REDIRECT_RE.search(cmd) or DECISIONS_WRITE_RE.search(cmd)
1360
1378
  or DECISIONS_INTERP_RE.search(cmd)):
1361
1379
  block("H-11", "ADR files under .codearbiter/decisions/ are authored only via "
@@ -20,6 +20,29 @@ import os
20
20
  GIT_ENV = "CODEARBITER_GIT_EXECUTABLE"
21
21
  PYTHON_ENV = "CODEARBITER_PYTHON_EXECUTABLE"
22
22
 
23
+ _ROOT_OVERRIDE_ENV = frozenset({
24
+ # `git rev-parse --local-env-vars` repository/object selectors.
25
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_OBJECT_DIRECTORY",
26
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE",
27
+ "GIT_GRAFT_FILE", "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS",
28
+ "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE",
29
+ "GIT_COMMON_DIR",
30
+ # Discovery indirection can also override an exact root-bound probe.
31
+ "GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM",
32
+ })
33
+
34
+
35
+ def root_bound_git_env():
36
+ """Environment for selected Git to describe an explicit checkout root.
37
+
38
+ Protected configuration such as safe.directory remains authoritative; only
39
+ repository, object, and discovery selectors that override `-C` are removed.
40
+ """
41
+ env = os.environ.copy()
42
+ for name in _ROOT_OVERRIDE_ENV:
43
+ env.pop(name, None)
44
+ return env
45
+
23
46
 
24
47
  def _trusted_environment_path(name):
25
48
  value = os.environ.get(name)
@@ -62,8 +62,8 @@
62
62
  # * Idempotent: an up-to-date ours-hook is left untouched (no churn); a stale
63
63
  # ours-hook is refreshed.
64
64
  # * performance-002 (#194): re-resolving hooks_dir() every SessionStart costs
65
- # up to two blocking `git` subprocess spawns (config --get core.hooksPath,
66
- # rev-parse --git-path hooks) even on the common steady-state call where
65
+ # one blocking `git` subprocess spawn (`rev-parse --git-path hooks`) even
66
+ # on the common steady-state call where
67
67
  # nothing changed. install() first checks a cheap on-disk cache (a single
68
68
  # small file read, no git spawn) recording the hooks_dir a prior successful
69
69
  # resolution used; if BOTH phase shims at that cached location already
@@ -114,7 +114,8 @@ import sys
114
114
 
115
115
  import _hooklib
116
116
  from _durabilitylib import is_ephemeral_path
117
- from _gitexec import git_executable, trusted_git_executable, trusted_python_executable
117
+ from _gitexec import (git_executable, root_bound_git_env,
118
+ trusted_git_executable, trusted_python_executable)
118
119
 
119
120
  SENTINEL = (
120
121
  "# codeArbiter-managed git hook (#161) — this SHIM is refreshed by any live "
@@ -140,6 +141,7 @@ def _git(args, cwd):
140
141
  return subprocess.run(
141
142
  [git_executable()] + args, cwd=cwd, capture_output=True, text=True,
142
143
  encoding="utf-8", errors="replace", timeout=5,
144
+ env=root_bound_git_env(),
143
145
  )
144
146
  except Exception: # noqa: BLE001
145
147
  return None
@@ -148,20 +150,15 @@ def _git(args, cwd):
148
150
  def hooks_dir(root):
149
151
  """The directory git actually reads hooks from for `root`, or None.
150
152
 
151
- Honors core.hooksPath (when set, git IGNORES .git/hooks entirely), and
152
- resolves the real git dir via `rev-parse --git-path hooks` so linked
153
- worktrees and submodules land in the right place. Falls back to
154
- <root>/.git/hooks only if git can't answer."""
155
- cfg = _git(["config", "--get", "core.hooksPath"], root)
156
- if cfg is not None and cfg.returncode == 0 and cfg.stdout.strip():
157
- hp = cfg.stdout.strip()
158
- return hp if os.path.isabs(hp) else os.path.join(root, hp)
153
+ The selected Git binary owns core.hooksPath parsing, including its path
154
+ grammar (`~`, `%(prefix)`, absolute, and relative forms), and linked
155
+ worktree/submodule semantics. Python must not reinterpret the raw config
156
+ value differently from the binary that will execute the hooks."""
159
157
  gp = _git(["rev-parse", "--git-path", "hooks"], root)
160
158
  if gp is not None and gp.returncode == 0 and gp.stdout.strip():
161
159
  hp = gp.stdout.strip()
162
160
  return hp if os.path.isabs(hp) else os.path.join(root, hp)
163
- default = os.path.join(root, ".git", "hooks")
164
- return default if os.path.isdir(os.path.join(root, ".git")) else None
161
+ return None
165
162
 
166
163
 
167
164
  def _enforcer_path():
@@ -213,13 +210,15 @@ def _git_common_dir(root):
213
210
  repo must resolve to the SAME common dir here, or the #265 drop-in dir
214
211
  would fork per-worktree and defeat the entire cross-host purpose (a shim
215
212
  installed from worktree A would never see an entry written from
216
- worktree B). The main-repo case (`.git` is a directory) needs no spawn at
217
- all: it IS its own common dir. Returns None if nothing resolves — callers
218
- must treat that as "can't place the drop-in dir right now" and never
219
- invent a per-worktree fallback."""
213
+ worktree B). The returned path is canonical so equivalent symlink, macOS
214
+ `/var`, and Windows short-name spellings produce the same registry path in
215
+ every managed shim. The main-repo case (`.git` is a directory) needs no
216
+ spawn at all: it IS its own common dir. Returns None if nothing resolves —
217
+ callers must treat that as "can't place the drop-in dir right now" and
218
+ never invent a per-worktree fallback."""
220
219
  git_path = os.path.join(root, ".git")
221
220
  if os.path.isdir(git_path):
222
- return os.path.abspath(git_path)
221
+ return os.path.realpath(git_path)
223
222
  if os.path.isfile(git_path):
224
223
  text = _read(git_path)
225
224
  if text:
@@ -234,12 +233,13 @@ def _git_common_dir(root):
234
233
  cd = cd_text.strip()
235
234
  common = (cd if os.path.isabs(cd)
236
235
  else os.path.normpath(os.path.join(wt_gitdir, cd)))
237
- return os.path.abspath(common)
236
+ return os.path.realpath(common)
238
237
  break
239
238
  r = _git(["rev-parse", "--git-common-dir"], root)
240
239
  if r is not None and r.returncode == 0 and r.stdout.strip():
241
240
  out = r.stdout.strip()
242
- return os.path.abspath(out if os.path.isabs(out) else os.path.join(root, out))
241
+ return os.path.realpath(
242
+ out if os.path.isabs(out) else os.path.join(root, out))
243
243
  return None
244
244
 
245
245
 
@@ -370,8 +370,9 @@ def _touch_seen_marker(dropin_dir, plugin, enforcer):
370
370
  # SENTINEL) session bakes in. `/ca:doctor` runs the SAME text as a real
371
371
  # subprocess instead of a parallel port, so the two can never drift.
372
372
  #
373
- # Algorithm: a plugin's registered entry is "stale" printed to stdout, one
374
- # per line iff (a) at least one OTHER registered entry in the same
373
+ # Algorithm: first discard entries whose registered enforcer is not a live
374
+ # regular file. A remaining plugin's registered entry is "stale" printed to
375
+ # stdout, one per line — iff (a) at least one OTHER live registered entry in the same
375
376
  # drop-in dir has recorded a `.seen` heartbeat, AND (b) this plugin's own
376
377
  # heartbeat is either absent or strictly older than the freshest one seen.
377
378
  # When NOBODY has ever recorded a heartbeat (a repo that predates #556, or
@@ -401,7 +402,7 @@ _FRESHNESS_PY = (
401
402
  " if legacy.fullmatch(plugin):\n"
402
403
  " continue\n"
403
404
  " path_val = _rd(os.path.join(d, n))\n"
404
- " if path_val is None:\n"
405
+ " if not path_val or not os.path.isfile(path_val):\n"
405
406
  " continue\n"
406
407
  " seen_file = os.path.join(d, plugin + '.seen')\n"
407
408
  # `.seen` only counts as a confirmation of what's registered RIGHT NOW when
@@ -455,6 +456,32 @@ def stale_registered_plugins(dropin_dir):
455
456
  return [ln for ln in r.stdout.splitlines() if ln.strip()]
456
457
 
457
458
 
459
+ def live_registered_plugins(dropin_dir):
460
+ """Stable plugin names whose current `.path` target is a regular file.
461
+
462
+ This deliberately uses the same liveness boundary as the generated shim
463
+ (`[ -f "$E" ]`) and the freshness probe above. It is diagnostic support
464
+ for doctor, not a second freshness implementation."""
465
+ if not dropin_dir or not os.path.isdir(dropin_dir):
466
+ return []
467
+ legacy = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
468
+ live = []
469
+ try:
470
+ names = sorted(os.listdir(dropin_dir))
471
+ except OSError:
472
+ return []
473
+ for name in names:
474
+ if not name.endswith(".path"):
475
+ continue
476
+ plugin = name[:-len(".path")]
477
+ if legacy.fullmatch(plugin):
478
+ continue
479
+ path_val = _read(os.path.join(dropin_dir, name))
480
+ if path_val and os.path.isfile(path_val.strip()):
481
+ live.append(plugin)
482
+ return live
483
+
484
+
458
485
  _TRUSTED_IDENTITY_FILE = "trusted-executables.identity"
459
486
 
460
487
 
@@ -186,7 +186,10 @@ from _protectedlib import ( # noqa: F401
186
186
  DECISIONS_DIR_RE,
187
187
  DECISIONS_PATH_RE,
188
188
  DECISION_LOG_BASENAME,
189
+ ADR_LIFECYCLE_LOG_BASENAME,
190
+ DECISION_AUDIT_LOG_NAMES,
189
191
  DECISION_LOG_RE,
192
+ ADR_LIFECYCLE_LOG_RE,
190
193
  GATE_MARKER_NAMES,
191
194
  MARKERS_RE,
192
195
  classify_protected,
@@ -532,30 +535,149 @@ def warn(msg):
532
535
  # active long-running flow's marker has sat around past `window_minutes` with
533
536
  # no matching activity in its expected audit log.
534
537
  #
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.
538
+ # Only the mode plane and /sprint have a persistent "in-progress" marker
539
+ # today (.codearbiter/.markers/mode.d/ and .codearbiter/sprint-active — the same
540
+ # state _arbiterstatelib.current_mode()/arbiter_state() already read; the
541
+ # mode marker is #437's direct successor to the retired dev-active marker
542
+ # this comment originally described). /override is a single synchronous
543
+ # action (announce-then-log in one turn, per override.md) with no analogous
544
+ # "still in progress" marker anywhere in the framework, so per CONFIRM-09's
545
+ # own "do not invent new state" constraint it is not tracked here — there is
546
+ # no existing signal to detect it from.
542
547
  #
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.
548
+ # #271 C-5 (pre-#437): this staleness WARN is presence + age based (marker
549
+ # mtime vs. an audit-log write), which was unaffected by session-start.py's
550
+ # then-newer session-scoped CLEARING decision for the SAME dev-active
551
+ # marker — the two consumers ask different questions ("has this sat around
552
+ # too long with no matching log activity?" vs. "am I sure enough this
553
+ # belongs to nobody live right now that I should force-close it?") and
554
+ # neither needs to agree with the other's answer. The same reasoning now
555
+ # applies to the mode marker: a non-arbiter entry owned by a still-live
556
+ # different session can legitimately trip THIS warning even though
557
+ # SessionStart correctly declines to clobber a live session's own entry.
552
558
  _STALE_FLOWS = (
553
559
  # (flow name, marker path parts, expected-log path parts)
554
- ("dev", (".markers", "dev-active"), ("overrides.log",)),
560
+ #
561
+ # #437 (mode-plane-deterministic-flip): the 'dev'/'dev-active' entry this
562
+ # tuple used to carry is RENAMED, not removed — the mode marker is the
563
+ # direct successor of dev-active (Decided parameters: dev-active is not
564
+ # dual-written; every reader migrates to the mode file). Missing this
565
+ # rename is the one hazard this whole registry exists to avoid: it is a
566
+ # WARN, not a gate, so a stale matcher fails PERMANENTLY SILENT with an
567
+ # otherwise green suite — nothing else in the repo would ever notice.
568
+ #
569
+ # #681 moved the target again, from that one file to the per-session entry
570
+ # DIRECTORY, for the same reason: pointed at `mode`, this row would have
571
+ # kept matching a file nothing writes any more and gone silent exactly as
572
+ # the note above warns. This row's marker is therefore a directory, and
573
+ # `_mode_plane_active_since` — not `os.path.isfile` + `getmtime` — answers
574
+ # both "is anything active" and "since when" for it.
575
+ ("mode", (".markers", "mode.d"), ("overrides.log",)),
555
576
  ("sprint", ("sprint-active",), ("sprint-log.md",)),
556
577
  )
557
578
 
558
579
 
580
+ def _mode_marker_has_non_arbiter_entry(marker):
581
+ """AC-36: True iff the mode marker JSON names at least one session in a
582
+ non-'arbiter' posture. Presence alone is NOT "active" for this flow the
583
+ way it was for the old boolean dev-active marker — the mode file is a
584
+ persistent `{session_id: mode}` map (#437's State: decided parameter)
585
+ that legitimately keeps existing, with plenty of purely-'arbiter'
586
+ entries, long after every non-arbiter session has flipped back or
587
+ ended. Warning on mere file presence would trip AC-36's negative arm
588
+ (never warn for arbiter) permanently, for every repo that has ever used
589
+ the mode plane at all — exactly the "warns on everything" matcher the
590
+ negative-arm assertion exists to catch.
591
+
592
+ Deliberately duplicates the literal 'arbiter' value here rather than
593
+ importing `_modelib.MODES[0]`: `_modelib` imports `write_text_atomic`
594
+ FROM this module, so importing back the other way would be circular.
595
+ Never raises: an absent/corrupt/malformed marker answers False — the
596
+ same fail-toward-silent convention `staleness_warning`'s own try/except
597
+ already applies to every other stat/read in this loop."""
598
+ try:
599
+ with open(marker, encoding="utf-8") as f:
600
+ data = json.load(f)
601
+ except Exception: # noqa: BLE001 — unreadable/corrupt -> nothing provably active
602
+ return False
603
+ if not isinstance(data, dict):
604
+ return False
605
+ return any(v != "arbiter" for v in data.values())
606
+
607
+
608
+ # Worst-case bound on the entry scan below: the all-arbiter case, where the
609
+ # newest-first early exit never fires and every entry would otherwise be read
610
+ # on every prompt. 64 is far above any plausible count of sessions live at once
611
+ # and far below the unbounded total a long-lived repo accumulates. Capping
612
+ # costs only a WARN — a flow older than the 64 most recently touched sessions
613
+ # goes unreported, which this registry's own contract already tolerates
614
+ # ("a missed warn is acceptable; a crash is not", prune-transcript.py).
615
+ _MODE_ENTRY_SCAN_MAX = 64
616
+
617
+
618
+ def _mode_plane_active_since(cad):
619
+ """(#681) Newest mtime among per-session mode entries in a NON-arbiter
620
+ posture, or None when the mode plane is not active.
621
+
622
+ Replaces the single marker file's stat for this flow. The plane is now a
623
+ directory of one-file-per-session entries (`_modelib.mode_entry_dir`),
624
+ which changes both halves of the question this registry asks:
625
+
626
+ - *Active* is still "some session is non-arbiter", but it is now answered
627
+ per entry rather than over one map's values.
628
+ - *Since when* gets strictly more accurate. The shared map's mtime was
629
+ bumped by ANY session's write, so one arbiter session flipping reset the
630
+ staleness clock for a different session sitting in `dangerous`. An
631
+ entry's own mtime is that session's own last flip.
632
+
633
+ Bounded: entries are examined newest-first and the scan stops at the first
634
+ non-arbiter one (which is by definition the newest), with a hard
635
+ `_MODE_ENTRY_SCAN_MAX` ceiling for the all-arbiter case.
636
+
637
+ Falls back to the pre-split map so a repo upgraded mid-flow keeps warning.
638
+ Never raises: this whole registry is a WARN, and a matcher that stopped
639
+ matching would fail permanently SILENT with a green suite — the exact
640
+ hazard `_STALE_FLOWS`' own comment names.
641
+
642
+ Duplicates the literal 'arbiter' for the same reason
643
+ `_mode_marker_has_non_arbiter_entry` does: `_modelib` imports
644
+ `write_text_atomic` from this module, so importing back would be circular.
645
+ """
646
+ entries = []
647
+ entry_dir = os.path.join(cad, ".markers", "mode.d")
648
+ try:
649
+ with os.scandir(entry_dir) as it:
650
+ for item in it:
651
+ try:
652
+ entries.append((item.stat().st_mtime, item.path))
653
+ except OSError:
654
+ continue
655
+ except OSError: # no directory yet -> no per-session entries to weigh
656
+ entries = []
657
+ # NEWEST FIRST, and stop at the first non-arbiter entry: that entry IS the
658
+ # newest non-arbiter mtime, so the ordering turns "read every entry" into
659
+ # "usually read one". This runs on the prompt seam — `prune-transcript.py`
660
+ # calls `staleness_warning` on every UserPromptSubmit — and entries are
661
+ # never reaped, so an unordered full scan would make every prompt pay for
662
+ # every session the repo has ever had.
663
+ entries.sort(reverse=True)
664
+ for mtime, path in entries[:_MODE_ENTRY_SCAN_MAX]:
665
+ try:
666
+ with open(path, encoding="utf-8") as f:
667
+ data = json.load(f)
668
+ except Exception: # noqa: BLE001 — unreadable entry proves nothing active
669
+ continue
670
+ if isinstance(data, dict) and data.get("mode") not in (None, "arbiter"):
671
+ return mtime
672
+ legacy = os.path.join(cad, ".markers", "mode")
673
+ if os.path.isfile(legacy) and _mode_marker_has_non_arbiter_entry(legacy):
674
+ try:
675
+ return os.path.getmtime(legacy)
676
+ except OSError:
677
+ return None
678
+ return None
679
+
680
+
559
681
  def staleness_warning(root, now=None, window_minutes=30):
560
682
  """(CONFIRM-09) One WARN message per active flow (see _STALE_FLOWS) whose
561
683
  marker has existed for at least `window_minutes` with no audit-log
@@ -573,9 +695,15 @@ def staleness_warning(root, now=None, window_minutes=30):
573
695
  for name, marker_parts, log_parts in _STALE_FLOWS:
574
696
  try:
575
697
  marker = os.path.join(cad, *marker_parts)
576
- if not os.path.isfile(marker):
577
- continue
578
- marker_mtime = os.path.getmtime(marker)
698
+ if name == "mode":
699
+ # AC-36: never warn for arbiter — presence alone isn't "active".
700
+ marker_mtime = _mode_plane_active_since(cad)
701
+ if marker_mtime is None:
702
+ continue
703
+ else:
704
+ if not os.path.isfile(marker):
705
+ continue
706
+ marker_mtime = os.path.getmtime(marker)
579
707
  if now - marker_mtime < window_minutes * 60:
580
708
  continue # flow started too recently to call it stale yet
581
709
  log_path = os.path.join(cad, *log_parts)
@@ -10,6 +10,11 @@ import hostapi # noqa: E402
10
10
 
11
11
  class PiHost(hostapi.Host):
12
12
  name = "pi"
13
+ adapter_name = "@arbiterforge/ca-pi"
14
+ adapter_version = "0.10.0"
15
+ update_target = "ca-pi"
16
+ update_tag_prefix = "ca-pi-v"
17
+ update_command = "pi update npm:@arbiterforge/ca-pi"
13
18
  command_noun = "command"
14
19
  has_statusline = False
15
20
  has_read_tool = True
@@ -40,7 +45,10 @@ class PiHost(hostapi.Host):
40
45
  return hostapi.git_toplevel() or os.getcwd()
41
46
 
42
47
  def plugin_root(self):
43
- return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
48
+ return hostapi.resolve_plugin_root(
49
+ __file__, adapter_name=self.adapter_name, adapter_version=self.adapter_version,
50
+ manifest_relpath=self.manifest_relpath(), anchor_relpath="hooks/_host.py",
51
+ )
44
52
 
45
53
  def manifest_relpath(self):
46
54
  return "package.json"
@@ -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: