@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
@@ -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(
@@ -448,6 +449,16 @@ GATE_MARKER_WRITE_RE = re.compile(
448
449
  _INTERP_TOKENS = (r"python3?|python2|py|node|deno|bun|perl|ruby|php"
449
450
  r"|sh|bash|zsh|pwsh|powershell")
450
451
 
452
+ # A word boundary also matches the `py` in `catalog.py`: that is a filename,
453
+ # not an interpreter. Share the corrected left edge across every interpreter
454
+ # leg so read-only filename arguments cannot falsely imply a protected write.
455
+ # Preserve slash/backslash and quote boundaries for executable paths, and the
456
+ # existing right edge for versioned executables and Windows `.exe` spellings.
457
+ # Consume leading dots at that boundary: `./.python` is an executable basename,
458
+ # unlike the interpreter-looking extension in `catalog.python`.
459
+ # This remains conservative lexical detection, not command-position parsing.
460
+ _INTERP_EXECUTABLE = r"(?<![\w.])\.*(" + _INTERP_TOKENS + r")\b"
461
+
451
462
  # The inline-code switch that makes an interpreter EXECUTE A STRING rather
452
463
  # than run a file. `-c` (python/py/sh/bash/zsh/pwsh), `-e`/`-E` (perl,
453
464
  # ruby, node, bun), `-r` (php), `-p`/`--print`/`--eval` (node), and
@@ -475,7 +486,7 @@ _INTERP_INLINE_CODE = (
475
486
  # whereas handing a board filename to `taskwrite.py` is the sanctioned
476
487
  # call.
477
488
  GATE_MARKER_INTERP_RE = re.compile(
478
- r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + GATE_MARKER, re.I,
489
+ _INTERP_EXECUTABLE + r"[\s\S]*" + GATE_MARKER, re.I,
479
490
  )
480
491
 
481
492
  # #574: H-05/H-11/H-18 carried NO interpreter leg at all — an inline-code
@@ -495,13 +506,13 @@ GATE_MARKER_INTERP_RE = re.compile(
495
506
  # target name may sit on different physical lines of the SAME multi-line
496
507
  # `-c`/`-e` payload.
497
508
  LOG_INTERP_RE = re.compile(
498
- r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + LOG_NAMES, re.I,
509
+ _INTERP_EXECUTABLE + r"[\s\S]*" + LOG_NAMES, re.I,
499
510
  )
500
511
  DECISIONS_INTERP_RE = re.compile(
501
- r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + DECISIONS, re.I,
512
+ _INTERP_EXECUTABLE + r"[\s\S]*" + DECISIONS, re.I,
502
513
  )
503
514
  CONTEXT_INTERP_RE = re.compile(
504
- r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + CONTEXT_MD, re.I,
515
+ _INTERP_EXECUTABLE + r"[\s\S]*" + CONTEXT_MD, re.I,
505
516
  )
506
517
 
507
518
  # H-22's shell flank: the protected-state registry (B1/#564) — Write/Edit are
@@ -730,7 +741,7 @@ def _state_write_res(basename, rel_path=None):
730
741
  + _STATE_NAME_RIGHT_EDGE, re.I,
731
742
  )
732
743
  interp_re = re.compile(
733
- r"\b(" + _INTERP_TOKENS + r")\b[^\n]*?" + _INTERP_INLINE_CODE
744
+ _INTERP_EXECUTABLE + r"[^\n]*?" + _INTERP_INLINE_CODE
734
745
  + r"[\s\S]*" + name + _STATE_NAME_RIGHT_EDGE, re.I,
735
746
  )
736
747
  return redirect_re, write_re, git_restore_re, interp_re
@@ -806,6 +817,7 @@ def git_cwd(cmd, root):
806
817
 
807
818
  _CODEX_EXPLICIT_WORKDIR_TOOLS = frozenset({
808
819
  "shell_command", "exec_command", "unified_exec",
820
+ "functions.exec",
809
821
  })
810
822
 
811
823
 
@@ -1340,7 +1352,11 @@ def _check_h05_audit_log(cmd):
1340
1352
  # (`python3 -c "open('.codearbiter/overrides.log','w')..."`) — the
1341
1353
  # verb-list and redirect legs above never look for an interpreter token
1342
1354
  # at all, so this shape walked past both.
1343
- if any(n in cmd for n in AUDIT_LOG_BASENAMES) and (
1355
+ folded = cmd.casefold()
1356
+ legacy_names = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
1357
+ enrolled_name = (any(n in cmd for n in legacy_names) or
1358
+ ADR_LIFECYCLE_LOG_BASENAME.casefold() in folded)
1359
+ if enrolled_name and (
1344
1360
  LOG_TRUNC_RE.search(cmd) or LOG_DESTROY_RE.search(cmd)
1345
1361
  or LOG_GIT_RESTORE_RE.search(cmd) or LOG_INTERP_RE.search(cmd)):
1346
1362
  block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, "
@@ -1367,6 +1383,7 @@ def _check_h11_decisions(cmd):
1367
1383
  # append to decisions/decision-log.md stays the #528 carve-out's to
1368
1384
  # police (via H-05's own LOG_INTERP_RE), not a false H-11 block.
1369
1385
  cmd = DECISION_LOG_SHELL_RE.sub(" ", cmd)
1386
+ cmd = ADR_LIFECYCLE_LOG_SHELL_RE.sub(" ", cmd)
1370
1387
  if (DECISIONS_REDIRECT_RE.search(cmd) or DECISIONS_WRITE_RE.search(cmd)
1371
1388
  or DECISIONS_INTERP_RE.search(cmd)):
1372
1389
  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,
@@ -533,7 +536,7 @@ def warn(msg):
533
536
  # no matching activity in its expected audit log.
534
537
  #
535
538
  # Only the mode plane and /sprint have a persistent "in-progress" marker
536
- # today (.codearbiter/.markers/mode and .codearbiter/sprint-active — the same
539
+ # today (.codearbiter/.markers/mode.d/ and .codearbiter/sprint-active — the same
537
540
  # state _arbiterstatelib.current_mode()/arbiter_state() already read; the
538
541
  # mode marker is #437's direct successor to the retired dev-active marker
539
542
  # this comment originally described). /override is a single synchronous
@@ -562,7 +565,14 @@ _STALE_FLOWS = (
562
565
  # rename is the one hazard this whole registry exists to avoid: it is a
563
566
  # WARN, not a gate, so a stale matcher fails PERMANENTLY SILENT with an
564
567
  # otherwise green suite — nothing else in the repo would ever notice.
565
- ("mode", (".markers", "mode"), ("overrides.log",)),
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",)),
566
576
  ("sprint", ("sprint-active",), ("sprint-log.md",)),
567
577
  )
568
578
 
@@ -595,6 +605,79 @@ def _mode_marker_has_non_arbiter_entry(marker):
595
605
  return any(v != "arbiter" for v in data.values())
596
606
 
597
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
+
598
681
  def staleness_warning(root, now=None, window_minutes=30):
599
682
  """(CONFIRM-09) One WARN message per active flow (see _STALE_FLOWS) whose
600
683
  marker has existed for at least `window_minutes` with no audit-log
@@ -612,11 +695,15 @@ def staleness_warning(root, now=None, window_minutes=30):
612
695
  for name, marker_parts, log_parts in _STALE_FLOWS:
613
696
  try:
614
697
  marker = os.path.join(cad, *marker_parts)
615
- if not os.path.isfile(marker):
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"
619
- 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)
620
707
  if now - marker_mtime < window_minutes * 60:
621
708
  continue # flow started too recently to call it stale yet
622
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.2"
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"