@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
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
# auto-update by default (only official Anthropic marketplaces get that). This
|
|
6
6
|
# module backs a lightweight notifier so a stale install is surfaced instead of
|
|
7
7
|
# running forever unnoticed: it reads the installed plugin.json version, reads
|
|
8
|
-
# a small user-global cache
|
|
9
|
-
#
|
|
8
|
+
# a small user-global cache keyed by independently versioned release target,
|
|
9
|
+
# and — when that target's cache says a newer version exists — hands back a
|
|
10
|
+
# single host-native notice line. Both
|
|
10
11
|
# SessionStart and the statusline render from that SAME cache; neither makes a
|
|
11
12
|
# network call on its own hot path (issue #194's constraint).
|
|
12
13
|
#
|
|
13
14
|
# The only network call this module makes (fetch_latest_tag) is invoked from
|
|
14
15
|
# the OFF-hot-path detached refresh (see hooks/update-refresh.py, spawned by
|
|
15
16
|
# session-start.py). refresh_if_stale() gates that call to at most once per
|
|
16
|
-
# day via the cached `checked_at`, and is fail-silent end to end:
|
|
17
|
+
# day per target via the cached `checked_at`, and is fail-silent end to end:
|
|
18
|
+
# any network
|
|
17
19
|
# error, timeout, non-200, or unparseable body degrades to "keep the last-known
|
|
18
20
|
# latest" — never a traceback, never a crash of the host hook.
|
|
19
21
|
#
|
|
@@ -34,18 +36,24 @@
|
|
|
34
36
|
# parse_version(s) -> tuple|None numeric-tuple parse; None if malformed/absent
|
|
35
37
|
# version_gt(a, b) -> bool True iff semver a > b (numeric-tuple compare)
|
|
36
38
|
# update_available(installed, latest) -> bool True iff latest > installed
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
+
# update_descriptor(host=None) -> dict|None validated host update descriptor
|
|
40
|
+
# target_state(state, host=None) -> dict one target's cache row, or {}
|
|
41
|
+
# notice_line(installed, latest, host=None) -> str|None host-native notice text
|
|
42
|
+
# read_state(path=None) -> dict target-keyed cache, or {} on any failure
|
|
39
43
|
# write_state(state, path=None) -> None atomic cache write; best-effort, never raises
|
|
40
44
|
# is_stale(checked_at, now, interval=ONE_DAY) -> bool True iff a refresh is due
|
|
41
|
-
#
|
|
42
|
-
#
|
|
45
|
+
# select_latest_tag(releases, tag_prefix) -> str|None target-series selection
|
|
46
|
+
# fetch_latest_tag(tag_prefix=None, url=..., timeout=3.0,
|
|
47
|
+
# opener=None, host=None) -> str|None
|
|
48
|
+
# refresh_if_stale(now=None, fetcher=None, path=None, host=None) -> dict
|
|
43
49
|
|
|
44
50
|
import json
|
|
51
|
+
import math
|
|
45
52
|
import os
|
|
46
53
|
import re
|
|
47
54
|
import sys
|
|
48
55
|
import time
|
|
56
|
+
import urllib.parse
|
|
49
57
|
import urllib.request
|
|
50
58
|
|
|
51
59
|
# Reuse the ONE atomic-write helper defined in _hooklib.py (same rationale as
|
|
@@ -54,16 +62,23 @@ import urllib.request
|
|
|
54
62
|
_HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
55
63
|
if _HOOKS_DIR not in sys.path:
|
|
56
64
|
sys.path.insert(0, _HOOKS_DIR)
|
|
57
|
-
from _hooklib import
|
|
65
|
+
from _hooklib import ( # noqa: E402 — sys.path mount above
|
|
66
|
+
acquire_lock,
|
|
67
|
+
get_host,
|
|
68
|
+
release_lock,
|
|
69
|
+
write_text_atomic,
|
|
70
|
+
)
|
|
58
71
|
# hostapi is not imported directly here (#257): plugin_root()/installed_version()
|
|
59
72
|
# resolve the Host via _hooklib.get_host() (the DI seam every entry script's
|
|
60
73
|
# run(host) primes via set_host()), never a fresh hostapi.load_host().
|
|
61
74
|
|
|
62
75
|
ONE_DAY = 24 * 60 * 60
|
|
63
76
|
|
|
64
|
-
# The repo's
|
|
65
|
-
#
|
|
66
|
-
|
|
77
|
+
# The repo's GitHub Releases collection — unauthenticated GET, HTTPS only
|
|
78
|
+
# (ADR-0003). A collection is required because each host publishes an
|
|
79
|
+
# independent tag series; /releases/latest can represent only one of them.
|
|
80
|
+
UPDATE_API_URL = "https://api.github.com/repos/arbiterForge/codeArbiter/releases?per_page=100"
|
|
81
|
+
MAX_RELEASE_PAGES = 10
|
|
67
82
|
|
|
68
83
|
_VERSION_STRIP_RE = re.compile(r"^[vV]")
|
|
69
84
|
|
|
@@ -147,21 +162,58 @@ def update_available(installed, latest):
|
|
|
147
162
|
return version_gt(latest, installed)
|
|
148
163
|
|
|
149
164
|
|
|
150
|
-
def
|
|
165
|
+
def update_descriptor(host=None):
|
|
166
|
+
"""Validated update descriptor for `host` (or the active host), else None.
|
|
167
|
+
A partially defined or multi-line descriptor disables the notifier rather
|
|
168
|
+
than falling back to another host's release series or command."""
|
|
169
|
+
host = host or get_host()
|
|
170
|
+
target = getattr(host, "update_target", None)
|
|
171
|
+
prefix = getattr(host, "update_tag_prefix", None)
|
|
172
|
+
command = getattr(host, "update_command", None)
|
|
173
|
+
if not all(isinstance(value, str) and value.strip()
|
|
174
|
+
for value in (target, prefix, command)):
|
|
175
|
+
return None
|
|
176
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", target.strip()):
|
|
177
|
+
return None
|
|
178
|
+
if "\n" in command or "\r" in command:
|
|
179
|
+
return None
|
|
180
|
+
return {
|
|
181
|
+
"target": target.strip(),
|
|
182
|
+
"tag_prefix": prefix.strip(),
|
|
183
|
+
"command": command.strip(),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def target_state(state, host=None):
|
|
188
|
+
"""The active host target's `{latest, checked_at}` row, or {}.
|
|
189
|
+
Legacy unkeyed cache data is deliberately not attributed to any host: its
|
|
190
|
+
`latest` may belong to an unrelated release series (the RA-02 defect)."""
|
|
191
|
+
descriptor = update_descriptor(host)
|
|
192
|
+
if descriptor is None or not isinstance(state, dict) or state.get("schema") != 1:
|
|
193
|
+
return {}
|
|
194
|
+
targets = state.get("targets")
|
|
195
|
+
if not isinstance(targets, dict):
|
|
196
|
+
return {}
|
|
197
|
+
row = targets.get(descriptor["target"])
|
|
198
|
+
return row if isinstance(row, dict) else {}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def notice_line(installed, latest, host=None):
|
|
151
202
|
"""The single-line SessionStart/statusline notice, or None when no update is due
|
|
152
|
-
(AC-1/AC-2): `codeArbiter: update available X -> Y (run
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
203
|
+
(AC-1/AC-2): `codeArbiter: update available X -> Y (run <host command>)`.
|
|
204
|
+
Never multi-line; never emitted for equal, lesser, missing, or malformed
|
|
205
|
+
`latest`."""
|
|
206
|
+
descriptor = update_descriptor(host)
|
|
207
|
+
if descriptor is None or not update_available(installed, latest):
|
|
156
208
|
return None
|
|
157
209
|
return (f"codeArbiter: update available {installed} -> {latest} "
|
|
158
|
-
f"(run
|
|
210
|
+
f"(run {descriptor['command']})")
|
|
159
211
|
|
|
160
212
|
|
|
161
213
|
def read_state(path=None):
|
|
162
|
-
"""The
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
"""The target-keyed cache state, or {} on ANY failure (missing file, corrupt
|
|
215
|
+
JSON, non-dict content) — a corrupt cache degrades to 'no notice', never a
|
|
216
|
+
crash of the host hook."""
|
|
165
217
|
path = path or state_path()
|
|
166
218
|
try:
|
|
167
219
|
with open(path, encoding="utf-8") as f:
|
|
@@ -190,7 +242,10 @@ def is_stale(checked_at, now, interval=ONE_DAY):
|
|
|
190
242
|
if checked_at is None:
|
|
191
243
|
return True
|
|
192
244
|
try:
|
|
193
|
-
|
|
245
|
+
checked_at = float(checked_at)
|
|
246
|
+
if not math.isfinite(checked_at):
|
|
247
|
+
return True
|
|
248
|
+
return (now - checked_at) >= interval
|
|
194
249
|
except (TypeError, ValueError):
|
|
195
250
|
return True
|
|
196
251
|
|
|
@@ -220,8 +275,34 @@ def _build_opener():
|
|
|
220
275
|
return urllib.request.build_opener(_HTTPSOnlyRedirectHandler())
|
|
221
276
|
|
|
222
277
|
|
|
223
|
-
def
|
|
224
|
-
"""
|
|
278
|
+
def select_latest_tag(releases, tag_prefix):
|
|
279
|
+
"""Highest stable numeric version in `releases` for exact `tag_prefix`.
|
|
280
|
+
Drafts, prereleases, malformed rows, and every sibling series are ignored."""
|
|
281
|
+
if not isinstance(releases, list) or not isinstance(tag_prefix, str) or not tag_prefix:
|
|
282
|
+
return None
|
|
283
|
+
selected = None
|
|
284
|
+
selected_tuple = None
|
|
285
|
+
for release in releases:
|
|
286
|
+
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"):
|
|
287
|
+
continue
|
|
288
|
+
tag = release.get("tag_name")
|
|
289
|
+
if not isinstance(tag, str) or not tag.startswith(tag_prefix):
|
|
290
|
+
continue
|
|
291
|
+
version = tag[len(tag_prefix):]
|
|
292
|
+
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
|
|
293
|
+
continue
|
|
294
|
+
parsed = parse_version(version)
|
|
295
|
+
if parsed is not None and (selected_tuple is None or parsed > selected_tuple):
|
|
296
|
+
selected = version
|
|
297
|
+
selected_tuple = parsed
|
|
298
|
+
return selected
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def fetch_latest_tag(tag_prefix=None, url=UPDATE_API_URL, timeout=3.0,
|
|
302
|
+
opener=None, host=None):
|
|
303
|
+
"""GET bounded pages of the GitHub Releases API and return the active
|
|
304
|
+
series' highest stable numeric version, or
|
|
305
|
+
None on ANY problem
|
|
225
306
|
(AC-5): non-https url, network error, timeout, non-200, an unparseable/absent
|
|
226
307
|
body, or a redirect to a non-https target. HTTPS-only per ADR-0003 — a
|
|
227
308
|
non-https INITIAL url is refused before any connection is attempted, and a
|
|
@@ -229,50 +310,149 @@ def fetch_latest_tag(url=UPDATE_API_URL, timeout=3.0, opener=None):
|
|
|
229
310
|
since urllib's default opener would otherwise follow an https->http
|
|
230
311
|
downgrade transparently). `opener` is injectable (tests); production builds
|
|
231
312
|
the hardened opener via `_build_opener()`."""
|
|
232
|
-
if
|
|
313
|
+
if tag_prefix is None:
|
|
314
|
+
descriptor = update_descriptor(host)
|
|
315
|
+
tag_prefix = descriptor.get("tag_prefix") if descriptor else None
|
|
316
|
+
if (not isinstance(tag_prefix, str) or not tag_prefix
|
|
317
|
+
or not isinstance(url, str) or not url.lower().startswith("https://")):
|
|
233
318
|
return None
|
|
234
319
|
try:
|
|
235
|
-
req = urllib.request.Request(url, headers={
|
|
236
|
-
"User-Agent": "codeArbiter-update-check",
|
|
237
|
-
"Accept": "application/vnd.github+json",
|
|
238
|
-
})
|
|
239
320
|
op = opener or _build_opener()
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
321
|
+
parsed_url = urllib.parse.urlsplit(url)
|
|
322
|
+
query = dict(urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True))
|
|
323
|
+
try:
|
|
324
|
+
page_size = int(query.get("per_page", "100"))
|
|
325
|
+
except (TypeError, ValueError):
|
|
326
|
+
page_size = 100
|
|
327
|
+
if page_size < 1:
|
|
328
|
+
page_size = 100
|
|
329
|
+
|
|
330
|
+
releases = []
|
|
331
|
+
for page in range(1, MAX_RELEASE_PAGES + 1):
|
|
332
|
+
page_query = dict(query)
|
|
333
|
+
page_query["page"] = str(page)
|
|
334
|
+
page_url = urllib.parse.urlunsplit(parsed_url._replace(
|
|
335
|
+
query=urllib.parse.urlencode(page_query)))
|
|
336
|
+
req = urllib.request.Request(page_url, headers={
|
|
337
|
+
"User-Agent": "codeArbiter-update-check",
|
|
338
|
+
"Accept": "application/vnd.github+json",
|
|
339
|
+
})
|
|
340
|
+
with op.open(req, timeout=timeout) as resp:
|
|
341
|
+
status = getattr(resp, "status", None) or getattr(resp, "code", None)
|
|
342
|
+
if status != 200:
|
|
343
|
+
return None
|
|
344
|
+
body = resp.read()
|
|
345
|
+
page_data = json.loads(body.decode("utf-8", "replace"))
|
|
346
|
+
if not isinstance(page_data, list):
|
|
243
347
|
return None
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
348
|
+
releases.extend(page_data)
|
|
349
|
+
if len(page_data) < page_size:
|
|
350
|
+
break
|
|
351
|
+
else:
|
|
352
|
+
# Every bounded page was full, so more releases may exist. Do not
|
|
353
|
+
# cache a result whose series enumeration is known to be incomplete.
|
|
354
|
+
return None
|
|
355
|
+
return select_latest_tag(releases, tag_prefix)
|
|
248
356
|
except Exception: # noqa: BLE001 — AC-5: fail-silent on any network/parse error
|
|
249
357
|
return None
|
|
250
358
|
|
|
251
359
|
|
|
252
|
-
def refresh_if_stale(now=None, fetcher=None, path=None):
|
|
360
|
+
def refresh_if_stale(now=None, fetcher=None, path=None, host=None):
|
|
253
361
|
"""Best-effort, once-daily, fail-silent cache refresh (AC-3/AC-4/AC-5).
|
|
254
362
|
|
|
255
|
-
Reads the cache; if `checked_at` is still fresh
|
|
256
|
-
UNCHANGED and calls the fetcher NOT AT ALL
|
|
257
|
-
Otherwise
|
|
363
|
+
Reads the cache under the write lock; if `checked_at` is still fresh
|
|
364
|
+
(is_stale() False), returns it UNCHANGED and calls the fetcher NOT AT ALL.
|
|
365
|
+
Otherwise it reserves that target's daily refresh before releasing the lock,
|
|
366
|
+
so a concurrent same-target process observes a fresh row and also makes no
|
|
367
|
+
call (AC-4 — at most one fetch per day). It then calls `fetcher()` (default
|
|
368
|
+
fetch_latest_tag): on success the new
|
|
258
369
|
`latest` is cached; on ANY exception or a None/falsy return, the PRIOR `latest`
|
|
259
370
|
is preserved (fail-silent — a network hiccup never blanks a known-good notice)
|
|
260
371
|
and `checked_at` still advances, so a persistently-unreachable network is not
|
|
261
372
|
retried every single session that day. Never raises (AC-3)."""
|
|
262
373
|
now = time.time() if now is None else now
|
|
263
374
|
path = path or state_path()
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
375
|
+
descriptor = update_descriptor(host)
|
|
376
|
+
if descriptor is None:
|
|
377
|
+
return read_state(path)
|
|
378
|
+
|
|
379
|
+
# Reserve this target's refresh under the cache lock before any network
|
|
380
|
+
# work. Without the re-read and reservation here, concurrent detached
|
|
381
|
+
# SessionStart refreshes can all observe the same stale row and each issue
|
|
382
|
+
# a bounded release-page fetch before the later merge lock serializes them.
|
|
383
|
+
try:
|
|
384
|
+
reservation_at = float(now)
|
|
385
|
+
except (TypeError, ValueError):
|
|
386
|
+
return read_state(path)
|
|
387
|
+
if not math.isfinite(reservation_at):
|
|
388
|
+
return read_state(path)
|
|
389
|
+
|
|
390
|
+
lock = acquire_lock(path)
|
|
391
|
+
if lock is None:
|
|
392
|
+
return read_state(path)
|
|
393
|
+
try:
|
|
394
|
+
current = read_state(path)
|
|
395
|
+
current_row = target_state(current, host=host)
|
|
396
|
+
if not is_stale(current_row.get("checked_at"), reservation_at):
|
|
397
|
+
return current
|
|
398
|
+
prior_targets = current.get("targets") if isinstance(current, dict) else None
|
|
399
|
+
targets = dict(prior_targets) if isinstance(prior_targets, dict) else {}
|
|
400
|
+
targets[descriptor["target"]] = {
|
|
401
|
+
"latest": current_row.get("latest"),
|
|
402
|
+
"checked_at": reservation_at,
|
|
403
|
+
}
|
|
404
|
+
reserved_state = {"schema": 1, "targets": targets}
|
|
405
|
+
write_state(reserved_state, path)
|
|
406
|
+
confirmed_row = target_state(read_state(path), host=host)
|
|
407
|
+
if confirmed_row.get("checked_at") != reservation_at:
|
|
408
|
+
# A failed reservation cannot safely authorize network egress. The
|
|
409
|
+
# notifier remains fail-silent and will retry on a later session.
|
|
410
|
+
return read_state(path)
|
|
411
|
+
finally:
|
|
412
|
+
release_lock(lock)
|
|
413
|
+
|
|
414
|
+
fetch = fetcher or (lambda: fetch_latest_tag(
|
|
415
|
+
tag_prefix=descriptor["tag_prefix"], host=host))
|
|
269
416
|
try:
|
|
270
417
|
latest = fetch()
|
|
271
418
|
except Exception: # noqa: BLE001 — AC-3/AC-5: never propagate a fetch failure
|
|
272
419
|
latest = None
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
420
|
+
lock = acquire_lock(path)
|
|
421
|
+
if lock is None:
|
|
422
|
+
return read_state(path)
|
|
423
|
+
try:
|
|
424
|
+
# Re-read under the write lock. Another independently versioned host
|
|
425
|
+
# may have refreshed while this process was waiting on GitHub; merging
|
|
426
|
+
# the pre-fetch snapshot would silently erase that target's row.
|
|
427
|
+
current = read_state(path)
|
|
428
|
+
current_row = target_state(current, host=host)
|
|
429
|
+
prior_targets = current.get("targets") if isinstance(current, dict) else None
|
|
430
|
+
targets = dict(prior_targets) if isinstance(prior_targets, dict) else {}
|
|
431
|
+
|
|
432
|
+
current_latest = current_row.get("latest")
|
|
433
|
+
if parse_version(latest) is None:
|
|
434
|
+
merged_latest = current_latest
|
|
435
|
+
elif (parse_version(current_latest) is not None
|
|
436
|
+
and version_gt(current_latest, latest)):
|
|
437
|
+
merged_latest = current_latest
|
|
438
|
+
else:
|
|
439
|
+
merged_latest = latest
|
|
440
|
+
|
|
441
|
+
current_checked_at = current_row.get("checked_at")
|
|
442
|
+
finite_checked_at = []
|
|
443
|
+
for value in (current_checked_at, now):
|
|
444
|
+
try:
|
|
445
|
+
value = float(value)
|
|
446
|
+
except (TypeError, ValueError):
|
|
447
|
+
continue
|
|
448
|
+
if math.isfinite(value):
|
|
449
|
+
finite_checked_at.append(value)
|
|
450
|
+
targets[descriptor["target"]] = {
|
|
451
|
+
"latest": merged_latest,
|
|
452
|
+
"checked_at": max(finite_checked_at) if finite_checked_at else None,
|
|
453
|
+
}
|
|
454
|
+
new_state = {"schema": 1, "targets": targets}
|
|
455
|
+
write_state(new_state, path)
|
|
456
|
+
return new_state
|
|
457
|
+
finally:
|
|
458
|
+
release_lock(lock)
|
|
@@ -19,7 +19,7 @@ import subprocess
|
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
21
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
-
from _gitexec import git_executable # noqa: E402
|
|
22
|
+
from _gitexec import git_executable, root_bound_git_env # noqa: E402
|
|
23
23
|
import hostapi # noqa: E402 — host seam (ADR-0011): plugin-root resolution
|
|
24
24
|
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
25
25
|
import _githooks # noqa: E402 — #556: git-hook drop-in registry freshness
|
|
@@ -173,7 +173,9 @@ def check_payload(root, host=None):
|
|
|
173
173
|
def check_repo():
|
|
174
174
|
"""Returns the resolved repo root (for check_git_hook_freshness below), or
|
|
175
175
|
None when this process isn't inside a git repository at all."""
|
|
176
|
-
r = _run_cmd(
|
|
176
|
+
r = _run_cmd(
|
|
177
|
+
[git_executable(), "rev-parse", "--show-toplevel"],
|
|
178
|
+
env=root_bound_git_env())
|
|
177
179
|
if r.returncode != 0:
|
|
178
180
|
warn("not inside a git repository — repo-level checks skipped")
|
|
179
181
|
return None
|
|
@@ -199,7 +201,9 @@ def check_repo():
|
|
|
199
201
|
else:
|
|
200
202
|
warn(f"no <!--INITIALIZED--> marker — startup will route to "
|
|
201
203
|
f"{get_host().cmd_ref('decompose')} or {get_host().cmd_ref('create-context')}")
|
|
202
|
-
email = _run_cmd(
|
|
204
|
+
email = _run_cmd(
|
|
205
|
+
[git_executable(), "config", "user.email"], cwd=root,
|
|
206
|
+
env=root_bound_git_env()).stdout.strip()
|
|
203
207
|
if email:
|
|
204
208
|
ok(f"git identity for audit attribution: {email}")
|
|
205
209
|
else:
|
|
@@ -209,7 +213,9 @@ def check_repo():
|
|
|
209
213
|
|
|
210
214
|
|
|
211
215
|
def check_git_hook_freshness(root):
|
|
212
|
-
"""
|
|
216
|
+
"""Verify the effective Git backstop, then report registry freshness.
|
|
217
|
+
|
|
218
|
+
#556 (AC-3): the git-level hook backstop (#161) can be running from a
|
|
213
219
|
host's plugin cache that nobody has refreshed in a long time — a cache
|
|
214
220
|
that predates a fix THIS checkout already carries (the #279
|
|
215
221
|
sensitive-scan exemption, in the issue that motivated this check) is
|
|
@@ -219,14 +225,56 @@ def check_git_hook_freshness(root):
|
|
|
219
225
|
commit/push time (`_githooks.stale_registered_plugins`), so this can
|
|
220
226
|
never disagree with what actually happens at commit time.
|
|
221
227
|
|
|
222
|
-
A no-op
|
|
223
|
-
|
|
224
|
-
|
|
228
|
+
A no-op when `root` is None (already reported by check_repo) or the repo is
|
|
229
|
+
deliberately dormant. For an arbiter-enabled repo, missing effective
|
|
230
|
+
managed shims or a registry without a live enforcer is a broken backstop,
|
|
231
|
+
not a healthy empty registry."""
|
|
225
232
|
if root is None:
|
|
226
233
|
return
|
|
234
|
+
ctx = os.path.join(root, ".codearbiter", "CONTEXT.md")
|
|
235
|
+
enabled, malformed = frontmatter_enabled(ctx)
|
|
236
|
+
if not enabled or malformed:
|
|
237
|
+
return
|
|
238
|
+
hooks_dir = _githooks.hooks_dir(root)
|
|
239
|
+
if hooks_dir is None:
|
|
240
|
+
fail("git-hook backstop: the selected Git binary could not resolve its "
|
|
241
|
+
"effective hooks directory")
|
|
242
|
+
return
|
|
227
243
|
dropin_dir = _githooks._dropin_dir(root)
|
|
228
|
-
if dropin_dir is None
|
|
244
|
+
if dropin_dir is None:
|
|
245
|
+
fail("git-hook backstop: Git's shared registry directory could not be resolved")
|
|
246
|
+
return
|
|
247
|
+
if not _githooks._hooks_current(hooks_dir, dropin_dir):
|
|
248
|
+
fail(f"git-hook backstop: Git's effective hooks directory ({hooks_dir}) "
|
|
249
|
+
"does not contain the current managed pre-commit and pre-push shims")
|
|
250
|
+
return
|
|
251
|
+
if os.name != "nt":
|
|
252
|
+
inoperable = [phase for phase in _githooks.PHASES
|
|
253
|
+
if not os.access(os.path.join(hooks_dir, phase), os.X_OK)]
|
|
254
|
+
if inoperable:
|
|
255
|
+
fail("git-hook backstop: Git's managed hook is not executable: "
|
|
256
|
+
+ ", ".join(inoperable))
|
|
257
|
+
return
|
|
258
|
+
live = _githooks.live_registered_plugins(dropin_dir)
|
|
259
|
+
if not live:
|
|
260
|
+
fail("git-hook backstop: the shared registry has no live enforcer; start "
|
|
261
|
+
"a session from a durable installed host or reinstall that host")
|
|
262
|
+
return
|
|
263
|
+
try:
|
|
264
|
+
probe = _run_cmd(
|
|
265
|
+
[git_executable(), "hook", "run", "pre-push"], cwd=root, input="",
|
|
266
|
+
env=root_bound_git_env())
|
|
267
|
+
except Exception as exc: # noqa: BLE001 - diagnostic must fail unhealthy, never crash
|
|
268
|
+
fail(f"git-hook backstop live-fire could not run: {exc}")
|
|
269
|
+
return
|
|
270
|
+
if probe.returncode != 0:
|
|
271
|
+
detail = (probe.stderr or probe.stdout or "managed pre-push returned nonzero").strip()
|
|
272
|
+
fail(f"git-hook backstop live-fire failed: {detail[:240]}")
|
|
273
|
+
return
|
|
274
|
+
if "ignored because" in (probe.stderr or "").lower():
|
|
275
|
+
fail(f"git-hook backstop live-fire was ignored by Git: {probe.stderr.strip()[:240]}")
|
|
229
276
|
return
|
|
277
|
+
ok("git-hook backstop live-fire: selected Git executed the managed pre-push shim")
|
|
230
278
|
stale = _githooks.stale_registered_plugins(dropin_dir)
|
|
231
279
|
if stale:
|
|
232
280
|
names = ", ".join(sorted(stale))
|
|
@@ -194,6 +194,12 @@ def _marker_set(root, name):
|
|
|
194
194
|
return set()
|
|
195
195
|
|
|
196
196
|
|
|
197
|
+
def _marker_root(root):
|
|
198
|
+
"""Escalate only gate-marker reads to the main checkout for a linked
|
|
199
|
+
worktree; all Git and worktree reads remain anchored at ``root`` (#695)."""
|
|
200
|
+
return hostapi.git_worktree_main_root(root) or root
|
|
201
|
+
|
|
202
|
+
|
|
197
203
|
def pre_commit(root):
|
|
198
204
|
cwd = root
|
|
199
205
|
# H-01: no commit onto a protected branch (or a detached HEAD on its tip).
|
|
@@ -230,11 +236,12 @@ def pre_commit(root):
|
|
|
230
236
|
kind = "crypto/TLS" if touches_crypto else "secret"
|
|
231
237
|
tag = "H-09b" if touches_crypto else "H-10b"
|
|
232
238
|
skill = "crypto-compliance" if touches_crypto else "secret-handling"
|
|
233
|
-
|
|
239
|
+
marker_root = _marker_root(root)
|
|
240
|
+
marker = os.path.join(marker_root, ".codearbiter", ".markers", "security-gate-passed")
|
|
234
241
|
if not marker_fresh(marker, MARKER_FRESHNESS_MINUTES):
|
|
235
242
|
block(tag, f"This commit introduces {kind} changes, but no security-gate pass is "
|
|
236
243
|
f"recorded (#161 git backstop). Run the {skill} gate, then commit.")
|
|
237
|
-
approved = _marker_set(
|
|
244
|
+
approved = _marker_set(marker_root, "security-gate-passed")
|
|
238
245
|
uncovered = [ln for ln in sensitive if line_digest(ln) not in approved]
|
|
239
246
|
if uncovered:
|
|
240
247
|
block(tag, f"{len(uncovered)} {kind} line(s) in this commit are not covered by the "
|
|
@@ -248,7 +255,7 @@ def pre_commit(root):
|
|
|
248
255
|
"failing closed (ORCHESTRATOR §2).")
|
|
249
256
|
migs = sorted(p for p in names if is_migration_path(p, root))
|
|
250
257
|
if migs:
|
|
251
|
-
approved = _marker_set(root, "migration-gate-passed")
|
|
258
|
+
approved = _marker_set(_marker_root(root), "migration-gate-passed")
|
|
252
259
|
uncovered = []
|
|
253
260
|
for rel in migs:
|
|
254
261
|
text = read_worktree(cwd, rel)
|