@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,193 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — preview helpers (issue #81, /ca:preview).
3
+ #
4
+ # A library beside _hooklib.py, NOT a hook: it exposes building blocks the
5
+ # /ca:preview command composes (T-03 here covers diff collection only; the
6
+ # secret scan and test-gap detection are later tasks). Same house style as
7
+ # _hooklib.py: stdlib only (ADR-0004), no network, and every git invocation
8
+ # goes through one capture_output/text/utf-8/timeout subprocess.run wrapper so
9
+ # Windows pipe encoding never surprises us.
10
+ #
11
+ # collect_diff is strictly READ-ONLY: it runs only inspecting git commands
12
+ # (rev-parse, diff --name-only, ls-files) and writes nothing to the worktree,
13
+ # the index, or disk.
14
+
15
+ import os
16
+ import subprocess
17
+
18
+ from _gitexec import git_executable
19
+ import sys
20
+ from collections import namedtuple
21
+
22
+ # Reuse the ONE secret pattern defined in _hooklib.py — never a second copy, so
23
+ # the preview scan and the commit-time gate (H-10b) can never drift apart.
24
+ # _hooklib sits beside this file; ensure that dir is importable the same way the
25
+ # test harness mounts it (sys.path.insert of the hooks dir), then import the
26
+ # regex by reference.
27
+ _HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
28
+ if _HOOKS_DIR not in sys.path:
29
+ sys.path.insert(0, _HOOKS_DIR)
30
+ from _hooklib import SECRET_RE # noqa: E402 — needs the sys.path mount above
31
+
32
+ # One changed file. `path` is repo-relative with forward slashes (so later
33
+ # content scans and reporting are platform-stable). `kinds` is the set of
34
+ # change categories the file appears under — a file can be both staged and
35
+ # modified-unstaged at once, and we keep all of them rather than collapsing.
36
+ ChangedFile = namedtuple("ChangedFile", ["path", "kinds"])
37
+
38
+ # One secret hit: `path` is the repo-relative (forward-slash) changed file,
39
+ # `line_no` is 1-based, `snippet` is the matching line with the credential
40
+ # VALUE masked (keyword/operator/quoting preserved for context) and trailing
41
+ # whitespace stripped so reporting is stable across CRLF/LF worktrees. The
42
+ # snippet never carries the plaintext secret value.
43
+ SecretFinding = namedtuple("SecretFinding", ["path", "line_no", "snippet"])
44
+
45
+ # Change categories used in ChangedFile.kinds.
46
+ KIND_UNSTAGED = "unstaged" # tracked, differs from HEAD in the worktree
47
+ KIND_STAGED = "staged" # staged in the index vs HEAD
48
+ KIND_UNTRACKED = "untracked" # new file git is not yet tracking
49
+
50
+ # Skip files larger than this before reading them. Kept in lockstep with
51
+ # security-pass.py's MAX_UNTRACKED_BYTES (1_000_000): a blob bigger than this is
52
+ # not reviewable prose, and a preview must not slurp a giant file into memory.
53
+ # Oversize files are skipped exactly like binary/unreadable ones — no raise.
54
+ MAX_SCAN_BYTES = 1_000_000
55
+
56
+ # Replace every complete SECRET_RE match before a snippet leaves scan_secrets.
57
+ # Using the detector itself avoids a narrower redaction regex drifting behind
58
+ # new assignment shapes or high-entropy token prefixes.
59
+ _SECRET_MASK = "****"
60
+
61
+
62
+ def _redact_secret(line):
63
+ """Mask every region recognized by the shared commit-time secret gate."""
64
+ return SECRET_RE.sub(_SECRET_MASK, line)
65
+
66
+
67
+ def _git(args, root):
68
+ """Run a read-only git command in `root`. Returns the CompletedProcess, or
69
+ None if git could not be invoked at all (missing binary, timeout). Never
70
+ raises — callers branch on returncode/None, matching the fail-without-stack
71
+ posture the non-repo edge requires."""
72
+ try:
73
+ return subprocess.run(
74
+ [git_executable()] + args, cwd=root,
75
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
76
+ timeout=15,
77
+ )
78
+ except Exception: # noqa: BLE001 — missing git, timeout, etc.
79
+ return None
80
+
81
+
82
+ def _is_repo(root):
83
+ """True iff `root` is inside a git work tree. Detected by the exit code of
84
+ `git rev-parse --is-inside-work-tree` (non-zero -> not a repo), NOT by
85
+ catching a stack trace from a later command."""
86
+ r = _git(["rev-parse", "--is-inside-work-tree"], root)
87
+ return bool(r) and r.returncode == 0 and r.stdout.strip() == "true"
88
+
89
+
90
+ def _name_lines(result):
91
+ """Non-empty, slash-normalized paths from a `--name-only` style output."""
92
+ if not result or result.returncode != 0:
93
+ return []
94
+ return [
95
+ ln.strip().replace("\\", "/")
96
+ for ln in result.stdout.splitlines()
97
+ if ln.strip()
98
+ ]
99
+
100
+
101
+ def collect_diff(root=None):
102
+ """Collect the changed files in a working tree, unioned across three kinds.
103
+
104
+ Args:
105
+ root: directory to inspect; defaults to the current working directory.
106
+
107
+ Returns:
108
+ dict mapping each changed repo-relative path (forward slashes) to a
109
+ ChangedFile(path, kinds), where `kinds` is the set of categories the
110
+ file falls under (any of KIND_UNSTAGED / KIND_STAGED / KIND_UNTRACKED).
111
+ A file changed in more than one way (e.g. staged AND further modified)
112
+ carries all matching kinds. Empty dict when `root` is not a git repo or
113
+ the tree is clean — never raises for those edges.
114
+
115
+ Read-only: runs only `git rev-parse`, `git diff --name-only`, and
116
+ `git ls-files`; writes nothing.
117
+ """
118
+ if root is None:
119
+ root = os.getcwd()
120
+
121
+ if not _is_repo(root):
122
+ return {}
123
+
124
+ sources = (
125
+ # tracked changes vs HEAD, present in the worktree but not staged
126
+ (KIND_UNSTAGED, ["diff", "--name-only", "HEAD"]),
127
+ # tracked changes staged in the index vs HEAD
128
+ (KIND_STAGED, ["diff", "--cached", "--name-only"]),
129
+ # files git is not tracking yet, honouring .gitignore
130
+ (KIND_UNTRACKED, ["ls-files", "--others", "--exclude-standard"]),
131
+ )
132
+
133
+ found = {}
134
+ for kind, args in sources:
135
+ for path in _name_lines(_git(args, root)):
136
+ entry = found.get(path)
137
+ if entry is None:
138
+ found[path] = ChangedFile(path=path, kinds={kind})
139
+ else:
140
+ entry.kinds.add(kind)
141
+ return found
142
+
143
+
144
+ def scan_secrets(root=None):
145
+ """Scan every changed file's current worktree content for credential lines.
146
+
147
+ For each path from collect_diff(root), read the file as text (read-only) and
148
+ report every line matching the shared _hooklib.SECRET_RE.
149
+
150
+ Args:
151
+ root: directory to inspect; defaults to the current working directory.
152
+
153
+ Returns:
154
+ list of SecretFinding(path, line_no, snippet). `path` is repo-relative
155
+ with forward slashes (as collect_diff yields it); `line_no` is 1-based.
156
+ Empty list when nothing matches, when `root` is not a repo, or when the
157
+ tree is clean.
158
+
159
+ Strictly READ-ONLY: opens files for reading only and writes nothing. Files
160
+ that cannot be read as text (binary, deleted, permission-denied) are skipped
161
+ silently rather than raising — a preview must never crash on the worktree it
162
+ is merely describing.
163
+ """
164
+ if root is None:
165
+ root = os.getcwd()
166
+
167
+ findings = []
168
+ for path in collect_diff(root):
169
+ abspath = os.path.join(root, path)
170
+ try:
171
+ # Size cap first, so an oversize blob is skipped before we read it
172
+ # (consistent with security-pass.py's MAX_UNTRACKED_BYTES). Treated
173
+ # like binary/unreadable files: skipped, never raised.
174
+ if os.path.getsize(abspath) > MAX_SCAN_BYTES:
175
+ continue
176
+ with open(abspath, "r", encoding="utf-8", errors="strict") as f:
177
+ lines = f.read().splitlines()
178
+ except (OSError, UnicodeDecodeError):
179
+ # Deleted/unreadable (OSError) or binary/non-UTF-8 (UnicodeDecodeError):
180
+ # skip without raising. errors="strict" makes binary blobs raise here
181
+ # rather than yielding mojibake we might then false-positive on.
182
+ continue
183
+ for i, line in enumerate(lines, start=1):
184
+ if SECRET_RE.search(line):
185
+ # Mask the credential value before it leaves the function: the
186
+ # snippet keeps keyword/quoting context but never the plaintext.
187
+ findings.append(
188
+ SecretFinding(
189
+ path=path, line_no=i,
190
+ snippet=_redact_secret(line).rstrip(),
191
+ )
192
+ )
193
+ return findings
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter - the protected-path classifiers: which repo paths are append-only
3
+ # audit logs (H-05), ADR decision files (H-11), the activation manifest, and the
4
+ # gate-marker directory.
5
+ #
6
+ # Extracted from _hooklib (issue #321, architecture-002) as slice 2. Measured the
7
+ # same way slice 1 was: the cluster referenced exactly ONE symbol from the rest
8
+ # of _hooklib (repo_rel, which moved to the _pathnorm floor because it
9
+ # references no module symbol at all and the remaining slices need it too), and
10
+ # NOTHING in the rest of _hooklib referenced the cluster. A one-way edge with no
11
+ # back-reference is what makes a slice safe to move without touching a consumer.
12
+ #
13
+ # WHY THESE BELONG TOGETHER: they answer one question - "what class of protected
14
+ # thing is this path?" - and the guards branch on the answer as a set.
15
+ # classify_protected returns EVERY class a path hits, which is load-bearing:
16
+ # #528/#529 showed that adding decision-log.md to the audit set while leaving it
17
+ # in the decisions set still blocked, because pre-write.py checks the classes
18
+ # independently. Splitting these across modules would let one classification
19
+ # move without the others and reopen exactly that.
20
+ #
21
+ # _hooklib re-exports every name below, so no consumer changed and the
22
+ # pre-existing hook suites prove parity without moving.
23
+ #
24
+ # T-05a (#564): a FIFTH class, "state", joins the four above — a path
25
+ # registered in the protected-state registry (_protectedstatelib.py, B1: a
26
+ # per-entry policy of marker-gated/helper-only/append-only). It is evaluated
27
+ # strictly AFTER the four legacy classes (a documented ordering, not just an
28
+ # implementation detail — see classify_protected's own docstring) and
29
+ # changes nothing about the RETURN CONTRACT: still a bare set of class-name
30
+ # strings, exactly as before. Resolving the registered path's POLICY is left
31
+ # entirely to whichever flank branch consumes the "state" tag
32
+ # (_protectedstatelib.lookup_policy / resolve_registered_path) — never done
33
+ # here, so this module gains no dependency on a POLICY concept it has no
34
+ # other reason to know about, and the four pre-existing consumers of
35
+ # classify_protected see zero contract change.
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+
41
+ from _pathnorm import norm_path, raw_repo_rel, repo_rel
42
+
43
+
44
+ # Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
45
+ # here (architecture-004) so the three pre-* hooks import ONE definition instead
46
+ # of re-encoding the regex inline (the exact drift this module exists to
47
+ # prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
48
+ # same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
49
+ #
50
+ # AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
51
+ # shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
52
+ # the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
53
+ # decisions directory token; pre-bash.py composes its shell DECISIONS from it,
54
+ # and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
55
+ # norm_path'd `/` as well as a raw backslash, so both the file-path and shell
56
+ # flanks derive from one source.
57
+ #
58
+ # gate-events.log (observability-001, #186) joins this set: it is the durable,
59
+ # mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
60
+ # an append-only audit artifact exactly like the other three, so it gets the
61
+ # SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
62
+ # alternation, with no separate guard to maintain. Note this protects it only
63
+ # from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
64
+ # append (below) is plain file I/O, never a tool call, so H-05 never gates it.
65
+ #
66
+ # AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
67
+ # ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
68
+ # these as plain strings too (a cheap `n in cmd` substring pre-filter before
69
+ # running the regexes below), so it imports this tuple directly instead of
70
+ # re-deriving/hand-copying the name set (the exact drift this centralization
71
+ # exists to prevent — a filter that silently skips a future audit log because
72
+ # its literal name was never added to a second, hand-maintained copy).
73
+ # AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
74
+ # is unchanged from the prior hand-written pattern (same four literal
75
+ # filenames, same (?:...) grouping), only the source of truth moved.
76
+ AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
77
+ # #528: the SMARTS arbitration log is an append-only audit artifact that happens
78
+ # to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
79
+ # as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
80
+ # to it, H-11 refused every write without the /adr authoring marker, and only
81
+ # decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
82
+ # session made a decision it could not record. Its own format doc states H-05's
83
+ # rule verbatim — "strictly append-only … to supersede, append a new entry" — so
84
+ # H-05 is the correct guard: append freely, never rewrite.
85
+ #
86
+ # It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
87
+ # directly under .codearbiter/ and this one is nested a level deeper. Both halves
88
+ # of the reclassification are load-bearing: adding it here WITHOUT removing it
89
+ # from the H-11 set below leaves the append blocked, because classify_protected
90
+ # reports every class a path hits and pre-write checks them independently.
91
+ DECISION_LOG_BASENAME = "decision-log.md"
92
+ DECISION_LOG_RE = re.compile(
93
+ r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
94
+ )
95
+ # AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
96
+ # arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
97
+ # `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
98
+ # log cannot silently skip the shell flank — adding the name only to the regex
99
+ # alternation below would sail past that pre-filter and leave the log deletable
100
+ # from the shell. (Caught by test_hook_guards.py, which the comment on that
101
+ # pre-filter predicted verbatim.)
102
+ AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
103
+ AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
104
+ # The path anchor stays scoped to the FLAT logs — those sit directly under
105
+ # .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
106
+ # both patterns rather than loosening this one into matching any nesting.
107
+ AUDIT_LOG_RE = re.compile(
108
+ r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
109
+ )
110
+ DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
111
+ DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
112
+
113
+ # The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
114
+ # master switch every hook gates on via arbiter_active(); .markers/ holds the
115
+ # gate-pass tokens (security-gate-passed, migration-gate-passed,
116
+ # adr-authoring-active). Both were writable project state with no Write/Edit
117
+ # guard — the token strings are centralized here beside the audit-log/decisions
118
+ # sets so the pre-* hooks import ONE definition (same anti-drift rationale).
119
+ CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
120
+ MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
121
+ # The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
122
+ # H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
123
+ # legitimately shell-written (the sanctioned producers are the python
124
+ # security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
125
+ # which /adr legitimately `touch`es.
126
+ #
127
+ # T-08a / #564: this set enumerates markers whose PRESENCE converts a
128
+ # hard-gate BLOCK into an ALLOW (H-09b/H-10b's security-gate-passed, H-14's
129
+ # migration-gate-passed) — that conversion is the membership criterion, not
130
+ # "is this a marker under .markers/". A protected-state AUTHORING marker
131
+ # (e.g. release-targets-authoring, and the `<stem>-authoring` shape every
132
+ # future marker-gated consumer follows — _protectedstatelib.marker_name_for)
133
+ # does NOT belong here even though it also gates a write: it authorizes
134
+ # nothing on its own, the content it gates still passes through ordinary
135
+ # review, and it is deliberately self-mintable by an agent (a plain `touch`)
136
+ # under ADR-0010 — same as adr-authoring-active above. Its purpose is audit
137
+ # friction, not authorization.
138
+ #
139
+ # The risk here runs opposite to intuition: a future "hardening" pass that
140
+ # widens this blocklist to cover every registered marker would brick every
141
+ # legitimate authoring lane (a marker an agent can never touch admits
142
+ # nothing, ever) while stopping no non-cooperative agent (who can bypass
143
+ # this guard's own enforcement the same way they could bypass any other
144
+ # lexical shell check). See test_marker_touch_allowed (T-08a) for the pin.
145
+ GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
146
+
147
+
148
+ def is_audit_log(rel):
149
+ """True iff `rel` is one of the append-only .codearbiter audit logs
150
+ (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
151
+ arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
152
+ n = norm_path(rel)
153
+ return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
154
+
155
+
156
+ def is_tail_append(current, old, new):
157
+ """True iff an Edit's (old_string, new_string) pair is a verifiable,
158
+ TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
159
+ content) — the H-05 guard (reliability-003, #172).
160
+
161
+ `new.startswith(old)` alone is not sufficient: `old` could be any interior
162
+ line that happens to be a prefix of `new`, which inserts content BETWEEN
163
+ existing lines rather than appending at the end. This requires TWO things:
164
+ `current` must literally END with `old` (old_string is the file's actual
165
+ trailing content, not just some substring elsewhere), and `new` must
166
+ extend `old`. An empty `old` is never a valid append — every string
167
+ "ends with" the empty string, so the tail-anchor check would trivially
168
+ pass and reopen the migration-003 empty-old_string hole this closes.
169
+
170
+ `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
171
+ that happens to also match the tail is not self-evidently an append — this
172
+ keeps the guard correct on its own terms rather than depending on the Edit
173
+ tool's own (client-side, not re-verified here) uniqueness enforcement for
174
+ a non-replace_all Edit."""
175
+ if not old:
176
+ return False
177
+ if current.count(old) != 1:
178
+ return False
179
+ return current.endswith(old) and new.startswith(old)
180
+
181
+
182
+ def is_decisions_path(rel):
183
+ """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
184
+ the H-11 guard set (a non-numbered draft or a nested path still counts).
185
+
186
+ decisions/decision-log.md is the ONE exception (#528): it is the append-only
187
+ arbitration log, not immutable ADR history, and is governed by H-05 instead.
188
+ The carve-out is exactly one path wide and anchored — `old-decision-log.md`
189
+ and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
190
+ cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
191
+ NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
192
+ n = norm_path(rel)
193
+ if DECISION_LOG_RE.search(n):
194
+ return False
195
+ return bool(DECISIONS_PATH_RE.search(n))
196
+
197
+
198
+ def is_context_md(rel):
199
+ """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
200
+ the master switch arbiter_active() reads. Guarded so it can't be flipped to
201
+ `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
202
+ return bool(CONTEXT_MD_RE.search(norm_path(rel)))
203
+
204
+
205
+ def is_marker_path(rel):
206
+ """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
207
+ gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
208
+ hand-written marker must not be admitted by the Write/Edit tools."""
209
+ return bool(MARKERS_RE.search(norm_path(rel)))
210
+
211
+
212
+ def is_registered_state_path(rel):
213
+ """True iff `rel` carries a policy in the protected-state registry
214
+ (`_protectedstatelib.lookup_policy`, B1/#564) — the fifth protected class
215
+ `classify_protected` recognizes (T-05a), evaluated strictly AFTER the
216
+ other four.
217
+
218
+ The `_protectedstatelib` import is deliberately LAZY — done here, inside
219
+ the function body, rather than at this module's top level.
220
+ `_protectedstatelib` imports `marker_fresh` from `_hooklib`, and
221
+ `_hooklib` imports `classify_protected` (and every other name in this
222
+ file) FROM here — a module-level `import _protectedstatelib` at the top
223
+ of `_protectedlib.py` would be a genuine three-module import cycle
224
+ (`_protectedlib` -> `_protectedstatelib` -> `_hooklib` -> `_protectedlib`)
225
+ that Python cannot resolve at module-load time. Deferring the import to
226
+ CALL time breaks the cycle without restructuring the dependency graph:
227
+ every real caller reaches this function only after `_hooklib` (and
228
+ therefore this module) has already finished its own top-level execution,
229
+ so by the time the deferred import statement actually runs,
230
+ `_protectedstatelib`'s own `from _hooklib import marker_fresh` resolves
231
+ against an already-complete `_hooklib` module.
232
+
233
+ Correction (finding F11, #564 follow-up): this is NOT the same pattern
234
+ `_babysitlib.babysit_config` or `statusline.py` use — neither of those
235
+ actually breaks an import CYCLE. `statusline.py`'s function-level
236
+ imports are a startup-latency lazy-load with no cyclic partner at all,
237
+ and `_babysitlib.py`'s deferred import sits inside a lazy default
238
+ injection where `_hooklib` never imports `_babysitlib` back (no cycle
239
+ to break). The deferral here IS still correct and necessary — it is
240
+ the only thing that breaks the genuine three-module cycle described
241
+ above — the prior comment simply cited the wrong precedent; corrected
242
+ so the comment matches the record.
243
+
244
+ It is a local import, not file I/O or a git call, so it does not violate
245
+ this module's zero-side-effects-at-import invariant."""
246
+ import _protectedstatelib
247
+ return _protectedstatelib.lookup_policy(rel) is not None
248
+
249
+
250
+ def classify_protected(fpath, root):
251
+ """The set of protected classes a Write/Edit `fpath` targets, resolving
252
+ symlinks (#162). Each classifier runs against BOTH the raw normalized path
253
+ AND the realpath-resolved repo-relative form: a symlink alias whose visible
254
+ path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
255
+ can no longer launder a write past the guard. Centralized so pre-write.py and
256
+ pre-edit.py apply the identical symlink-safe check to every class (H-05,
257
+ H-11, #159 CONTEXT.md, #160 markers, #564 protected-state) instead of
258
+ re-encoding it twice.
259
+
260
+ Classes: "audit", "decisions", "context", "marker", "state". repo_rel()
261
+ returns "" for a target outside the repo (which cannot be a
262
+ `.codearbiter` path), so that flank is simply skipped.
263
+
264
+ "state" (T-05a, #564) is resolved in a SEPARATE loop below, strictly
265
+ AFTER the four legacy classes above — not interleaved into their loop.
266
+ That split is about incremental extension safety (a class added later
267
+ stays visibly separate from the four it was added beside, per the
268
+ module-header rationale above), NOT about the hits it produces:
269
+ `hits` is a `set`, so the SECOND loop's position relative to the first
270
+ has no observable effect on `classify_protected`'s own return value —
271
+ no test (mutant or otherwise) can distinguish "state resolved in loop 2"
272
+ from "state resolved inside loop 1" from the returned set alone
273
+ (finding F12, #564 follow-up correcting an earlier, overselling version
274
+ of this paragraph). The ordering that DOES matter or a real class/tag
275
+ conflict lives elsewhere and IS enforced in code: the textual branch
276
+ order of the `if "..." in classes:` checks in pre-write.py/pre-edit.py,
277
+ and pre-edit.py's own `_CLASS_TAG` priority tuple, which decide which
278
+ ONE message a caller emits when a path hits more than one class.
279
+
280
+ The "state" loop tries BOTH a raw (symlink-unresolved) repo-relative
281
+ form (`raw_repo_rel`) and the realpath-resolved repo-relative form
282
+ (`repo_rel`) — the SAME #162 two-form symlink-safety property the four
283
+ legacy classes get for free from `.search()`-based regex matching over
284
+ the raw path text, restored here explicitly (finding F3) because an
285
+ EQUALITY-based lookup (`_protectedstatelib.lookup_policy`) does not get
286
+ that coverage for free from a bare `norm_path(fpath)` — see
287
+ `_protectedstatelib.resolve_registered_path`'s docstring for the full
288
+ account of the symlink-inversion bug this closes. The return contract is
289
+ unchanged by this addition: still a bare set of strings. A caller that
290
+ needs the registered path's POLICY (marker-gated/helper-only/
291
+ append-only) resolves it separately, via `_protectedstatelib` — this
292
+ function only ever reports CLASS membership, exactly as it always has,
293
+ so its four pre-existing consumers see no contract change."""
294
+ hits = set()
295
+ for p in (norm_path(fpath), repo_rel(fpath, root)):
296
+ if not p:
297
+ continue
298
+ if is_audit_log(p):
299
+ hits.add("audit")
300
+ if is_decisions_path(p):
301
+ hits.add("decisions")
302
+ if is_context_md(p):
303
+ hits.add("context")
304
+ if is_marker_path(p):
305
+ hits.add("marker")
306
+ for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)):
307
+ if not p:
308
+ continue
309
+ if is_registered_state_path(p):
310
+ hits.add("state")
311
+ break
312
+ return hits