@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
@@ -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 of the latest published GitHub release, and — when
9
- # the cache says a newer version exists — hands back a single notice line. Both
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: any network
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
- # notice_line(installed, latest) -> str|None the SessionStart/statusline notice text
38
- # read_state(path=None) -> dict {latest, checked_at} cache, or {} on any failure
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
- # fetch_latest_tag(url=UPDATE_API_URL, timeout=3.0, opener=None) -> str|None HTTPS GET, fail-silent
42
- # refresh_if_stale(now=None, fetcher=None, path=None) -> dict best-effort cache refresh
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 get_host, write_text_atomic # noqa: E402 — sys.path mount above
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 own GitHub Releases API — unauthenticated GET, HTTPS only (ADR-0003).
65
- # The release tag equals plugin.json's version (an established repo invariant).
66
- UPDATE_API_URL = "https://api.github.com/repos/arbiterForge/codeArbiter/releases/latest"
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 notice_line(installed, latest):
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 /plugin marketplace update
153
- codearbiter)`. Never multi-line; never emitted for equal, lesser, missing, or
154
- malformed `latest`."""
155
- if not update_available(installed, latest):
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 /plugin marketplace update codearbiter)")
210
+ f"(run {descriptor['command']})")
159
211
 
160
212
 
161
213
  def read_state(path=None):
162
- """The cached `{latest, checked_at}` state, or {} on ANY failure (missing file,
163
- corrupt JSON, non-dict content) — a corrupt cache degrades to 'no notice', never
164
- a crash of the host hook."""
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
- return (now - float(checked_at)) >= interval
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 fetch_latest_tag(url=UPDATE_API_URL, timeout=3.0, opener=None):
224
- """GET the GitHub Releases API and return `tag_name`, or None on ANY problem
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 not isinstance(url, str) or not url.lower().startswith("https://"):
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
- with op.open(req, timeout=timeout) as resp:
241
- status = getattr(resp, "status", None) or getattr(resp, "code", None)
242
- if status != 200:
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
- body = resp.read()
245
- data = json.loads(body.decode("utf-8", "replace"))
246
- tag = data.get("tag_name") if isinstance(data, dict) else None
247
- return tag.strip() if isinstance(tag, str) and tag.strip() else None
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 (is_stale() False), returns it
256
- UNCHANGED and calls the fetcher NOT AT ALL (AC-4 — at most one fetch per day).
257
- Otherwise calls `fetcher()` (default fetch_latest_tag): on success the new
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
- state = read_state(path)
265
- checked_at = state.get("checked_at") if isinstance(state, dict) else None
266
- if not is_stale(checked_at, now):
267
- return state
268
- fetch = fetcher or fetch_latest_tag
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
- new_state = {
274
- "latest": latest if latest else state.get("latest"),
275
- "checked_at": now,
276
- }
277
- write_state(new_state, path)
278
- return new_state
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([git_executable(), "rev-parse", "--show-toplevel"])
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([git_executable(), "config", "user.email"]).stdout.strip()
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
- """#556 (AC-3): the git-level hook backstop (#161) can be running from a
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 (nothing printed) when `root` is None (not inside a git repo
223
- already reported by check_repo) or the drop-in registry doesn't exist
224
- yet (nothing installed, or a host that predates ADR-0014)."""
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 or not os.path.isdir(dropin_dir):
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
- marker = os.path.join(root, ".codearbiter", ".markers", "security-gate-passed")
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(root, "security-gate-passed")
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)