@arbiterforge/ca-pi 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,920 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — installs the git-level enforcement hooks (#161).
3
+ #
4
+ # The PreToolUse Bash hook (pre-bash.py) gates git operations by matching the
5
+ # literal command string, so shell indirection (`g=git; c=commit; $g $c`) walks
6
+ # past it. There is no enforcement below that layer. This module installs
7
+ # repo-level .git/hooks/pre-commit and pre-push that invoke git-enforce.py at the
8
+ # git operation itself, where spelling no longer matters.
9
+ #
10
+ # Design decisions (ADR-0014, resolves #265 / tribunal reliability-009):
11
+ # * The shim is a tiny POSIX `sh` script that detects the interpreter ONCE
12
+ # (python3 else python) and runs the enforcer EXACTLY once — never
13
+ # `python3 X || python X`, which would (a) swallow a BLOCK when python3 both
14
+ # exists and blocks, and (b) drain stdin before the fallback (pre-push feeds
15
+ # the ref list on stdin). Same hazard hooks.json avoids via two entries; a
16
+ # single hook file must guard it inline.
17
+ # * The shim itself is HOST-NEUTRAL: it embeds no absolute enforcer path at
18
+ # all. It instead points at a shared, non-versioned drop-in directory
19
+ # inside the repo's OWN `.git/`:
20
+ #
21
+ # .git/codearbiter-hooksd/<plugin>.path # e.g. ca.path, ca-codex.path
22
+ #
23
+ # Each installed host writes its OWN current `_enforcer_path()` into its
24
+ # own `<plugin>.path` file every SessionStart (install() below) — a live
25
+ # host self-heals a stale entry on its very next session. The shim runs
26
+ # every resolving enforcer until one blocks; all must allow for Git to
27
+ # proceed. A dead entry from an uninstalled plugin is skipped.
28
+ # `uninstall()` removes only ITS OWN
29
+ # `.path` file — never the shared shim, which a sibling plugin may still
30
+ # depend on.
31
+ # * FAIL CLOSED, not fail-open: if the directory is empty, absent, or every
32
+ # entry it contains names a file that no longer exists, the shim prints a
33
+ # diagnostic to stderr and exits non-zero — it BLOCKS the git operation
34
+ # rather than silently allowing it. This is a deliberate reversal of the
35
+ # single-plugin fail-open era: ADR-0014 records why. Before this drop-in dir
36
+ # existed, the shim embedded ONE absolute enforcer path (whichever plugin's
37
+ # SessionStart ran last), so uninstalling that plugin — or even the OTHER
38
+ # plugin, if IT never got a chance to write its own copy afterward — could
39
+ # silently unwire the git-level backstop for every host. The drop-in dir
40
+ # removes the reason fail-open existed (a plugin no longer has to derive a
41
+ # SIBLING's path — each writes only its own), so the residual failure mode
42
+ # (truly nothing resolves) can safely — and must — fail closed instead.
43
+ # * The drop-in directory itself is resolved via the repo's git COMMON dir
44
+ # (mirrors `git rev-parse --git-common-dir`, resolved without a git spawn
45
+ # when possible — see `_git_common_dir`), never `--git-dir` and never a
46
+ # per-worktree path: a linked worktree's `.git` is a FILE pointing at
47
+ # `<main>/.git/worktrees/<name>`, and the shared hooks/backstop must resolve
48
+ # to the ONE drop-in dir inside the MAIN repo's `.git/`, so every worktree
49
+ # and every host agree on the same directory — a per-worktree drop-in dir
50
+ # would defeat the entire cross-host purpose.
51
+ # * A pre-existing NON-ours hook is NEVER clobbered — we warn loudly and skip,
52
+ # so an existing husky / pre-commit-framework setup is preserved.
53
+ # * Idempotent: an up-to-date ours-hook is left untouched (no churn); a stale
54
+ # ours-hook is refreshed. Because the shim no longer embeds any
55
+ # plugin-specific path, an enforcer-path change (e.g. a version bump moving
56
+ # the install dir) does NOT by itself require rewriting the shim file — only
57
+ # this plugin's own `<plugin>.path` drop-in entry, which install() refreshes
58
+ # unconditionally every session regardless of whether the shim itself needed
59
+ # a rewrite.
60
+ # * A pre-existing NON-ours hook is NEVER clobbered — we warn loudly and skip,
61
+ # so an existing husky / pre-commit-framework setup is preserved.
62
+ # * Idempotent: an up-to-date ours-hook is left untouched (no churn); a stale
63
+ # ours-hook is refreshed.
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
67
+ # nothing changed. install() first checks a cheap on-disk cache (a single
68
+ # small file read, no git spawn) recording the hooks_dir a prior successful
69
+ # resolution used; if BOTH phase shims at that cached location already
70
+ # match what we'd install right now, it returns immediately. Any mismatch
71
+ # or absence (including a genuinely fresh/cold repo) falls through to the
72
+ # full git-based probe unchanged — the cache is a pure latency optimization,
73
+ # never load-bearing for correctness.
74
+ #
75
+ # CRITICAL fix (security review, post-#194): the fast path must NEVER trust
76
+ # a cached hooks_dir without CHEAPLY (no git spawn) proving the EFFECTIVE
77
+ # hooks dir has not moved since that cache was written. The original cut
78
+ # only re-checked that the shims AT the cached location were current — it
79
+ # never re-checked that git would still read hooks FROM that location. A
80
+ # LOCAL core.hooksPath change after the cache was written (the realistic
81
+ # case: the user later adopts husky / pre-commit-framework, which set
82
+ # `core.hooksPath` in `.git/config`) left the fast path returning `[]`
83
+ # (success) while the NEW hooks dir got no codeArbiter shim at all — the
84
+ # #161 backstop silently unwired. Fixed: the fast path now ALSO requires
85
+ # (a) the cached dir be exactly the DEFAULT `<root>/.git/hooks` (never a
86
+ # cached custom hooksPath — those must always re-confirm via git, since a
87
+ # custom path is exactly the kind of thing that gets repointed), and (b) a
88
+ # direct read of `.git/config` (and `.git/config.worktree`, for
89
+ # extensions.worktreeConfig repos) positively CONFIRMS no local
90
+ # core.hooksPath key is set. Any read failure, parse ambiguity, or a
91
+ # detected key falls through to the full git-based probe — fail direction
92
+ # is "install when unsure," never "skip when unsure."
93
+ #
94
+ # Documented residual (accepted, not cheaply closable): a GLOBAL/SYSTEM
95
+ # core.hooksPath set AFTER a default-location install is not caught by the
96
+ # `.git/config` read alone. This covers ALL of git's global/system config
97
+ # locations, not just `~/.gitconfig`: `~/.config/git/config` (or
98
+ # `$XDG_CONFIG_HOME/git/config`), and a `$GIT_CONFIG_GLOBAL`/
99
+ # `$GIT_CONFIG_SYSTEM` env override repointing the file entirely. The cache
100
+ # is keyed on the mtime of `~/.gitconfig` AND the XDG path (below), which
101
+ # closes the common case of a later edit to either of those two files; a
102
+ # `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` env override, or an edit to
103
+ # `/etc/gitconfig`, is not cheaply detectable from a fixed path and remains
104
+ # residual. This is rare (those overrides predating a later default-location
105
+ # install is the unusual order), and a cold/first install always resolves
106
+ # it correctly via the full git-based probe regardless.
107
+
108
+ import json
109
+ import os
110
+ import re
111
+ import stat
112
+ import subprocess
113
+ import sys
114
+
115
+ import _hooklib
116
+ from _durabilitylib import is_ephemeral_path
117
+ from _gitexec import git_executable, trusted_git_executable, trusted_python_executable
118
+
119
+ SENTINEL = (
120
+ "# codeArbiter-managed git hook (#161) — this SHIM is refreshed by any live "
121
+ "host's session (it is host-neutral, ADR-0014); the plugin-specific enforcer "
122
+ "entries it dispatches to (.git/codearbiter-hooksd/*.path) each self-heal "
123
+ "only on THAT plugin's own next session (#556) — edits here are overwritten."
124
+ )
125
+ PHASES = ("pre-commit", "pre-push")
126
+ # The hooks_dir() resolution cache lives INSIDE .git/ itself (never under
127
+ # .codearbiter/): a linked worktree's `.git` is a FILE (not a directory)
128
+ # pointing at the real gitdir elsewhere, so os.path.isdir(...) on it is
129
+ # naturally False there — the cache silently declines to engage and every call
130
+ # falls through to the full probe, rather than ever risking a wrong-repo guess.
131
+ _HOOKSDIR_CACHE_NAME = "codearbiter-hooksdir-cache"
132
+
133
+
134
+ def _warn(msg):
135
+ print(f"codeArbiter git-hooks: {msg}", file=sys.stderr)
136
+
137
+
138
+ def _git(args, cwd):
139
+ try:
140
+ return subprocess.run(
141
+ [git_executable()] + args, cwd=cwd, capture_output=True, text=True,
142
+ encoding="utf-8", errors="replace", timeout=5,
143
+ )
144
+ except Exception: # noqa: BLE001
145
+ return None
146
+
147
+
148
+ def hooks_dir(root):
149
+ """The directory git actually reads hooks from for `root`, or None.
150
+
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)
159
+ gp = _git(["rev-parse", "--git-path", "hooks"], root)
160
+ if gp is not None and gp.returncode == 0 and gp.stdout.strip():
161
+ hp = gp.stdout.strip()
162
+ 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
165
+
166
+
167
+ def _enforcer_path():
168
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "git-enforce.py")
169
+
170
+
171
+ def _plugin_name():
172
+ """A stable per-plugin identifier for THIS install's drop-in `.path`
173
+ filename (ADR-0014). Host caches insert a version directory between the
174
+ plugin name and `hooks/`, so the package-directory basename is not stable.
175
+ Prefer the shipped host manifest. A damaged versioned cache falls back to
176
+ its parent plugin directory; a source-tree install falls back to its
177
+ package-directory basename. Every returned key is filename-safe."""
178
+ hooks_dir_path = os.path.dirname(os.path.abspath(__file__))
179
+ package_dir = os.path.dirname(hooks_dir_path)
180
+ manifests = (
181
+ os.path.join(package_dir, ".claude-plugin", "plugin.json"),
182
+ os.path.join(package_dir, ".codex-plugin", "plugin.json"),
183
+ os.path.join(package_dir, "package.json"),
184
+ )
185
+ safe_name = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
186
+ for manifest in manifests:
187
+ try:
188
+ with open(manifest, encoding="utf-8") as f:
189
+ name = json.load(f).get("name")
190
+ except (OSError, UnicodeError, json.JSONDecodeError, AttributeError):
191
+ continue
192
+ if isinstance(name, str) and safe_name.fullmatch(name):
193
+ return name
194
+
195
+ package_name = os.path.basename(package_dir)
196
+ if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?", package_name):
197
+ package_name = os.path.basename(os.path.dirname(package_dir))
198
+ return package_name if safe_name.fullmatch(package_name or "") else "plugin"
199
+
200
+
201
+ _DROPIN_DIRNAME = "codearbiter-hooksd"
202
+
203
+
204
+ def _git_common_dir(root):
205
+ """The directory `git rev-parse --git-common-dir` would report for
206
+ `root` — resolved WITHOUT a git spawn whenever the on-disk layout is
207
+ cheaply readable, falling back to a real git spawn only when it isn't.
208
+
209
+ Deliberately mirrors --git-common-dir, NOT --git-dir: a linked worktree's
210
+ `.git` is a FILE (not a directory) holding a `gitdir: <path>` pointer into
211
+ `<main>/.git/worktrees/<name>`, and THAT directory in turn holds a
212
+ `commondir` file naming the real, SHARED main `.git`. Every worktree of a
213
+ repo must resolve to the SAME common dir here, or the #265 drop-in dir
214
+ would fork per-worktree and defeat the entire cross-host purpose (a shim
215
+ 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."""
220
+ git_path = os.path.join(root, ".git")
221
+ if os.path.isdir(git_path):
222
+ return os.path.abspath(git_path)
223
+ if os.path.isfile(git_path):
224
+ text = _read(git_path)
225
+ if text:
226
+ for line in text.splitlines():
227
+ line = line.strip()
228
+ if line.lower().startswith("gitdir:"):
229
+ wt_gitdir = line.split(":", 1)[1].strip()
230
+ if not os.path.isabs(wt_gitdir):
231
+ wt_gitdir = os.path.normpath(os.path.join(root, wt_gitdir))
232
+ cd_text = _read(os.path.join(wt_gitdir, "commondir"))
233
+ if cd_text:
234
+ cd = cd_text.strip()
235
+ common = (cd if os.path.isabs(cd)
236
+ else os.path.normpath(os.path.join(wt_gitdir, cd)))
237
+ return os.path.abspath(common)
238
+ break
239
+ r = _git(["rev-parse", "--git-common-dir"], root)
240
+ if r is not None and r.returncode == 0 and r.stdout.strip():
241
+ out = r.stdout.strip()
242
+ return os.path.abspath(out if os.path.isabs(out) else os.path.join(root, out))
243
+ return None
244
+
245
+
246
+ def _dropin_dir(root):
247
+ """The shared, non-versioned drop-in directory (ADR-0014) each installed
248
+ host writes its own `<plugin>.path` entry into. Lives inside the repo's
249
+ git COMMON dir (never a per-worktree one — see `_git_common_dir`) so
250
+ every worktree and every host share exactly ONE directory. Returns None
251
+ when the common dir itself can't be resolved (no git dir at all)."""
252
+ common = _git_common_dir(root)
253
+ return os.path.join(common, _DROPIN_DIRNAME) if common else None
254
+
255
+
256
+ def _path_entry_file(dropin_dir, plugin):
257
+ return os.path.join(dropin_dir, f"{plugin}.path")
258
+
259
+
260
+ def _shell_path(path):
261
+ """Render an absolute native path for the POSIX sh git-hook boundary.
262
+
263
+ Git for Windows executes these hooks through its POSIX shell. Native
264
+ backslashes are ordinary characters there, so both globbing the drop-in
265
+ directory and testing an enforcer entry would fail closed even though the
266
+ Windows files exist. Forward slashes remain valid to Windows Python and
267
+ Git while also being unambiguous to the shell.
268
+ """
269
+ return path.replace("\\", "/")
270
+
271
+
272
+ def _path_entry_current(dropin_dir, plugin, enforcer):
273
+ """True iff this plugin's own drop-in entry already names `enforcer`."""
274
+ existing = _read(_path_entry_file(dropin_dir, plugin))
275
+ return existing is not None and existing.strip() == enforcer
276
+
277
+
278
+ def _write_path_entry(dropin_dir, plugin, enforcer):
279
+ """Best-effort (never fatal) write/refresh of this plugin's OWN
280
+ `<plugin>.path` drop-in entry — the self-heal half of ADR-0014: a live
281
+ host rewrites its own entry every SessionStart regardless of whether the
282
+ shared shim itself needed any change, so a stale entry from a version
283
+ bump never outlives one session on a live install.
284
+
285
+ #441: that self-heal must never pin an EPHEMERAL enforcer. The drop-in dir
286
+ lives in the git COMMON dir, so every linked worktree writes the MAIN
287
+ repository's entry (`TestDropInSharedDir` proves the sharing, and it is the
288
+ point of ADR-0014). A session started inside a worktree — subagents do this
289
+ routinely — therefore repoints the main repo's enforcer at a directory that
290
+ vanishes the moment that worktree is pruned, which is what worktrees are
291
+ for.
292
+
293
+ Losing this entry is SILENT gate loss, not a visible break like #438's
294
+ statusline: H-01, H-03, H-05, H-09b, H-10b, H-11 and H-19 all run through
295
+ the enforcer named here, and a repo that loses it keeps looking governed.
296
+ So an ephemeral enforcer is refused outright, leaving whatever durable entry
297
+ is already there untouched — a stale-but-durable enforcer still enforces, an
298
+ absent one does not.
299
+
300
+ A stale but DURABLE path is still refreshed exactly as before; the guard is
301
+ not a kill-switch. `is_ephemeral_path` fails toward "durable", so an
302
+ unreadable or unrecognised layout keeps the old behaviour rather than
303
+ silently disabling the self-heal."""
304
+ if is_ephemeral_path(enforcer):
305
+ _warn(f"'{plugin}' was loaded from a path that will not outlive this session "
306
+ f"({enforcer}); leaving the shared enforcer entry as it is. Git-level "
307
+ f"enforcement keeps using the previously registered install. Start a "
308
+ f"session from the main checkout to refresh it.")
309
+ return False
310
+ shell_enforcer = _shell_path(enforcer)
311
+ if _path_entry_current(dropin_dir, plugin, shell_enforcer):
312
+ return True
313
+ try:
314
+ os.makedirs(dropin_dir, exist_ok=True)
315
+ _hooklib.write_text_atomic(
316
+ _path_entry_file(dropin_dir, plugin), shell_enforcer + "\n", newline="\n")
317
+ return True
318
+ except Exception as e: # noqa: BLE001
319
+ _warn(f"could not write drop-in enforcer entry for '{plugin}' at {dropin_dir}: {e}")
320
+ return False
321
+
322
+
323
+ def _seen_marker_file(dropin_dir, plugin):
324
+ return os.path.join(dropin_dir, f"{plugin}.seen")
325
+
326
+
327
+ def _touch_seen_marker(dropin_dir, plugin, enforcer):
328
+ """Best-effort (never fatal) freshness heartbeat for `plugin` (#556).
329
+
330
+ Records the SAME enforcer value `_write_path_entry` just confirmed in
331
+ `<plugin>.path` — content-addressed on purpose. The freshness guard below
332
+ only trusts this heartbeat's mtime when its recorded content still
333
+ matches `<plugin>.path`'s CURRENT content; a raw mtime-only heartbeat
334
+ (compared only against `.path`'s own mtime) is a sub-millisecond race on
335
+ some filesystems whenever a `.path` entry is rewritten by something other
336
+ than `install()` shortly after a real install (exactly what several
337
+ existing drop-in tests simulate to probe unrelated behavior) — content
338
+ equality has no such timing dependency.
339
+
340
+ Unlike `<plugin>.path` (which `_write_path_entry` deliberately leaves
341
+ untouched when its content hasn't changed, to avoid churn), THIS file is
342
+ rewritten every live session regardless of whether the `.path` entry
343
+ itself changed — it is the signal a sibling plugin's cache has gone
344
+ stale. A plugin whose host never runs a session again simply stops
345
+ updating its `.seen` file, which is exactly the staleness #556 needs
346
+ surfaced.
347
+
348
+ Callers must skip this for an ephemeral enforcer (mirroring
349
+ `_write_path_entry`'s own refusal) — an ephemeral session confirming
350
+ freshness would be exactly the wrong direction: it would make a
351
+ sibling's genuinely durable, still-correct entry look stale by
352
+ comparison."""
353
+ try:
354
+ os.makedirs(dropin_dir, exist_ok=True)
355
+ _hooklib.write_text_atomic(
356
+ _seen_marker_file(dropin_dir, plugin), _shell_path(enforcer) + "\n", newline="\n")
357
+ except Exception: # noqa: BLE001
358
+ pass
359
+
360
+
361
+ # The freshness probe embedded VERBATIM into every generated shim (via a
362
+ # stdin heredoc, see `_shim()`) AND run identically by `stale_registered_plugins`
363
+ # below for `/ca:doctor` (#556, AC-3). Deliberately a single string constant
364
+ # run in BOTH places rather than two hand-kept implementations: the shim
365
+ # cannot import any plugin's `_githooks.py` to get this logic (whichever
366
+ # plugin's copy it picked could itself be the stale one this guard exists to
367
+ # distrust — the exact #556 failure, one level up), so it must be entirely
368
+ # self-contained text that the CURRENTLY installing (never stale — the shim
369
+ # file itself is regenerated by whatever live host runs `install()`, see
370
+ # SENTINEL) session bakes in. `/ca:doctor` runs the SAME text as a real
371
+ # subprocess instead of a parallel port, so the two can never drift.
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
375
+ # drop-in dir has recorded a `.seen` heartbeat, AND (b) this plugin's own
376
+ # heartbeat is either absent or strictly older than the freshest one seen.
377
+ # When NOBODY has ever recorded a heartbeat (a repo that predates #556, or
378
+ # every registered plugin genuinely dormant), nothing is printed — the
379
+ # caller then treats every entry as before this fix (the original, safe
380
+ # fail-closed "run everything" default), never silently disabling
381
+ # enforcement outright.
382
+ _FRESHNESS_PY = (
383
+ "import os, re, sys\n"
384
+ "d = sys.argv[1] if len(sys.argv) > 1 else ''\n"
385
+ "try:\n"
386
+ " names = os.listdir(d)\n"
387
+ "except OSError:\n"
388
+ " names = []\n"
389
+ "legacy = re.compile(r'^[0-9]+\\.[0-9]+\\.[0-9]+$')\n"
390
+ "def _rd(p):\n"
391
+ " try:\n"
392
+ " with open(p, encoding='utf-8', errors='replace') as f:\n"
393
+ " return f.read().strip()\n"
394
+ " except OSError:\n"
395
+ " return None\n"
396
+ "entries = []\n"
397
+ "for n in sorted(names):\n"
398
+ " if not n.endswith('.path'):\n"
399
+ " continue\n"
400
+ " plugin = n[:-len('.path')]\n"
401
+ " if legacy.fullmatch(plugin):\n"
402
+ " continue\n"
403
+ " path_val = _rd(os.path.join(d, n))\n"
404
+ " if path_val is None:\n"
405
+ " continue\n"
406
+ " seen_file = os.path.join(d, plugin + '.seen')\n"
407
+ # `.seen` only counts as a confirmation of what's registered RIGHT NOW when
408
+ # its recorded value still matches `.path`'s CURRENT content -- content
409
+ # equality, never a raw mtime-ordering guess. A `.path` entry rewritten by
410
+ # something other than install() (a version bump landing between two
411
+ # sessions, or -- in this suite's own drop-in fixtures -- a direct
412
+ # overwrite that never re-confirms) leaves a `.seen` file whose value no
413
+ # longer matches, which must NOT count as evidence for the new content: a
414
+ # sub-millisecond mtime race between two nearly-simultaneous writes is not
415
+ # a reliable ordering signal on every filesystem, but string equality has
416
+ # no timing dependency at all.
417
+ " confirmed = None\n"
418
+ " if _rd(seen_file) == path_val:\n"
419
+ " try:\n"
420
+ " confirmed = os.stat(seen_file).st_mtime\n"
421
+ " except OSError:\n"
422
+ " confirmed = None\n"
423
+ " entries.append((plugin, confirmed))\n"
424
+ "known = [m for _, m in entries if m is not None]\n"
425
+ "if known:\n"
426
+ " mx = max(known)\n"
427
+ " for plugin, m in entries:\n"
428
+ " if m is None or m < mx:\n"
429
+ " print(plugin)\n"
430
+ )
431
+
432
+
433
+ def stale_registered_plugins(dropin_dir):
434
+ """Plugin names whose drop-in `.path` entry the generated shim's
435
+ freshness guard (#556) will SKIP at the next commit/push, because a
436
+ fresher registered sibling exists. Runs `_FRESHNESS_PY` as a real
437
+ subprocess of THIS interpreter — never a hand-kept parallel
438
+ implementation — so `/ca:doctor` (AC-3) and the shim can never disagree.
439
+
440
+ Returns [] when `dropin_dir` doesn't exist, nothing is registered, or
441
+ the probe fails for any reason — a diagnostic must never be able to
442
+ raise into its caller."""
443
+ if not dropin_dir or not os.path.isdir(dropin_dir):
444
+ return []
445
+ try:
446
+ r = subprocess.run(
447
+ [sys.executable, "-", dropin_dir], input=_FRESHNESS_PY,
448
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
449
+ timeout=5,
450
+ )
451
+ except Exception: # noqa: BLE001
452
+ return []
453
+ if r.returncode != 0:
454
+ return []
455
+ return [ln for ln in r.stdout.splitlines() if ln.strip()]
456
+
457
+
458
+ _TRUSTED_IDENTITY_FILE = "trusted-executables.identity"
459
+
460
+
461
+ def _identity_file(dropin_dir):
462
+ return os.path.join(dropin_dir, _TRUSTED_IDENTITY_FILE)
463
+
464
+
465
+ def _read_trusted_identity(dropin_dir):
466
+ text = _read(_identity_file(dropin_dir))
467
+ if text is None:
468
+ return None
469
+ lines = text.splitlines()
470
+ if len(lines) != 3 or not lines[2]:
471
+ return None
472
+ python_path, git_path, owner = lines
473
+ if not os.path.isfile(python_path) or not os.path.isfile(git_path):
474
+ return None
475
+ return python_path, git_path, owner
476
+
477
+
478
+ def _refresh_trusted_identity(dropin_dir, plugin):
479
+ """Persist trusted executables without permitting an identity-less host
480
+ session to downgrade the shared shim back to PATH resolution."""
481
+ trusted_git = trusted_git_executable()
482
+ trusted_python = trusted_python_executable()
483
+ existing = _read_trusted_identity(dropin_dir)
484
+ if trusted_git is None and trusted_python is None:
485
+ return True
486
+ if trusted_git is None or trusted_python is None:
487
+ if existing is not None:
488
+ _warn("trusted executable refresh is incomplete; preserving the prior complete identity")
489
+ return True
490
+ raise RuntimeError("codeArbiter executable identity channel is incomplete")
491
+ values = (_shell_path(trusted_python), _shell_path(trusted_git), plugin)
492
+ if any("\n" in value or "\r" in value for value in values):
493
+ raise RuntimeError("trusted executable identity contains a newline")
494
+ payload = "\n".join(values) + "\n"
495
+ try:
496
+ os.makedirs(dropin_dir, exist_ok=True)
497
+ _hooklib.write_text_atomic(_identity_file(dropin_dir), payload, newline="\n")
498
+ return True
499
+ except Exception as e: # noqa: BLE001
500
+ if existing is not None:
501
+ _warn(f"could not refresh trusted identity; preserving prior complete identity: {e}")
502
+ return True
503
+ raise RuntimeError(
504
+ f"could not persist trusted executable identity at {dropin_dir}: {e}") from e
505
+
506
+
507
+ def _shim(dropin_dir, phase):
508
+ # Single-interpreter selection preserves stdin (pre-push) and the BLOCK
509
+ # exit code. The shim is HOST-NEUTRAL (ADR-0014): it embeds no plugin-
510
+ # specific enforcer path, only the shared drop-in directory. It iterates
511
+ # every "*.path" entry there and runs every enforcer that resolves AND is
512
+ # not recognized as stale (#556, below) — any non-zero verdict from one of
513
+ # those blocks. A dead entry from an uninstalled plugin is skipped. An
514
+ # unmatched glob (dir absent or
515
+ # empty) leaves `c` as the literal, un-expanded "$D/*.path" string in
516
+ # POSIX `sh` — `[ -f "$c" ]` on that literal correctly fails too, so the
517
+ # loop falls straight through to the same fail-closed tail with no special
518
+ # case needed. When NOTHING resolves, this now FAILS CLOSED: a loud
519
+ # stderr diagnostic and a non-zero exit, blocking the git operation,
520
+ # rather than the old single-plugin era's `exit 0`. When the Pi bridge
521
+ # provides trusted executable identities, install() persists them beside
522
+ # the registry. Identity-less hosts preserve that set, so a later Claude or
523
+ # Codex session cannot downgrade Pi's absolute executable boundary.
524
+ #
525
+ # #556 (AC-1): "any non-zero verdict blocks" used to mean an entry that
526
+ # nobody has refreshed in months — a host cache that predates a fix THIS
527
+ # checkout already carries, e.g. the #279 sensitive-scan exemption — could
528
+ # resurrect an already-closed false positive with no in-session exit but
529
+ # an override. Before running the loop, `$SKIP` is populated (via
530
+ # `_FRESHNESS_PY`, run once here from a heredoc so this is never delegated
531
+ # to any specific plugin's own — possibly stale — `_githooks.py`) with the
532
+ # plugin names whose `.seen` heartbeat (written every live session,
533
+ # unconditionally, by `install()`) is missing or older than a sibling's.
534
+ # Those entries are skipped WITHOUT running their python at all, deferring
535
+ # to whichever registered sibling a live session confirmed more recently.
536
+ # When NO entry anywhere has ever recorded a heartbeat (a repo that
537
+ # predates this fix, or a wholly dormant install), `$SKIP` is empty and
538
+ # every entry runs exactly as before — this can only ever narrow which
539
+ # entries run, never widen it, so a genuine `SEEN=0` fail-closed case is
540
+ # unaffected.
541
+ def quote(value):
542
+ return "'" + value.replace("'", "'\"'\"'") + "'"
543
+
544
+ capture = ""
545
+ invoke = f'"$PY" "$E" {phase}\n'
546
+ if phase == "pre-push":
547
+ capture = (
548
+ "PUSH_INPUT=''\n"
549
+ "while IFS= read -r L; do\n"
550
+ " PUSH_INPUT=\"${PUSH_INPUT}${L}\n\"\n"
551
+ "done\n"
552
+ )
553
+ invoke = f'printf \'%s\' "$PUSH_INPUT" | "$PY" "$E" {phase}\n'
554
+ return (
555
+ "#!/bin/sh\n"
556
+ f"{SENTINEL}\n"
557
+ f"D={quote(_shell_path(dropin_dir))}\n"
558
+ f'if [ -e "$D/{_TRUSTED_IDENTITY_FILE}" ] || [ -L "$D/{_TRUSTED_IDENTITY_FILE}" ]; then\n'
559
+ f' [ -f "$D/{_TRUSTED_IDENTITY_FILE}" ] || exit 1\n'
560
+ f' exec 3< "$D/{_TRUSTED_IDENTITY_FILE}" || exit 1\n'
561
+ ' IFS= read -r PY <&3 || exit 1\n'
562
+ ' IFS= read -r G <&3 || exit 1\n'
563
+ ' IFS= read -r IDENTITY_OWNER <&3 || exit 1\n'
564
+ " IDENTITY_EXTRA=''\n"
565
+ ' if IFS= read -r IDENTITY_EXTRA <&3 || [ -n "$IDENTITY_EXTRA" ]; then exit 1; fi\n'
566
+ ' exec 3<&-\n'
567
+ ' [ -n "$IDENTITY_OWNER" ] && [ -f "$PY" ] && [ -f "$G" ] || exit 1\n'
568
+ ' export CODEARBITER_GIT_EXECUTABLE="$G"\n'
569
+ ' export CODEARBITER_PYTHON_EXECUTABLE="$PY"\n'
570
+ "else\n"
571
+ ' if python3 -c "" 2>/dev/null; then PY=python3; else PY=python; fi\n'
572
+ "fi\n"
573
+ f"{capture}"
574
+ # #556: computed once per hook firing, from a literal heredoc (never
575
+ # an `import` of any plugin's own `_githooks.py`) so this stays
576
+ # correct even when every REGISTERED enforcer is stale — only the
577
+ # currently-installing session's freshly generated shim needs to be
578
+ # current for this to work. A crash/empty result here just leaves
579
+ # $SKIP empty (see `[ "$RC" -eq 0 ] || exit "$RC"` below — command
580
+ # substitution failure doesn't abort `sh`), the original run-everything
581
+ # behavior.
582
+ "SKIP=$(\"$PY\" - \"$D\" <<'CODEARBITER_556_FRESHNESS'\n"
583
+ f"{_FRESHNESS_PY}"
584
+ "CODEARBITER_556_FRESHNESS\n"
585
+ ")\n"
586
+ "SEEN=0\n"
587
+ 'for c in "$D"/*.path; do\n'
588
+ ' [ -f "$c" ] || continue\n'
589
+ ' N=${c##*/}\n'
590
+ ' case "$N" in [0-9]*.[0-9]*.[0-9]*.path) continue ;; esac\n'
591
+ ' case " $SKIP " in *" ${N%.path} "*) continue ;; esac\n'
592
+ ' IFS= read -r E < "$c" || continue\n'
593
+ ' [ -f "$E" ] || continue\n'
594
+ ' SEEN=1\n'
595
+ f' {invoke}'
596
+ ' RC=$?\n'
597
+ ' [ "$RC" -eq 0 ] || exit "$RC"\n'
598
+ 'done\n'
599
+ '[ "$SEEN" -eq 0 ] || exit 0\n'
600
+ 'echo "codeArbiter: no registered git-enforce.py could be resolved from '
601
+ '\\"$D\\" -- failing CLOSED (#161/#265 git backstop, ADR-0014). Reinstall '
602
+ 'codeArbiter, or check .git/codearbiter-hooksd/*.path entries." >&2\n'
603
+ 'exit 1\n'
604
+ )
605
+
606
+
607
+ def _read(path):
608
+ try:
609
+ with open(path, encoding="utf-8", errors="replace") as f:
610
+ return f.read()
611
+ except Exception: # noqa: BLE001
612
+ return None
613
+
614
+
615
+ def _local_config_paths(root):
616
+ """git-config files that could define a LOCAL core.hooksPath override for
617
+ `root` — `.git/config` (always checked, even if the file happens to be
618
+ missing — see _confirmed_no_local_hooks_path) plus `.git/config.worktree`
619
+ (extensions.worktreeConfig repos), when it exists. Deliberately excludes
620
+ global/system config — see the module header's documented residual."""
621
+ git_dir = os.path.join(root, ".git")
622
+ return [os.path.join(git_dir, "config"), os.path.join(git_dir, "config.worktree")]
623
+
624
+
625
+ def _confirmed_no_local_hooks_path(root):
626
+ """True ONLY if a direct, no-git-spawn read of the config file(s) that
627
+ could set a LOCAL core.hooksPath for `root` positively confirms NONE of
628
+ them could possibly do so.
629
+
630
+ GRAMMAR-FREE by design (HIGH-severity fix, second spelling of the same
631
+ skip -> backstop-unwire class): git's config grammar honors a variable on
632
+ the SAME line as its section header (`[core] hooksPath = x`,
633
+ `[core]hooksPath=x`, `[CORE]HooksPath=x` are all valid and honored by real
634
+ git), plus quoting/continuation/case variations — a hand-rolled
635
+ line-oriented section/key parser reliably misses some of these spellings.
636
+ Rather than chase git's config grammar (an unbounded set of spellings),
637
+ this check is a single case-insensitive SUBSTRING scan for `hookspath`
638
+ anywhere in the file, plus a substring scan for an `[include`/`[includeif`
639
+ directive (which could pull a hooksPath in from elsewhere, unfollowed by
640
+ this check). Any occurrence of either substring — even inside a comment —
641
+ or any read failure, returns False (not confirmed). This can never
642
+ UNDER-detect a real hooksPath key (a real key always contains the
643
+ substring "hookspath" case-insensitively, by definition of the git-config
644
+ keyword), so it can only ever be OVER-cautious (an extra, harmless
645
+ git-spawn fall-through on a false positive, e.g. a stray comment
646
+ mentioning the word) — never falsely confirm an override is absent when
647
+ one is actually present. That asymmetry is exactly the fail-direction the
648
+ fast path requires: "install when unsure," never "skip when unsure." A
649
+ simply-ABSENT `config.worktree` is not an error (most repos don't have
650
+ one) and contributes no override, exactly like git itself.
651
+
652
+ This is the fail-direction-critical check (CRITICAL/HIGH fix, post-#194):
653
+ the fast path in install() must never trust a cached hooks_dir without
654
+ this positive confirmation, or a later `core.hooksPath` change (husky /
655
+ pre-commit-framework) would silently leave the NEW hooks dir unwired."""
656
+ for path in _local_config_paths(root):
657
+ if not os.path.isfile(path):
658
+ continue # absent -> no override possible from this file
659
+ text = _read(path)
660
+ if text is None:
661
+ return False # exists but unreadable -> can't confirm -> unsafe to skip
662
+ lowered = text.lower()
663
+ if "hookspath" in lowered:
664
+ return False # ANY spelling/placement/casing -> can't confirm absent
665
+ if "[include" in lowered:
666
+ return False # could pull in a hooksPath from elsewhere -> can't confirm
667
+ return True
668
+
669
+
670
+ def _xdg_git_config_path():
671
+ """The XDG git global-config path git ALSO reads (lower precedence than
672
+ ~/.gitconfig, but still consulted): `$XDG_CONFIG_HOME/git/config`, or
673
+ `~/.config/git/config` when XDG_CONFIG_HOME is unset — matching git's own
674
+ fallback."""
675
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
676
+ return os.path.join(base, "git", "config")
677
+
678
+
679
+ def _file_mtime_token(path):
680
+ """A cheap cache-invalidation token for `path`: its mtime, or the literal
681
+ 'absent' if it doesn't exist."""
682
+ try:
683
+ return repr(os.stat(path).st_mtime)
684
+ except OSError:
685
+ return "absent"
686
+
687
+
688
+ def _global_gitconfig_mtime_token():
689
+ """A cheap cache-invalidation token covering BOTH global git-config
690
+ locations codeArbiter can cheaply stat by a fixed path: `~/.gitconfig` and
691
+ the XDG `~/.config/git/config` (or `$XDG_CONFIG_HOME/git/config`).
692
+ Included in the on-disk cache so a LATER edit to either (e.g. adding a
693
+ global core.hooksPath) invalidates a previously-fast-pathable cache
694
+ instead of silently going unnoticed. Does NOT cover a `$GIT_CONFIG_GLOBAL`/
695
+ `$GIT_CONFIG_SYSTEM` env override repointing the file entirely, nor
696
+ `/etc/gitconfig` — see the module header's documented residual."""
697
+ return f"{_file_mtime_token(os.path.join(os.path.expanduser('~'), '.gitconfig'))}|" \
698
+ f"{_file_mtime_token(_xdg_git_config_path())}"
699
+
700
+
701
+ def _cached_hooks_dir(root):
702
+ """The last hooks_dir() a successful resolution used for `root`, read from
703
+ the on-disk cache — NO git spawn. Returns None (cache miss) if the cache
704
+ file is absent/unreadable/blank/malformed, if it names a directory that no
705
+ longer exists (e.g. deleted between sessions), or if either global
706
+ git-config location (~/.gitconfig, the XDG git config) has changed since
707
+ the cache was written (see _global_gitconfig_mtime_token). A None return
708
+ always falls the caller through to the real git-based hooks_dir() probe."""
709
+ git_dir = os.path.join(root, ".git")
710
+ if not os.path.isdir(git_dir):
711
+ return None
712
+ text = _read(os.path.join(git_dir, _HOOKSDIR_CACHE_NAME))
713
+ if not text:
714
+ return None
715
+ lines = text.splitlines()
716
+ if len(lines) < 2:
717
+ return None # malformed/legacy cache shape -> treat as a miss
718
+ hd, stored_token = lines[0].strip(), lines[1].strip()
719
+ if not hd or not os.path.isdir(hd):
720
+ return None
721
+ if stored_token != _global_gitconfig_mtime_token():
722
+ return None # a covered global git-config location changed since this cache was written
723
+ return hd
724
+
725
+
726
+ def _write_hooks_dir_cache(root, hd):
727
+ """Best-effort persistence of the resolved hooks_dir (+ the global
728
+ git-config invalidation token) so a LATER session can skip the
729
+ git-config/rev-parse re-probe (performance-002) when nothing has changed.
730
+ Any failure — including `.git` being a FILE, not a directory, for a linked
731
+ worktree — is swallowed: this cache is a pure optimization and is never
732
+ allowed to affect whether hooks actually get installed."""
733
+ git_dir = os.path.join(root, ".git")
734
+ if not os.path.isdir(git_dir):
735
+ return
736
+ try:
737
+ payload = f"{hd}\n{_global_gitconfig_mtime_token()}\n"
738
+ _hooklib.write_text_atomic(
739
+ os.path.join(git_dir, _HOOKSDIR_CACHE_NAME), payload, newline="\n")
740
+ except Exception: # noqa: BLE001 — best-effort cache, never fatal
741
+ pass
742
+
743
+
744
+ def _hooks_current(hd, dropin_dir):
745
+ """True iff BOTH phase shims at `hd` already match what install() would
746
+ write right now for `dropin_dir` — i.e. install() would be a complete
747
+ no-op for the SHIM files themselves. Filesystem-only (no git spawn): this
748
+ is exactly the check that lets install() skip the git-config/rev-parse
749
+ re-probe when a prior session already installed current hooks. A foreign
750
+ (non-sentinel) hook, a stale shim, or a missing file all correctly return
751
+ False here, falling the caller through to the full probe (which then
752
+ re-derives the right action: refresh, warn-and-preserve, or install
753
+ fresh).
754
+
755
+ Note (ADR-0014): the shim is host-neutral — it depends only on
756
+ `dropin_dir` (repo-derived, stable across plugin versions), never on this
757
+ plugin's own enforcer path. So a plugin-version bump that only changes
758
+ `_enforcer_path()` does NOT make this return False; install() refreshes
759
+ the plugin's OWN drop-in `.path` entry unconditionally every call,
760
+ independent of whether this check short-circuits the shim-file rewrite."""
761
+ for phase in PHASES:
762
+ existing = _read(os.path.join(hd, phase))
763
+ if existing is None or existing != _shim(dropin_dir, phase):
764
+ return False
765
+ return True
766
+
767
+
768
+ def _default_hooks_dir(root):
769
+ return os.path.join(root, ".git", "hooks")
770
+
771
+
772
+ def install(root):
773
+ """Ensure the git-level enforcement hooks are installed for `root`.
774
+ Idempotent and safe to call every session. Returns a list of human-readable
775
+ actions taken (possibly empty). Never raises for an expected condition
776
+ (no git dir, foreign hook) — those are reported, not fatal.
777
+
778
+ performance-002 (#194): before doing any git spawn, checks a cheap on-disk
779
+ cache of the last resolved hooks_dir. The fast path (zero git subprocess
780
+ calls) fires ONLY when ALL of the following hold — every one of them is a
781
+ cheap, no-git-spawn check:
782
+ 1. a cached hooks_dir exists and still exists on disk;
783
+ 2. that cached dir is EXACTLY the default `<root>/.git/hooks` — a cached
784
+ CUSTOM hooksPath is never fast-pathed, since a custom path is exactly
785
+ the kind of value that gets repointed later;
786
+ 3. a direct read of `.git/config` (+ `.git/config.worktree`) positively
787
+ CONFIRMS no local core.hooksPath override is set right now (see
788
+ _confirmed_no_local_hooks_path — CRITICAL fix, post-#194: the
789
+ original cut skipped this check entirely, so a LOCAL hooksPath added
790
+ after the cache was written — e.g. adopting husky / pre-commit-
791
+ framework — silently left the NEW hooks dir unwired while returning
792
+ `[]`);
793
+ 4. the shims at that dir are already current for the drop-in dir
794
+ (_hooks_current — ADR-0014: the shim depends only on `dropin_dir`,
795
+ never on this plugin's own enforcer path).
796
+ Any single miss/mismatch — including a genuine cold install, a foreign
797
+ hook, a changed core.hooksPath, or ambiguity in the config read — falls
798
+ through to the original git-based probe below, unchanged. Fail direction
799
+ is "install when unsure," never "skip when unsure".
800
+
801
+ ADR-0014: regardless of which path this function takes (fast path or full
802
+ probe), THIS plugin's own drop-in `<plugin>.path` entry is refreshed
803
+ every single call — a live host self-heals a stale entry (e.g. after a
804
+ version bump moved `_enforcer_path()`) every SessionStart, independent of
805
+ whether the shared shim FILE itself needed any rewrite."""
806
+ plugin = _plugin_name()
807
+ enforcer = _enforcer_path()
808
+ dropin_dir = _dropin_dir(root)
809
+ if dropin_dir is None:
810
+ return [] # no resolvable git dir at all — nothing to install against
811
+ _refresh_trusted_identity(dropin_dir, plugin)
812
+ cached_hd = _cached_hooks_dir(root)
813
+ if cached_hd is not None:
814
+ default_hd = os.path.normcase(os.path.abspath(_default_hooks_dir(root)))
815
+ cached_norm = os.path.normcase(os.path.abspath(cached_hd))
816
+ if (cached_norm == default_hd
817
+ and _confirmed_no_local_hooks_path(root)
818
+ and _hooks_current(cached_hd, dropin_dir)):
819
+ if _write_path_entry(dropin_dir, plugin, enforcer):
820
+ _touch_seen_marker(dropin_dir, plugin, enforcer) # #556 freshness heartbeat
821
+ return []
822
+ hd = hooks_dir(root)
823
+ if not hd:
824
+ return []
825
+ try:
826
+ os.makedirs(hd, exist_ok=True)
827
+ except Exception: # noqa: BLE001
828
+ _warn(f"could not create hooks dir {hd}; skipping git-hook install")
829
+ return []
830
+ actions = []
831
+ for phase in PHASES:
832
+ dest = os.path.join(hd, phase)
833
+ desired = _shim(dropin_dir, phase)
834
+ if os.path.exists(dest):
835
+ existing = _read(dest)
836
+ if existing is not None and SENTINEL not in existing:
837
+ _warn(f"an existing {phase} hook is not codeArbiter-managed — leaving it "
838
+ f"untouched. For git-level enforcement, call "
839
+ f"'{os.path.basename(enforcer)} {phase}' from it (see includes docs).")
840
+ actions.append(f"{phase}: foreign hook preserved (not installed)")
841
+ continue
842
+ if existing == desired:
843
+ continue # already current — no churn
844
+ try:
845
+ # reliability-010: atomic sibling-temp + os.replace (mirrors
846
+ # write_provenance/save_state). A crash mid-write with a plain
847
+ # open('w') could leave a sentinel-less partial shim that the
848
+ # foreign-hook guard above then preserves forever; os.replace
849
+ # guarantees `dest` is either the complete new shim or the prior
850
+ # (sentinel-bearing, or absent) file — never a torn write.
851
+ _hooklib.write_text_atomic(dest, desired, newline="\n")
852
+ st = os.stat(dest)
853
+ os.chmod(dest, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
854
+ actions.append(f"{phase}: installed")
855
+ except Exception as e: # noqa: BLE001
856
+ _warn(f"could not write {dest}: {e}")
857
+ # Cache the resolved location so the NEXT call can skip the git-config/
858
+ # rev-parse re-probe entirely (performance-002) — best-effort, never fatal.
859
+ _write_hooks_dir_cache(root, hd)
860
+ # ADR-0014: refresh THIS plugin's own drop-in entry every call, whether or
861
+ # not the shim files above needed a rewrite. #556: the `.seen` heartbeat
862
+ # is touched on every successful confirmation too (never skipped for
863
+ # "no churn" the way the `.path` entry itself is) — it is the freshness
864
+ # guard's only signal that a LIVE session confirmed this entry today.
865
+ if _write_path_entry(dropin_dir, plugin, enforcer):
866
+ _touch_seen_marker(dropin_dir, plugin, enforcer)
867
+ return actions
868
+
869
+
870
+ def uninstall(root):
871
+ """Remove ONLY this plugin's OWN drop-in `<plugin>.path` entry (ADR-0014).
872
+
873
+ Deliberately does NOT touch the shared shim file (.git/hooks/pre-commit /
874
+ pre-push) — that shim is host-neutral and a sibling plugin may still
875
+ depend on it. Leaving a genuinely EMPTY drop-in dir behind (every plugin
876
+ uninstalled) is the intended fail-closed contract, not a bug: the next
877
+ commit finds no resolvable enforcer and blocks with a clear diagnostic
878
+ (see `_shim`'s tail), rather than the old single-plugin era silently
879
+ passing. Returns the actions taken."""
880
+ plugin = _plugin_name()
881
+ dropin_dir = _dropin_dir(root)
882
+ if dropin_dir is None:
883
+ return []
884
+ actions = []
885
+ entry = _path_entry_file(dropin_dir, plugin)
886
+ if os.path.isfile(entry):
887
+ try:
888
+ os.remove(entry)
889
+ actions.append(f"{plugin}.path: removed")
890
+ except Exception as e: # noqa: BLE001
891
+ _warn(f"could not remove {entry}: {e}")
892
+ # #556: drop this plugin's OWN freshness heartbeat alongside its `.path`
893
+ # entry — a genuinely uninstalled plugin must not keep looking "live" to
894
+ # the freshness guard above (it would otherwise sit there, forever
895
+ # confirmed-fresh at its last mtime, potentially outranking a sibling
896
+ # that IS still being maintained).
897
+ seen = _seen_marker_file(dropin_dir, plugin)
898
+ if os.path.isfile(seen):
899
+ try:
900
+ os.remove(seen)
901
+ actions.append(f"{plugin}.seen: removed")
902
+ except Exception as e: # noqa: BLE001
903
+ _warn(f"could not remove {seen}: {e}")
904
+ identity = _read_trusted_identity(dropin_dir)
905
+ if identity is not None and identity[2] == plugin:
906
+ path = _identity_file(dropin_dir)
907
+ try:
908
+ os.remove(path)
909
+ actions.append(f"{plugin} trusted identity: removed")
910
+ except Exception as e: # noqa: BLE001
911
+ _warn(f"could not remove {path}: {e}")
912
+ return actions
913
+
914
+
915
+ if __name__ == "__main__":
916
+ # Manual install/uninstall: `python _githooks.py [install|uninstall] [root]`.
917
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "install"
918
+ where = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
919
+ done = uninstall(where) if cmd == "uninstall" else install(where)
920
+ print(f"{cmd}: " + (", ".join(done) if done else "no changes"))