@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,1080 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — read-inject helpers for file-scoped just-in-time context injection.
3
+ #
4
+ # Builds the hook response dict and (in later tasks) the file->knowledge map,
5
+ # budget assembler, and freshness gate for the PreToolUse:Read hook (pre-read.py).
6
+ # This module holds all testable pure logic; pre-read.py is the thin entry point.
7
+ #
8
+ # Design invariants (mirroring _provenancelib.py / _taskboardlib.py):
9
+ # - Stdlib only; no third-party imports, ever — runs on stock Python.
10
+ # - Zero side effects at import time: no git calls, no file I/O on import.
11
+ # - Pure functions, testable with synthetic input; isolate any filesystem
12
+ # access to explicitly-named reader functions (added in later tasks).
13
+ # - Never raise on malformed input — degrade gracefully (this runs on the
14
+ # PreToolUse hook path; a crash must never block a Read).
15
+ #
16
+ # Public API:
17
+ # allow_output(additional_context) -> dict build the hookSpecificOutput dict
18
+ # that allows the Read and injects
19
+ # additional_context into the model.
20
+ # None is coerced to "".
21
+ # token_estimate(s) -> int token proxy: ceil(len(s) / 4).
22
+ # Empty string or None -> 0. Never raises.
23
+ # assemble_context(pointers, budget=150) assemble the additionalContext payload
24
+ # -> str from an ordered pointer list; result is
25
+ # <= budget tokens; excess is truncated at
26
+ # the cap with a trailing "…" marker.
27
+ # security_pointer(path) -> dict | None tier 1: pointer to security-controls.md
28
+ # for a security-entry path (auth/middleware/
29
+ # jwt token match), else None. Pure; never
30
+ # raises. No provenance data consulted.
31
+ # accepted_adr_index(root) -> list tier 2 FS reader: scan
32
+ # <root>/.codearbiter/decisions/ for
33
+ # [0-9]+-.+\.md; parse frontmatter (cap 26
34
+ # lines); return [{"adr", "title", "globs"}]
35
+ # for ADRs with status==accepted (case-
36
+ # insensitive) AND a non-empty governs: list.
37
+ # Missing dir -> []. Malformed file -> skip.
38
+ # Never raises. No cache.
39
+ # adr_pointers(rel, index) -> list tier 2 pure matcher: for each index entry
40
+ # whose globs fnmatch-match rel, produce a
41
+ # pointer dict {"text", "tier": "decisions"}.
42
+ # [] when nothing matches, index is empty, or
43
+ # input is malformed. Pure; never raises.
44
+ # parse_spec_governs(text) -> list tier 3 PURE: find the FIRST **Governs:**
45
+ # line in a spec's text; return its comma-
46
+ # separated globs (stripped, empties dropped).
47
+ # No such line -> []. Case-insensitive on
48
+ # "Governs". Never raises.
49
+ # approved_spec_index(root) -> list tier 3 FS reader: scan
50
+ # <root>/.codearbiter/specs/*.md; keep specs
51
+ # with non-empty Governs AND **Status:**
52
+ # beginning with "approved" (case-insensitive;
53
+ # e.g. "approved (2026-06-26)" qualifies,
54
+ # "draft (pending approval)" does not).
55
+ # Status may be inline after · separators.
56
+ # Returns [{"spec": "<stem>", "globs": [...]}].
57
+ # Missing dir -> []. Malformed file -> skip.
58
+ # Never raises. No cache.
59
+ # spec_pointers(rel, index) -> list tier 3 PURE matcher: for each index entry
60
+ # whose any glob fnmatch-matches rel, produce
61
+ # {"text": "spec <slug> governs this file —
62
+ # implement to its acceptance criteria.",
63
+ # "tier": "specs"}. [] when nothing matches,
64
+ # index is empty, or input is malformed.
65
+ # Pure; never raises.
66
+ # provenance_pointer(rel, provenance, tier 4 PURE comparator: freshness-gated
67
+ # current_hashes) -> list pointers from provenance entries for rel.
68
+ # Does NOT call git or batch_hash — caller
69
+ # supplies precomputed current_hashes so a
70
+ # non-matching Read pays no git cost (AC-11).
71
+ # Emits one pointer per FRESH entry (stored
72
+ # hash non-null AND rel in current_hashes AND
73
+ # hashes match). SUPPRESSES diverged/
74
+ # unverifiable/null-hash entries. [] when
75
+ # provenance is empty/{} or no path match.
76
+ # Pointer: {"text": "<doc>.md notes …",
77
+ # "tier": "standards"}. Never raises.
78
+ # governing_docs(rel, index, compose all four tiers in priority order:
79
+ # runner=None) -> list security-controls > decisions > specs >
80
+ # standards. index is the prebuilt dict
81
+ # {"adr":[…], "spec":[…], "provenance":{…}}.
82
+ # Tier-4 (standards) applies the LAZY hashing
83
+ # gate (AC-11): batch_hash is called ONCE only
84
+ # when rel appears as a provenance entry path;
85
+ # a non-provenance Read makes ZERO git calls.
86
+ # [] on any error. Never raises.
87
+ # marker_path(root, session_id, rel) dedup (AC-09): return the absolute path of
88
+ # -> str the per-(session,file) marker under
89
+ # <root>/.codearbiter/.markers/. Filename
90
+ # derived from sha256(session_id+"\0"+rel)
91
+ # for filesystem-safety. PURE — no I/O.
92
+ # Never raises; coerces inputs to str.
93
+ # already_injected(root, dedup (AC-09): True iff the marker file
94
+ # session_id, rel) -> bool exists. On ANY error returns False —
95
+ # degrades toward injecting, never toward
96
+ # wrongly suppressing. Never raises.
97
+ # record_injection(root, dedup (AC-09): create the marker file
98
+ # session_id, rel) -> None (creates .markers/ dir if needed; atomic
99
+ # .tmp + os.replace write). All errors
100
+ # swallowed — a failed write must never
101
+ # break the hook. Never raises.
102
+ # build_index(root) -> dict assemble the prebuilt index governing_docs
103
+ # consumes: {"adr": accepted_adr_index(root),
104
+ # "spec": approved_spec_index(root),
105
+ # "provenance": load_provenance_dir(…)}.
106
+ # Caches adr+spec in <root>/.codearbiter/
107
+ # .markers/readinject-index-cache.json keyed
108
+ # on max mtime of decisions/ and specs/ dirs
109
+ # and their .md files; cache error degrades to
110
+ # fresh build; provenance always fresh; missing
111
+ # dirs → empty sub-structures. Never raises.
112
+ # compute_injection(root, end-to-end orchestrator for AC-09/10/11:
113
+ # session_id, rel, self-read guard → dedup → build_index →
114
+ # runner=None) -> str governing_docs → assemble_context →
115
+ # record_injection. Returns the
116
+ # additionalContext string (possibly "").
117
+ # A non-matching Read makes ZERO runner calls
118
+ # (AC-11 cost guarantee). Never raises.
119
+
120
+ import fnmatch
121
+ import hashlib
122
+ import json
123
+ import math
124
+ import os
125
+ import re
126
+ import unicodedata
127
+
128
+ import _provenancelib
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Constants
132
+ # ---------------------------------------------------------------------------
133
+
134
+ _HOOK_EVENT_NAME = "PreToolUse"
135
+ _PERMISSION_ALLOW = "allow"
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Output builder (AC-03)
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def allow_output(additional_context):
143
+ """Build the hookSpecificOutput dict that always allows the Read.
144
+
145
+ Returns exactly:
146
+ {"hookSpecificOutput": {"hookEventName": "PreToolUse",
147
+ "permissionDecision": "allow",
148
+ "additionalContext": <additional_context>}}
149
+
150
+ `additional_context` is the string injected as additionalContext. None is
151
+ coerced to "" so the dict is always well-formed. The Read is NEVER denied
152
+ by this function — permissionDecision is always "allow".
153
+
154
+ Pure; never raises.
155
+ """
156
+ if additional_context is None:
157
+ additional_context = ""
158
+ return {
159
+ "hookSpecificOutput": {
160
+ "hookEventName": _HOOK_EVENT_NAME,
161
+ "permissionDecision": _PERMISSION_ALLOW,
162
+ "additionalContext": additional_context,
163
+ }
164
+ }
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Token budget helpers (AC-08)
169
+ # ---------------------------------------------------------------------------
170
+
171
+ _ELLIPSIS = "…" # U+2026 HORIZONTAL ELLIPSIS, len == 1
172
+
173
+
174
+ def token_estimate(s):
175
+ """Token proxy: ceil(len(s) / 4).
176
+
177
+ Returns the estimated token count for string s. Empty string or None
178
+ returns 0. Any other value that has a len() uses that length; anything
179
+ that raises returns 0. Pure; never raises.
180
+ """
181
+ try:
182
+ if not s:
183
+ return 0
184
+ return math.ceil(len(s) / 4)
185
+ except Exception:
186
+ return 0
187
+
188
+
189
+ def assemble_context(pointers, budget=150):
190
+ """Assemble the additionalContext payload from an ordered list of pointers.
191
+
192
+ Each pointer must be a dict with a "text" key whose value is a str.
193
+ Pointers with a missing, None, or non-string "text" are silently skipped.
194
+ Non-dict entries in the list are skipped. A non-list `pointers` argument
195
+ degrades to "".
196
+
197
+ Pointers arrive ALREADY in priority order (caller T-07 orders them
198
+ security-controls > decisions > specs > standards). Order is preserved —
199
+ pointers are included from the front.
200
+
201
+ The returned string's token_estimate must be <= budget. When the full
202
+ joined text exceeds budget, whole pointers are included from the front
203
+ while they fit, then the accumulated text is truncated at budget*4 chars
204
+ (the proxy ceiling) and the ellipsis marker "…" is appended so the
205
+ payload is never silently cut.
206
+
207
+ Single-oversized-pointer edge case (T-10 fix): when even the first
208
+ (highest-priority) pointer's text alone exceeds budget, the greedy
209
+ whole-pointer loop accumulates nothing. Rather than returning a bare
210
+ "…" marker (which silently discards the governing note), the function
211
+ truncates the first valid pointer's text to `budget*4 - len("…")` chars
212
+ and appends the ellipsis — so real content is always preserved. The
213
+ result's token_estimate is still <= budget.
214
+
215
+ Empty list or all-skipped -> "". Pure; never raises.
216
+ """
217
+ try:
218
+ if not isinstance(pointers, list):
219
+ return ""
220
+
221
+ # Collect valid texts in the given priority order.
222
+ valid_texts = []
223
+ for p in pointers:
224
+ if not isinstance(p, dict):
225
+ continue
226
+ text = p.get("text")
227
+ if not isinstance(text, str):
228
+ continue
229
+ valid_texts.append(text)
230
+
231
+ if not valid_texts:
232
+ return ""
233
+
234
+ # Try the full join first — the common, cheap path.
235
+ full_text = "\n".join(valid_texts)
236
+ if token_estimate(full_text) <= budget:
237
+ return full_text
238
+
239
+ # Over budget: greedily include whole pointers from the front.
240
+ accumulated = ""
241
+ for text in valid_texts:
242
+ candidate = text if not accumulated else accumulated + "\n" + text
243
+ if token_estimate(candidate) <= budget:
244
+ accumulated = candidate
245
+ else:
246
+ break # stop — preserve priority order, no skipping
247
+
248
+ # Truncate to leave room for the ellipsis marker, then append it.
249
+ # Single-oversized-pointer edge case: when nothing fit as a whole
250
+ # pointer (first pointer alone exceeds budget), fall back to
251
+ # truncating the first valid pointer's text — so real content is
252
+ # preserved and the result is never a bare ellipsis marker. The
253
+ # final string is at most budget*4 chars, guaranteeing
254
+ # token_estimate(result) <= budget.
255
+ max_text_len = budget * 4 - len(_ELLIPSIS)
256
+ if not accumulated:
257
+ accumulated = valid_texts[0]
258
+ result = accumulated[:max_text_len] + _ELLIPSIS
259
+ return result
260
+
261
+ except Exception:
262
+ return ""
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Tier 1 — security-controls pointer (AC-04)
267
+ # ---------------------------------------------------------------------------
268
+
269
+ # Concise pointer text for tier 1. Kept well under the 150-token budget so it
270
+ # leaves headroom for tiers 2–4 when assembled by governing_docs (T-07).
271
+ _SECURITY_CONTROLS_TEXT = (
272
+ "security-controls.md governs this file — consult it for auth, crypto,"
273
+ " and secret-handling constraints."
274
+ )
275
+
276
+
277
+ def security_pointer(path):
278
+ """Return a pointer dict for a security-entry path (tier 1), or None.
279
+
280
+ Fires ONLY for security-entry paths — files whose tokenised, lowercased
281
+ normalised path contains an exact member of _provenancelib._SECURITY_ENTRY_TOKENS
282
+ ({"auth", "middleware", "jwt"}). Manifests (package.json), CI yaml, and
283
+ migration files are NOT security-entry paths and return None here even though
284
+ classify_source() returns True for them.
285
+
286
+ Reuses _provenancelib._SECURITY_ENTRY_TOKENS and _SECURITY_TOKEN_SPLIT_RE
287
+ directly so this predicate stays in lockstep with the shipped classifier —
288
+ a change to either constant propagates here automatically.
289
+
290
+ Pointer dict shape (STABLE — must match the assembler T-07):
291
+ {"text": <concise ≤1-sentence string naming security-controls.md>,
292
+ "tier": "security-controls"}
293
+
294
+ Pure; never raises. None or garbage path → None. No provenance data is
295
+ consulted; no git calls are made (tier 1 is provenance-free).
296
+ """
297
+ try:
298
+ if not path:
299
+ return None
300
+ norm = str(path).replace("\\", "/")
301
+ # Apply the same whole-token rule as classify_source's security-entry
302
+ # branch: split on separators + camelCase boundaries, lowercase, then
303
+ # test for exact membership in _SECURITY_ENTRY_TOKENS. This prevents
304
+ # substring false-positives such as "author" (contains "auth") or
305
+ # "AuthorCard" (camelCase token "Author").
306
+ path_tokens = [
307
+ t.lower()
308
+ for t in _provenancelib._SECURITY_TOKEN_SPLIT_RE.split(norm)
309
+ if t
310
+ ]
311
+ if not _provenancelib._SECURITY_ENTRY_TOKENS.intersection(path_tokens):
312
+ return None
313
+ return {
314
+ "text": _SECURITY_CONTROLS_TEXT,
315
+ "tier": "security-controls",
316
+ }
317
+ except Exception:
318
+ return None
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Tier 2 — accepted-ADR index + pointer (AC-05)
323
+ # ---------------------------------------------------------------------------
324
+
325
+ _ADR_FILE_RE = re.compile(r"^[0-9]+-.+\.md$")
326
+ _ADR_GOVERNS_RE = re.compile(r"^governs:\s*(.+)$", re.I)
327
+ _ADR_TITLE_RE = re.compile(r"^title:\s*(.+)$", re.I)
328
+ _ADR_STATUS_RE = re.compile(r"^status:\s*(.+)$", re.I)
329
+
330
+ # Maximum number of file lines to scan for frontmatter (mirrors post-write-edit.py).
331
+ _ADR_SCAN_LIMIT = 26 # i > 25 breaks → lines 0-25 inclusive
332
+
333
+
334
+ def adr_identifier(filename):
335
+ """The ADR identifier for `filename` — its filename STEM, not its number.
336
+
337
+ `0014-githook-shim-dropin-fail-closed.md` → `0014-githook-shim-dropin-fail-closed`.
338
+
339
+ #416: two ADRs in this repo share the number 0014, so the old
340
+ `filename.split("-")[0]` derivation collapsed two distinct decisions onto one
341
+ id and emitted byte-identical "ADR-0014 governs this file" pointers for both.
342
+ The stem is unique by construction (the filesystem enforces it), so keying on
343
+ it makes the collision unrepresentable rather than merely unlikely.
344
+
345
+ Deliberately mirrored in post-write-edit.py — the two indexers are separate
346
+ payload files that cannot import each other's private helpers; the shared
347
+ contract test (.github/scripts/test_adr_identity.py) pins them to the same
348
+ answer so the derivation cannot drift apart again.
349
+ """
350
+ return os.path.splitext(str(filename))[0]
351
+
352
+
353
+ def accepted_adr_index(root):
354
+ """Tier-2 filesystem reader. Scan <root>/.codearbiter/decisions/ for ADR files.
355
+
356
+ Returns a list of {"adr": "<filename stem>", "title": "<title>", "globs": [<glob>, …]}
357
+ for every ADR that satisfies BOTH conditions:
358
+ - governs: is present and non-empty (one or more comma-separated globs).
359
+ - status: is exactly "accepted" (case-insensitive).
360
+
361
+ STRICT status filter: "superseded", "rejected", "draft", "proposed", missing,
362
+ or any other value are ALL excluded. This is intentionally stricter than
363
+ post-write-edit.py's governs_index which keeps anything not in
364
+ {superseded, rejected}.
365
+
366
+ Missing decisions dir → []. Malformed / unreadable file → skip, never raise.
367
+ No mtime cache; each call re-scans (correctness over speed; caching is optional
368
+ per spec and deferred until benchmarked).
369
+
370
+ Pure filesystem access is isolated here; adr_pointers is the pure matcher.
371
+ Never raises.
372
+ """
373
+ try:
374
+ ddir = os.path.join(str(root), ".codearbiter", "decisions")
375
+ if not os.path.isdir(ddir):
376
+ return []
377
+ files = [f for f in os.listdir(ddir) if _ADR_FILE_RE.match(f)]
378
+ if not files:
379
+ return []
380
+ index = []
381
+ for fn in files:
382
+ title, status, globs = fn, "", []
383
+ try:
384
+ with open(
385
+ os.path.join(ddir, fn), encoding="utf-8", errors="replace"
386
+ ) as fh:
387
+ for i, ln in enumerate(fh):
388
+ if i >= _ADR_SCAN_LIMIT:
389
+ break
390
+ stripped = ln.strip()
391
+ m = _ADR_GOVERNS_RE.match(stripped)
392
+ if m:
393
+ globs = [
394
+ g.strip()
395
+ for g in m.group(1).split(",")
396
+ if g.strip()
397
+ ]
398
+ continue
399
+ m = _ADR_TITLE_RE.match(stripped)
400
+ if m:
401
+ title = m.group(1).strip()
402
+ continue
403
+ m = _ADR_STATUS_RE.match(stripped)
404
+ if m:
405
+ status = m.group(1).strip().lower()
406
+ except Exception: # noqa: BLE001 — skip unreadable/malformed files
407
+ continue
408
+ # ACCEPTED ONLY — stricter than post-write-edit.py governs_index.
409
+ if globs and status == "accepted":
410
+ adr = adr_identifier(fn)
411
+ index.append({"adr": adr, "title": title, "globs": globs})
412
+ return index
413
+ except Exception: # noqa: BLE001
414
+ return []
415
+
416
+
417
+ def adr_pointers(rel, index):
418
+ """Tier-2 pure matcher. Return pointer dicts for accepted ADRs governing `rel`.
419
+
420
+ `rel` is a repo-relative path string. `index` is the output of
421
+ accepted_adr_index. For each index entry whose any glob fnmatch-matches
422
+ `rel`, produce:
423
+ {"text": "ADR-<id> (<title>) governs this file — do not contradict it;
424
+ route changes via /ca:reconcile or /ca:adr.",
425
+ "tier": "decisions"}
426
+
427
+ The text stays well under the 150-token budget. Returns [] when nothing
428
+ matches, when index is empty, or when either argument is malformed/None.
429
+ Pure; no filesystem access; never raises.
430
+ """
431
+ try:
432
+ if not rel or not isinstance(index, list):
433
+ return []
434
+ result = []
435
+ for entry in index:
436
+ try:
437
+ globs = entry.get("globs", [])
438
+ if any(fnmatch.fnmatch(rel, g) for g in globs):
439
+ adr_id = entry.get("adr", "")
440
+ title = entry.get("title", "")
441
+ text = (
442
+ "ADR-{} ({}) governs this file"
443
+ " — do not contradict it;"
444
+ " route changes via /ca:reconcile or /ca:adr.".format(
445
+ adr_id, title
446
+ )
447
+ )
448
+ result.append({"text": text, "tier": "decisions"})
449
+ except Exception: # noqa: BLE001 — skip malformed entry
450
+ continue
451
+ return result
452
+ except Exception: # noqa: BLE001
453
+ return []
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Tier 3 — approved-spec index + pointer (AC-06, AC-13)
458
+ # ---------------------------------------------------------------------------
459
+
460
+ # U+00B7 MIDDLE DOT — the · separator between inline header fields in spec files.
461
+ # e.g. **Slug:** `x` · **Lane:** full · **Status:** approved (2026-06-26)
462
+ _SPEC_SEPARATOR = "·"
463
+
464
+ # Match the first **Governs:** line (case-insensitive on the word); capture the
465
+ # rest of the line as the comma-separated glob list.
466
+ _SPEC_GOVERNS_RE = re.compile(r"\*\*governs:\*\*\s*(.+)", re.I)
467
+
468
+ # Match **Status:** anywhere on a line (inline support); capture everything up
469
+ # to the next · separator or end-of-line. The separator character is embedded
470
+ # via string concatenation so that · is expanded by Python, while the raw
471
+ # string keeps the regex metacharacter escapes intact.
472
+ _SPEC_STATUS_RE = re.compile(
473
+ r"\*\*status:\*\*\s*([^" + _SPEC_SEPARATOR + r"\n]+)", re.I
474
+ )
475
+
476
+
477
+ def parse_spec_governs(text):
478
+ """Return the glob list from the FIRST **Governs:** line in `text`.
479
+
480
+ Scans `text` line by line; returns a list of stripped, non-empty glob
481
+ strings from the first matching line. No such line → []. Non-string
482
+ input → []. Case-insensitive on "Governs". Pure; never raises.
483
+ """
484
+ try:
485
+ if not isinstance(text, str):
486
+ return []
487
+ for line in text.splitlines():
488
+ m = _SPEC_GOVERNS_RE.search(line)
489
+ if m:
490
+ raw = m.group(1)
491
+ return [g.strip() for g in raw.split(",") if g.strip()]
492
+ return []
493
+ except Exception: # noqa: BLE001
494
+ return []
495
+
496
+
497
+ def approved_spec_index(root):
498
+ """Tier-3 filesystem reader. Scan <root>/.codearbiter/specs/ for approved specs.
499
+
500
+ For each *.md file: reads its text, extracts globs via parse_spec_governs,
501
+ and extracts the status by searching for **Status:** anywhere in the text
502
+ (inline · separator support) then capturing the value until the next ·
503
+ or end-of-line, lowercased+stripped.
504
+
505
+ Keeps a spec ONLY when BOTH conditions hold:
506
+ - non-empty globs (has a **Governs:** line), AND
507
+ - status begins with "approved" (case-insensitive) — e.g.
508
+ "approved (2026-06-26)" qualifies; "draft (pending approval)" does NOT.
509
+
510
+ Returns [{"spec": "<filename stem>", "globs": [<glob>, ...]}, ...].
511
+ Missing specs dir → []. Malformed/unreadable file → skip, never raise.
512
+ No mtime cache; each call re-scans. Never raises.
513
+ """
514
+ try:
515
+ sdir = os.path.join(str(root), ".codearbiter", "specs")
516
+ if not os.path.isdir(sdir):
517
+ return []
518
+ files = [f for f in os.listdir(sdir) if f.endswith(".md")]
519
+ if not files:
520
+ return []
521
+ index = []
522
+ for fn in files:
523
+ try:
524
+ with open(
525
+ os.path.join(sdir, fn), encoding="utf-8", errors="replace"
526
+ ) as fh:
527
+ text = fh.read()
528
+ globs = parse_spec_governs(text)
529
+ if not globs:
530
+ continue
531
+ m = _SPEC_STATUS_RE.search(text)
532
+ if not m:
533
+ continue
534
+ status = m.group(1).strip().lower()
535
+ if not status.startswith("approved"):
536
+ continue
537
+ stem = os.path.splitext(fn)[0]
538
+ index.append({"spec": stem, "globs": globs})
539
+ except Exception: # noqa: BLE001 — skip malformed/unreadable files
540
+ continue
541
+ return index
542
+ except Exception: # noqa: BLE001
543
+ return []
544
+
545
+
546
+ def spec_pointers(rel, index):
547
+ """Tier-3 pure matcher. Return pointer dicts for approved specs governing `rel`.
548
+
549
+ `rel` is a repo-relative path string. `index` is the output of
550
+ approved_spec_index. For each index entry whose any glob fnmatch-matches
551
+ `rel`, produce:
552
+ {"text": "spec <slug> governs this file — implement to its acceptance criteria.",
553
+ "tier": "specs"}
554
+
555
+ The text stays well under the 150-token budget. Returns [] when nothing
556
+ matches, when index is empty, or when either argument is malformed/None.
557
+ Pure; no filesystem access; never raises.
558
+ """
559
+ try:
560
+ if not rel or not isinstance(index, list):
561
+ return []
562
+ result = []
563
+ for entry in index:
564
+ try:
565
+ globs = entry.get("globs", [])
566
+ if any(fnmatch.fnmatch(rel, g) for g in globs):
567
+ slug = entry.get("spec", "")
568
+ text = (
569
+ "spec {} governs this file"
570
+ " — implement to its acceptance criteria.".format(slug)
571
+ )
572
+ result.append({"text": text, "tier": "specs"})
573
+ except Exception: # noqa: BLE001 — skip malformed entry
574
+ continue
575
+ return result
576
+ except Exception: # noqa: BLE001
577
+ return []
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # Tier 4 — provenance freshness-gated pointer (AC-07)
582
+ # ---------------------------------------------------------------------------
583
+
584
+
585
+ _CONTEXT_CONTROL_CATEGORIES = frozenset(("Cc", "Cf", "Zl", "Zp"))
586
+
587
+
588
+ def _strip_context_controls(value):
589
+ """Return value as text without controls that can restructure context."""
590
+ try:
591
+ text = value if isinstance(value, str) else str(value)
592
+ return "".join(
593
+ char for char in text
594
+ if unicodedata.category(char) not in _CONTEXT_CONTROL_CATEGORIES
595
+ )
596
+ except Exception: # noqa: BLE001 - malformed provenance degrades to empty
597
+ return ""
598
+
599
+
600
+ def provenance_pointer(rel, provenance, current_hashes):
601
+ """Tier-4 pure comparator. Freshness-gated pointers from provenance entries.
602
+
603
+ PURE — does NOT call git or batch_hash. The caller (a later task) computes
604
+ current_hashes via _provenancelib.batch_hash ONLY for matched paths, so that
605
+ a non-matching Read pays zero git cost (AC-11). This function just consumes
606
+ the precomputed hashes.
607
+
608
+ rel — repo-relative path being read.
609
+ provenance — {doc_name: record} map (same shape _provenancelib produces).
610
+ May be {} — tier 4 is dormant until a re-scout backfills
611
+ .provenance/ (the live state of this repo today).
612
+ Non-dict → [].
613
+ current_hashes — {path: git_oid} for paths the caller already hashed.
614
+ Non-dict → [].
615
+
616
+ FRESHNESS GATE (the heart of AC-07):
617
+ - Find every entry across all docs whose path == rel.
618
+ - For each such entry: emit a pointer ONLY when ALL THREE hold:
619
+ (a) entry["hash"] is non-null, AND
620
+ (b) rel is present in current_hashes, AND
621
+ (c) current_hashes[rel] == entry["hash"] (FRESH)
622
+ - SUPPRESS (emit nothing for that entry) when:
623
+ - stored hash is None/null (unverifiable by design)
624
+ - rel absent from current_hashes (caller chose not to hash this path)
625
+ - current_hashes[rel] != stored hash (DIVERGED — stale note is worse
626
+ than none; drift system already nudges separately)
627
+
628
+ Pointer shape (stable contract):
629
+ {"text": "<doc>.md notes (lines L-M): <claim>", "tier": "standards"}
630
+ If the first claim has no "lines" key:
631
+ {"text": "<doc>.md notes: <claim>", "tier": "standards"}
632
+ Document names, claim text, and line-range metadata are stripped of
633
+ control, formatting, and line/paragraph separator characters before
634
+ pointer assembly. A document name that becomes empty is suppressed.
635
+ Uses the FIRST claim in entry["claims"]. Long texts are truncated at the
636
+ 150-token budget (same proxy as token_estimate: ceil(len/4)) with a
637
+ trailing "…" marker so the payload is never silently cut.
638
+
639
+ Returns [] when provenance is empty/malformed, no entry's path matches rel,
640
+ or all matching entries are suppressed. Malformed record/entry/None → skip
641
+ it, never raise. Non-dict inputs → []. Pure; never raises.
642
+ """
643
+ try:
644
+ if not isinstance(provenance, dict) or not isinstance(current_hashes, dict):
645
+ return []
646
+ if not rel or not provenance:
647
+ return []
648
+
649
+ result = []
650
+ for doc_name, record in provenance.items():
651
+ try:
652
+ if not isinstance(record, dict):
653
+ continue
654
+ safe_doc_name = _strip_context_controls(doc_name)
655
+ if not safe_doc_name:
656
+ continue
657
+ entries = record.get("entries")
658
+ if not isinstance(entries, list):
659
+ continue
660
+ for entry in entries:
661
+ try:
662
+ if not isinstance(entry, dict):
663
+ continue
664
+ if entry.get("path") != rel:
665
+ continue
666
+
667
+ stored_hash = entry.get("hash")
668
+ # SUPPRESS: null stored hash — cannot verify.
669
+ if stored_hash is None:
670
+ continue
671
+ # SUPPRESS: rel absent from current_hashes — unverifiable.
672
+ if rel not in current_hashes:
673
+ continue
674
+ # SUPPRESS: hash diverged — stale note is worse than none.
675
+ if current_hashes[rel] != stored_hash:
676
+ continue
677
+
678
+ # FRESH — build pointer from the first claim.
679
+ claims = entry.get("claims")
680
+ if not isinstance(claims, list) or not claims:
681
+ continue
682
+ first_claim = claims[0]
683
+ if not isinstance(first_claim, dict):
684
+ continue
685
+ claim_text = first_claim.get("claim", "")
686
+ if not isinstance(claim_text, str):
687
+ try:
688
+ claim_text = str(claim_text)
689
+ except Exception:
690
+ claim_text = ""
691
+ claim_text = _strip_context_controls(claim_text)
692
+ lines_range = first_claim.get("lines")
693
+ if lines_range:
694
+ lines_range = _strip_context_controls(lines_range)
695
+
696
+ # Assemble pointer text; include lines range when present.
697
+ doc_part = "{}.md".format(safe_doc_name)
698
+ if lines_range:
699
+ text = "{} notes (lines {}): {}".format(
700
+ doc_part, lines_range, claim_text
701
+ )
702
+ else:
703
+ text = "{} notes: {}".format(doc_part, claim_text)
704
+
705
+ # Enforce the 150-token budget; truncate with ellipsis marker
706
+ # so the payload is never silently cut (mirrors assemble_context).
707
+ if token_estimate(text) > 150:
708
+ max_text_len = 150 * 4 - len(_ELLIPSIS)
709
+ text = text[:max_text_len] + _ELLIPSIS
710
+
711
+ result.append({"text": text, "tier": "standards"})
712
+ except Exception: # noqa: BLE001 — skip malformed entry
713
+ continue
714
+ except Exception: # noqa: BLE001 — skip malformed record
715
+ continue
716
+
717
+ return result
718
+ except Exception: # noqa: BLE001
719
+ return []
720
+
721
+
722
+ # ---------------------------------------------------------------------------
723
+ # Four-tier composer (T-07 / AC-04/05/06/08/11)
724
+ # ---------------------------------------------------------------------------
725
+
726
+
727
+ def governing_docs(rel, index, runner=None):
728
+ """Return an ordered list of pointer dicts for rel — four-tier composition.
729
+
730
+ Composes the four knowledge tiers in the PRIORITY ORDER the budget assembler
731
+ relies on (security-controls > decisions > specs > standards):
732
+
733
+ 1. security_pointer(rel) tier "security-controls"
734
+ 2. adr_pointers(rel, index["adr"]) tier "decisions"
735
+ 3. spec_pointers(rel, index["spec"]) tier "specs"
736
+ 4. provenance_pointer(rel, …, current_hashes) tier "standards"
737
+ — ONLY when rel appears as a provenance entry path (AC-11 lazy gate).
738
+
739
+ Parameters
740
+ ----------
741
+ rel : repo-relative path string being read.
742
+ index : prebuilt dict with the STABLE shape produced by T-09:
743
+ {"adr": [{adr, title, globs}, …],
744
+ "spec": [{spec, globs}, …],
745
+ "provenance": {doc_name: record, …}}
746
+ Missing sub-keys degrade to their empty defaults.
747
+ runner : injectable git runner for batch_hash; contract:
748
+ runner(args, stdin_text) -> str
749
+ Default None (batch_hash uses _default_hash_runner).
750
+
751
+ Tier-4 LAZY hashing gate (AC-11)
752
+ ---------------------------------
753
+ Before calling batch_hash, a cheap dict/list walk collects all 'path' values
754
+ from every provenance record's 'entries' list into a set. If rel is NOT in
755
+ that set, tier 4 is SKIPPED ENTIRELY — no batch_hash call, zero git calls.
756
+ If rel IS in that set, batch_hash([rel], runner) is called ONCE to obtain
757
+ current_hashes, then provenance_pointer applies the freshness gate.
758
+
759
+ Returns
760
+ -------
761
+ list of pointer dicts (each has 'text': str and 'tier': str), in priority
762
+ order. [] when nothing matches. [] on any error. Never raises.
763
+ """
764
+ try:
765
+ result = []
766
+
767
+ # Tier 1: security-controls — pure; never raises.
768
+ sec = security_pointer(rel)
769
+ if sec is not None:
770
+ result.append(sec)
771
+
772
+ # Safely extract index sub-maps; degrade on missing/wrong-type values.
773
+ if isinstance(index, dict):
774
+ adr_index = index.get("adr") or []
775
+ spec_index = index.get("spec") or []
776
+ provenance = index.get("provenance") or {}
777
+ else:
778
+ adr_index = []
779
+ spec_index = []
780
+ provenance = {}
781
+
782
+ # Tier 2: decisions (accepted ADRs) — pure; never raises.
783
+ result.extend(adr_pointers(rel, adr_index))
784
+
785
+ # Tier 3: specs (approved specs) — pure; never raises.
786
+ result.extend(spec_pointers(rel, spec_index))
787
+
788
+ # Tier 4: standards (provenance freshness gate) — lazy hashing (AC-11).
789
+ #
790
+ # Step A: cheap dict/list walk to build the set of all provenance entry
791
+ # paths. This is git-free. A non-provenance Read exits here with zero
792
+ # git calls.
793
+ entry_paths = set()
794
+ if isinstance(provenance, dict):
795
+ for record in provenance.values():
796
+ try:
797
+ if not isinstance(record, dict):
798
+ continue
799
+ entries = record.get("entries")
800
+ if not isinstance(entries, list):
801
+ continue
802
+ for entry in entries:
803
+ try:
804
+ if isinstance(entry, dict):
805
+ p = entry.get("path")
806
+ if p:
807
+ entry_paths.add(p)
808
+ except Exception: # noqa: BLE001
809
+ continue
810
+ except Exception: # noqa: BLE001
811
+ continue
812
+
813
+ # Step B: only call batch_hash when rel is a known provenance entry path.
814
+ # Absence from entry_paths → SKIP; zero git calls.
815
+ if rel and rel in entry_paths:
816
+ current_hashes = _provenancelib.batch_hash([rel], runner)
817
+ result.extend(provenance_pointer(rel, provenance, current_hashes))
818
+
819
+ return result
820
+
821
+ except Exception: # noqa: BLE001
822
+ return []
823
+
824
+
825
+ # ---------------------------------------------------------------------------
826
+ # Dedup gate — per-(session, file) marker (AC-09)
827
+ # ---------------------------------------------------------------------------
828
+
829
+
830
+ def marker_path(root, session_id, rel):
831
+ """Return the absolute path of the dedup marker for (session_id, rel).
832
+
833
+ The marker lives under <root>/.codearbiter/.markers/ with a filename
834
+ derived from sha256(str(session_id) + "\\0" + str(rel)).hexdigest(). The
835
+ null-byte separator ensures ('ab', 'c') and ('a', 'bc') hash to different
836
+ filenames.
837
+
838
+ PURE — no filesystem access of any kind. Inputs are coerced to str so any
839
+ type is accepted. Never raises; on the (essentially impossible) error path,
840
+ returns a fallback path whose last segment is 'readinject-error.marker'
841
+ which will not match any normally-written marker.
842
+ """
843
+ try:
844
+ digest = hashlib.sha256(
845
+ (str(session_id) + "\0" + str(rel)).encode("utf-8")
846
+ ).hexdigest()
847
+ return os.path.join(
848
+ str(root),
849
+ ".codearbiter",
850
+ ".markers",
851
+ "readinject-{}.marker".format(digest),
852
+ )
853
+ except Exception: # noqa: BLE001
854
+ return os.path.join(
855
+ str(root), ".codearbiter", ".markers", "readinject-error.marker"
856
+ )
857
+
858
+
859
+ def already_injected(root, session_id, rel):
860
+ """Return True iff the dedup marker for (session_id, rel) exists on disk.
861
+
862
+ On ANY error (bad path, permission error, FS error) returns False —
863
+ degrades toward injecting again, never toward wrongly suppressing an
864
+ injection. Never raises.
865
+ """
866
+ try:
867
+ return os.path.isfile(marker_path(root, session_id, rel))
868
+ except Exception: # noqa: BLE001
869
+ return False
870
+
871
+
872
+ def record_injection(root, session_id, rel):
873
+ """Create the dedup marker for (session_id, rel), making .markers/ if needed.
874
+
875
+ Uses an atomic .tmp + os.replace write: a crash between the open() and the
876
+ rename never leaves a half-written marker at the target path. The marker
877
+ content is an empty string; only its existence is tested by already_injected.
878
+
879
+ All errors are silently swallowed — a failed marker write must never break
880
+ the hook. Worst case: the marker is absent and the same (session, file)
881
+ pair is injected again on the next Read. Never raises.
882
+ """
883
+ try:
884
+ path = marker_path(root, session_id, rel)
885
+ marker_dir = os.path.dirname(path)
886
+ os.makedirs(marker_dir, exist_ok=True)
887
+ tmp = path + ".tmp"
888
+ try:
889
+ with open(tmp, "w", encoding="utf-8") as f:
890
+ f.write("")
891
+ os.replace(tmp, path)
892
+ except Exception: # noqa: BLE001
893
+ try:
894
+ os.remove(tmp)
895
+ except Exception: # noqa: BLE001
896
+ pass
897
+ except Exception: # noqa: BLE001
898
+ pass
899
+
900
+
901
+ # ---------------------------------------------------------------------------
902
+ # Index builder with mtime caching (T-09 / AC-11)
903
+ # ---------------------------------------------------------------------------
904
+
905
+ _INDEX_CACHE_FILENAME = "readinject-index-cache.json"
906
+
907
+
908
+ def _index_stamp(root):
909
+ """Compute a max-mtime float stamp covering decisions/ and specs/ dirs + .md files.
910
+
911
+ Includes the directory mtime (changes on file add/remove) AND the mtime of
912
+ every .md file inside each directory (changes on file edits). This catches
913
+ all three mutation kinds: add, remove, and modify.
914
+
915
+ Returns 0.0 when neither directory exists (absence sentinel — a cache keyed
916
+ on 0.0 is safe to reuse while neither dir is present). Never raises.
917
+ """
918
+ mtimes = []
919
+ try:
920
+ for subdir in ("decisions", "specs"):
921
+ ddir = os.path.join(str(root), ".codearbiter", subdir)
922
+ try:
923
+ if not os.path.isdir(ddir):
924
+ continue
925
+ try:
926
+ mtimes.append(os.path.getmtime(ddir))
927
+ except Exception: # noqa: BLE001
928
+ pass
929
+ try:
930
+ for fname in os.listdir(ddir):
931
+ if fname.endswith(".md"):
932
+ try:
933
+ mtimes.append(
934
+ os.path.getmtime(os.path.join(ddir, fname))
935
+ )
936
+ except Exception: # noqa: BLE001
937
+ pass
938
+ except Exception: # noqa: BLE001
939
+ pass
940
+ except Exception: # noqa: BLE001
941
+ pass
942
+ except Exception: # noqa: BLE001
943
+ pass
944
+ return max(mtimes) if mtimes else 0.0
945
+
946
+
947
+ def build_index(root):
948
+ """Assemble the prebuilt index dict that governing_docs consumes.
949
+
950
+ Returns:
951
+ {"adr": accepted_adr_index(root),
952
+ "spec": approved_spec_index(root),
953
+ "provenance": load_provenance_dir(<root>/.codearbiter/.provenance)}
954
+
955
+ CACHING (batch-2 review finding): the hook fires on every PreToolUse:Read,
956
+ so this function MUST NOT re-scan decisions/ + specs/ from disk on every
957
+ call. The 'adr' and 'spec' index lists are cached in:
958
+ <root>/.codearbiter/.markers/readinject-index-cache.json
959
+ keyed on a stamp = max mtime of the decisions/ and specs/ directories and
960
+ their .md files (same technique governs_index uses in post-write-edit.py).
961
+ On a cache hit (stamp unchanged) the cached lists are reused; on a miss the
962
+ lists are rebuilt and the cache is rewritten atomically (.tmp + os.replace).
963
+
964
+ Any cache read/write error degrades to a fresh uncached build — MUST NOT
965
+ raise and MUST NOT prevent correct results. Cache is an optimization only.
966
+
967
+ 'provenance' is always loaded fresh each call (it is small; no cache needed).
968
+
969
+ Missing directories → empty sub-lists/maps. Never raises.
970
+ """
971
+ try:
972
+ stamp = _index_stamp(root)
973
+ cache_path = os.path.join(
974
+ str(root), ".codearbiter", ".markers", _INDEX_CACHE_FILENAME
975
+ )
976
+
977
+ # --- Cache hit path ---
978
+ try:
979
+ with open(cache_path, encoding="utf-8") as _fh:
980
+ _cached = json.load(_fh)
981
+ if _cached.get("stamp") == stamp:
982
+ _adr = _cached.get("adr", [])
983
+ _spec = _cached.get("spec", [])
984
+ _prov_dir = os.path.join(
985
+ str(root), ".codearbiter", ".provenance"
986
+ )
987
+ _provenance = _provenancelib.load_provenance_dir(_prov_dir)
988
+ return {"adr": _adr, "spec": _spec, "provenance": _provenance}
989
+ except Exception: # noqa: BLE001 — absent / corrupt cache; rebuild
990
+ pass
991
+
992
+ # --- Cache miss: rebuild both indexes ---
993
+ adr = accepted_adr_index(root)
994
+ spec_list = approved_spec_index(root)
995
+
996
+ # Write the cache atomically (.tmp + os.replace).
997
+ # Guard: only write when cache_path is absolute — prevents creating
998
+ # directories relative to the CWD when root is a garbage/relative value.
999
+ # Any failure is swallowed — cache is an optimization, never required.
1000
+ if os.path.isabs(cache_path):
1001
+ try:
1002
+ _cache_dir = os.path.dirname(cache_path)
1003
+ os.makedirs(_cache_dir, exist_ok=True)
1004
+ _tmp = cache_path + ".tmp"
1005
+ with open(_tmp, "w", encoding="utf-8") as _fh:
1006
+ json.dump({"stamp": stamp, "adr": adr, "spec": spec_list}, _fh)
1007
+ os.replace(_tmp, cache_path)
1008
+ except Exception: # noqa: BLE001 — cache write failure is acceptable
1009
+ pass
1010
+
1011
+ prov_dir = os.path.join(str(root), ".codearbiter", ".provenance")
1012
+ provenance = _provenancelib.load_provenance_dir(prov_dir)
1013
+ return {"adr": adr, "spec": spec_list, "provenance": provenance}
1014
+ except Exception: # noqa: BLE001
1015
+ return {"adr": [], "spec": [], "provenance": {}}
1016
+
1017
+
1018
+ # ---------------------------------------------------------------------------
1019
+ # End-to-end orchestrator (T-09 / AC-09/10/11)
1020
+ # ---------------------------------------------------------------------------
1021
+
1022
+
1023
+ def compute_injection(root, session_id, rel, runner=None):
1024
+ """End-to-end orchestrator for file-scoped JIT context injection.
1025
+
1026
+ Returns the additionalContext string (possibly "") that the hook sets as
1027
+ hookSpecificOutput.additionalContext. Steps in order:
1028
+
1029
+ 1. Self-read guard (AC-10): if rel's first path segment is '.codearbiter'
1030
+ (after backslash → forward-slash normalisation), return "" immediately —
1031
+ NO index build, NO git call, NO marker written.
1032
+ 2. Dedup (AC-09): if already_injected(root, session_id, rel), return "".
1033
+ 3. Build the index via build_index(root); compose pointers via
1034
+ governing_docs(rel, index, runner).
1035
+ 4. No-match fast-path (AC-11): if pointers is empty, return "" WITHOUT
1036
+ recording a marker — markers are only written for injecting reads.
1037
+ 5. assemble_context(pointers, budget=150). If ctx is non-empty →
1038
+ record_injection(root, session_id, rel) then return ctx.
1039
+ If somehow empty → return "" without recording.
1040
+
1041
+ COST GUARANTEE (AC-11): a non-matching Read makes ZERO runner calls.
1042
+ • The self-read guard exits before build_index is called.
1043
+ • build_index makes no git calls (reads dirs + provenance JSON only).
1044
+ • governing_docs gates batch_hash on provenance entry-path membership:
1045
+ a path absent from all provenance entries never calls the runner.
1046
+ Passing runner straight through to governing_docs preserves this property.
1047
+
1048
+ Never raises.
1049
+ """
1050
+ try:
1051
+ # Step 1: Self-read guard (AC-10) — exit before any I/O or git call.
1052
+ if rel is not None:
1053
+ try:
1054
+ _norm = str(rel).replace("\\", "/")
1055
+ _parts = [_p for _p in _norm.split("/") if _p]
1056
+ if _parts and _parts[0] == ".codearbiter":
1057
+ return ""
1058
+ except Exception: # noqa: BLE001
1059
+ pass # Malformed rel; fall through — won't match anything below.
1060
+
1061
+ # Step 2: Per-(session, file) dedup (AC-09).
1062
+ if already_injected(root, session_id, rel):
1063
+ return ""
1064
+
1065
+ # Step 3: Build the index (cached) and compose the four-tier pointer list.
1066
+ index = build_index(root)
1067
+ pointers = governing_docs(rel, index, runner)
1068
+
1069
+ # Step 4: No-match fast-path — do NOT record a marker on a miss (AC-11).
1070
+ if not pointers:
1071
+ return ""
1072
+
1073
+ # Step 5: Assemble the context payload and deliver.
1074
+ ctx = assemble_context(pointers, budget=150)
1075
+ if ctx:
1076
+ record_injection(root, session_id, rel)
1077
+ return ctx
1078
+ return ""
1079
+ except Exception: # noqa: BLE001
1080
+ return ""