@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.
- package/README.md +29 -90
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +89 -0
- package/plugins/ca-pi/COMMANDS.md +141 -64
- package/plugins/ca-pi/SKILLS.md +137 -28
- package/plugins/ca-pi/agents/INDEX.md +3 -2
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
- package/plugins/ca-pi/agents/finding-triage.md +31 -14
- package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
- package/plugins/ca-pi/arbiter.md +12 -3
- package/plugins/ca-pi/extensions/codearbiter.js +137 -15
- package/plugins/ca-pi/generated/command-catalog.json +386 -186
- package/plugins/ca-pi/generated/roles.json +9 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +33 -16
- package/plugins/ca-pi/hooks/_gitexec.py +23 -0
- package/plugins/ca-pi/hooks/_githooks.py +50 -23
- package/plugins/ca-pi/hooks/_hooklib.py +94 -7
- package/plugins/ca-pi/hooks/_host.py +9 -1
- package/plugins/ca-pi/hooks/_modelib.py +173 -55
- package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
- package/plugins/ca-pi/hooks/_releaselib.py +278 -48
- package/plugins/ca-pi/hooks/_updatelib.py +230 -50
- package/plugins/ca-pi/hooks/doctor.py +56 -8
- package/plugins/ca-pi/hooks/git-enforce.py +10 -3
- package/plugins/ca-pi/hooks/hostapi.py +220 -22
- package/plugins/ca-pi/hooks/session-start.py +8 -6
- package/plugins/ca-pi/hooks/statusline.py +1 -1
- package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
- package/plugins/ca-pi/includes/command-compatibility.md +16 -0
- package/plugins/ca-pi/includes/routing-table.md +13 -5
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +54 -2
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
- package/plugins/ca-pi/routines/release/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
- package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
- package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
+
_INTERP_EXECUTABLE + r"[\s\S]*" + LOG_NAMES, re.I,
|
|
499
510
|
)
|
|
500
511
|
DECISIONS_INTERP_RE = re.compile(
|
|
501
|
-
|
|
512
|
+
_INTERP_EXECUTABLE + r"[\s\S]*" + DECISIONS, re.I,
|
|
502
513
|
)
|
|
503
514
|
CONTEXT_INTERP_RE = re.compile(
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
66
|
-
#
|
|
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,
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
374
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
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"
|