@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,231 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter v2 — PostToolUse(Write|Edit) reminders. Scope-touch nudges.
3
+ # Python port of post-write-edit.sh (#25): no jq, fails loud.
4
+ #
5
+ # These are advisory (non-blocking) nudges fired right after a write. The
6
+ # load-bearing crypto/secret ENFORCEMENT is the blocking pre-commit gate in
7
+ # pre-bash.py (H-09b/H-10b); these reminders surface the touch early so the gate
8
+ # isn't a surprise at commit time. Kept: H-07 (dependency review), H-09 (crypto),
9
+ # H-10 (secret). Added: H-12 (file governed by an ADR with a `governs:` field).
10
+
11
+ import fnmatch
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+ import hostapi # noqa: E402 — host seam (ADR-0011)
19
+ import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
20
+ from _hooklib import ( # noqa: E402
21
+ CRYPTO_RE, SECRET_RE, arbiter_active, get_host, is_ci_path, is_deploy_path,
22
+ project_root, read_input, remind, repo_rel, set_host, utf8_stdio,
23
+ )
24
+ from _sloplib import find_prose_separator_dashes, in_antislop_doc_scope # noqa: E402
25
+
26
+ DEP_MANIFEST_RE = re.compile(
27
+ r"(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|requirements\.txt"
28
+ r"|pyproject\.toml|go\.mod|Cargo\.toml)$"
29
+ )
30
+
31
+ # Narrow, high-signal auth patterns for the H-17 advisory (#73). Deliberately
32
+ # tight — auth has no clean deterministic trigger, so this nudges on the
33
+ # unmistakable markers (passport, jwt verify/sign, framework guards/decorators,
34
+ # common auth middleware names) rather than every mention of "login"/"session",
35
+ # which would be noise. Advisory only: there is no auth commit gate.
36
+ AUTH_RE = re.compile(
37
+ r"(passport\.(?:authenticate|use)"
38
+ r"|\bjsonwebtoken\b|jwt\.(?:verify|sign)"
39
+ r"|@(?:UseGuards|CanActivate|Authorize|RequireAuth)\b"
40
+ r"|\b(?:AuthGuard|requireAuth|isAuthenticated|authenticateUser|withAuth)\b"
41
+ r"|@login_required\b)",
42
+ re.I,
43
+ )
44
+
45
+ GOVERNS_RE = re.compile(r"^governs:\s*(.+)$", re.I)
46
+ TITLE_RE = re.compile(r"^title:\s*(.+)$", re.I)
47
+ STATUS_RE = re.compile(r"^status:\s*(.+)$", re.I)
48
+
49
+
50
+ def adr_identifier(filename):
51
+ """The ADR identifier for `filename` — its filename STEM, not its number.
52
+
53
+ `0014-githook-shim-dropin-fail-closed.md` → `0014-githook-shim-dropin-fail-closed`.
54
+
55
+ #416: two ADRs in this repo share the number 0014, so the old
56
+ `filename.split("-")[0]` derivation collapsed two distinct decisions onto one
57
+ id and nudged "governed by ADR-0014" without saying which. The stem is unique
58
+ by construction, so keying on it makes the collision unrepresentable.
59
+
60
+ Deliberately mirrored in _readinjectlib.py — the two indexers are separate
61
+ payload files that cannot import each other's private helpers; the shared
62
+ contract test (.github/scripts/test_adr_identity.py) pins them to the same
63
+ answer so the derivation cannot drift apart again.
64
+ """
65
+ return os.path.splitext(str(filename))[0]
66
+
67
+
68
+ # Bumped whenever the SHAPE or the derivation of a cached index entry changes.
69
+ # The mtime stamp alone only invalidates on an ADR edit, so without this a
70
+ # payload upgrade would keep serving pre-#416 numeric ids out of an already-
71
+ # written cache until someone happened to touch a decisions/ file.
72
+ GOVERNS_CACHE_SCHEMA = 2
73
+
74
+
75
+ def governs_index(root):
76
+ """path-glob → ADR map from decisions/ frontmatter, cached against the
77
+ directory's latest mtime so the scan doesn't repeat on every write."""
78
+ ddir = os.path.join(root, ".codearbiter", "decisions")
79
+ if not os.path.isdir(ddir):
80
+ return []
81
+ files = [f for f in os.listdir(ddir) if re.match(r"[0-9]+-.+\.md$", f)]
82
+ if not files:
83
+ return []
84
+ stamp = max(os.path.getmtime(os.path.join(ddir, f)) for f in files)
85
+ cache_path = os.path.join(root, ".codearbiter", ".markers", "governs-cache.json")
86
+ try:
87
+ with open(cache_path, encoding="utf-8") as f:
88
+ cache = json.load(f)
89
+ if (cache.get("stamp") == stamp
90
+ and cache.get("schema") == GOVERNS_CACHE_SCHEMA):
91
+ return cache.get("index", [])
92
+ except Exception: # noqa: BLE001 — absent/corrupt cache just rebuilds
93
+ pass
94
+
95
+ index = []
96
+ for fn in files:
97
+ title, status, globs = fn, "", []
98
+ try:
99
+ with open(os.path.join(ddir, fn), encoding="utf-8", errors="replace") as f:
100
+ for i, ln in enumerate(f):
101
+ if i > 25:
102
+ break
103
+ if m := GOVERNS_RE.match(ln.strip()):
104
+ globs = [g.strip() for g in m.group(1).split(",") if g.strip()]
105
+ elif m := TITLE_RE.match(ln.strip()):
106
+ title = m.group(1).strip()
107
+ elif m := STATUS_RE.match(ln.strip()):
108
+ status = m.group(1).strip().lower()
109
+ except Exception: # noqa: BLE001
110
+ continue
111
+ if globs and status not in ("superseded", "rejected"):
112
+ adr = adr_identifier(fn)
113
+ index.append({"adr": adr, "title": title, "globs": globs})
114
+ try:
115
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
116
+ with open(cache_path, "w", encoding="utf-8") as f:
117
+ json.dump({"stamp": stamp, "schema": GOVERNS_CACHE_SCHEMA,
118
+ "index": index}, f)
119
+ except Exception: # noqa: BLE001 — cache is an optimization, never required
120
+ pass
121
+ return index
122
+
123
+
124
+ def _remind_op(root, op):
125
+ """Run every advisory nudge against ONE canonical file op
126
+ (hostapi.Host.iter_file_ops, ADR-0011 M2). Under Claude Code this receives
127
+ exactly the one op its Write/Edit payload maps to — op["added_text"] is
128
+ content-or-new_string, precisely the pre-seam `ti.get("content") or
129
+ ti.get("new_string")` read — so the Claude reminder path is unchanged. A
130
+ Codex apply_patch envelope fans out to one op per touched file, each
131
+ getting the same per-file nudges over its added lines."""
132
+ fpath = op.get("file_path", "") or ""
133
+ content = op.get("added_text") or ""
134
+
135
+ # H-12: the touched file is governed by an accepted ADR — surface it so the
136
+ # recorded decision pushes back at edit time, not at the next checkpoint.
137
+ rel = repo_rel(fpath, root)
138
+ if rel:
139
+ for entry in governs_index(root):
140
+ if any(fnmatch.fnmatch(rel, g) for g in entry["globs"]):
141
+ remind("H-12", f"{rel} is governed by ADR-{entry['adr']} ({entry['title']}). "
142
+ f"If this change contradicts it, route to "
143
+ f"{get_host().cmd_ref('reconcile')} or "
144
+ f"{get_host().cmd_ref('adr')} — do not drift silently.")
145
+ break
146
+
147
+ # H-07: dependency manifest changed — review before committing.
148
+ if DEP_MANIFEST_RE.search(fpath):
149
+ remind("H-07", "Dependency manifest changed. Dispatch dependency-reviewer before "
150
+ "committing (ORCHESTRATOR §5).")
151
+
152
+ # H-15: CI/CD workflow touched — advisory (#73). A bad workflow only runs
153
+ # once merged, so this is a nudge, not a commit gate; dispatch security-reviewer
154
+ # before the PR merges.
155
+ if rel and is_ci_path(rel, root):
156
+ remind("H-15", "CI/CD workflow changed. Dispatch security-reviewer before merging "
157
+ "(it reviews workflow/secrets/permissions exposure). Advisory — "
158
+ "not a commit block.")
159
+
160
+ # H-16: deployment / IaC manifest touched — advisory (#73). IaC bites only on
161
+ # apply, so this nudges; security-reviewer is the enforcement point at PR.
162
+ if rel and is_deploy_path(rel, root):
163
+ remind("H-16", "Deployment/IaC manifest changed. Dispatch security-reviewer before "
164
+ "merging (it reviews exposed ports, public ACLs, disabled "
165
+ "encryption, privilege). Advisory — not a commit block.")
166
+
167
+ # H-17: authentication/authorization logic touched — advisory (#73). Narrow,
168
+ # high-signal patterns only; the dangerous crypto/secret primitives are still
169
+ # hard-blocked by H-09b/H-10b at commit time.
170
+ if AUTH_RE.search(content):
171
+ remind("H-17", "Auth/authorization logic detected. Dispatch security-reviewer "
172
+ "before merging (authn on every protected route; authz at the "
173
+ "right layer). Advisory — not a commit block.")
174
+
175
+ # H-09: crypto/TLS pattern — the pre-commit gate will BLOCK until the
176
+ # crypto-compliance gate records a pass; run it now to avoid a commit-time stop.
177
+ if CRYPTO_RE.search(content):
178
+ remind("H-09", "Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch "
179
+ "auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable "
180
+ "TLS verification). The commit will block until the gate records a pass.")
181
+
182
+ # H-10: possible hardcoded secret.
183
+ if SECRET_RE.search(content):
184
+ remind("H-10", "Possible hardcoded secret. Run the secret-handling check before "
185
+ "committing. The commit will block until the gate records a pass.")
186
+
187
+ # H-13: a user-facing doc was written with an em/en dash used as a prose
188
+ # separator (anti-slop-design core §3.A, the single highest-signal AI tell).
189
+ # Advisory nudge to run the copy self-audit before it ships. Scope is community
190
+ # docs + docs/**, never codeArbiter's own framework bodies (see _sloplib).
191
+ if rel and in_antislop_doc_scope(rel):
192
+ hits = find_prose_separator_dashes(content)
193
+ if hits:
194
+ shown = ", ".join(str(h["line"]) for h in hits[:5])
195
+ more = "" if len(hits) <= 5 else f" (+{len(hits) - 5} more)"
196
+ remind("H-13", f"{rel}: em/en dash used as a prose separator on line(s) "
197
+ f"{shown}{more} (anti-slop-design §3.A). Restructure the prose "
198
+ f"and run the §3.A/§3.B copy self-audit before committing.")
199
+
200
+
201
+ def main():
202
+ utf8_stdio()
203
+ root = project_root()
204
+ if not arbiter_active(root):
205
+ sys.exit(0)
206
+ # Host seam (ADR-0011, M2): one canonical op per touched file — exactly one
207
+ # under Claude Code (verdict-identical to the pre-seam single-payload body),
208
+ # one per patched file under Codex.
209
+ for op in get_host().iter_file_ops(read_input()):
210
+ _remind_op(root, op)
211
+ sys.exit(0)
212
+
213
+
214
+ def run(host, argv=None):
215
+ """Host-seam entry point (ADR-0011): the __main__ guard calls this with the
216
+ plugin's loaded Host. Wraps main() unchanged — main() still communicates
217
+ via sys.exit/stdout/stderr, and its return value stays discarded exactly
218
+ as the old bare `main()` guard discarded it (so the process still exits 0
219
+ on a normal fall-through).
220
+
221
+ Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
222
+ `set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
223
+ to the SAME instance the caller passed here — no second
224
+ `hostapi.load_host()`, and `run(fake_host)` genuinely exercises
225
+ `fake_host`."""
226
+ return _entrylib.dispatch(host, argv, main, set_host,
227
+ pass_argv=False, propagate_result=False)
228
+
229
+
230
+ if __name__ == "__main__":
231
+ sys.exit(run(hostapi.load_host()) or 0)
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter v2 — PreToolUse(Bash|PowerShell) guard. Thin entry point (#320):
3
+ # reads stdin, dispatches to _bashguardlib.run_guards() for the actual
4
+ # command-parsing / H-NN gate logic, exit code. Python port of pre-bash.sh
5
+ # (issues #24, #25): no jq, fails loud, blocks via exit 2.
6
+ #
7
+ # All guards run only in arbiter-enabled repos (the plugin.json activation
8
+ # contract); elsewhere this exits 0 immediately.
9
+
10
+ import os
11
+ import sys
12
+ import traceback
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+ import hostapi # noqa: E402 — host seam (ADR-0011)
16
+ import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
17
+ from _hooklib import ( # noqa: E402
18
+ arbiter_active, block, get_host, project_root, read_input, set_host,
19
+ tool_input, utf8_stdio,
20
+ )
21
+ import _bashguardlib # noqa: E402 — guard logic lives here (#320)
22
+
23
+ # Backward-compat re-exports: pre-existing tests load this module directly
24
+ # (importlib) and reach into it by name — kept resolving exactly as they did
25
+ # before the #320 extraction, rather than editing those tests.
26
+ # .github/scripts/test_hook_guards.py: pre_bash._strip_heredoc_bodies,
27
+ # pre_bash.commit_pathspecs (now _bashguardlib's implementations)
28
+ # plugins/ca/hooks/tests/test_repo_resolution.py: pre_bash.git_cwd
29
+ _strip_heredoc_bodies = _bashguardlib._strip_heredoc_bodies
30
+ commit_pathspecs = _bashguardlib.commit_pathspecs
31
+ git_cwd = _bashguardlib.git_cwd
32
+
33
+
34
+ def _run(root):
35
+ # tool_input()/get_host() are resolved HERE (module-level names on this
36
+ # entry file), not inside _bashguardlib, so that
37
+ # test_guard_crash_failclosed.py's `self.mod.tool_input = <raiser>`
38
+ # monkeypatch — which replaces the attribute on THIS module object —
39
+ # still reaches the call (module-level rebinding is invisible to a name
40
+ # already imported into a different module's namespace).
41
+ payload = read_input()
42
+ ti = get_host().normalize_tool_input(payload.get("tool_name", "") or "",
43
+ tool_input(payload))
44
+ _bashguardlib.run_guards(payload, root, ti)
45
+
46
+
47
+ def main():
48
+ utf8_stdio()
49
+ root = project_root()
50
+ if not arbiter_active(root):
51
+ sys.exit(0)
52
+ # reliability-002 (#189): everything past this point runs only in an
53
+ # arbiter-enabled repo, so a dormant/non-codeArbiter repo can never be
54
+ # bricked by a crash here. An uncaught exception in the scan path below
55
+ # (H-01/H-03/H-05/H-09b/H-10b/H-11/H-14/H-18/H-19/H-20) must fail CLOSED (exit 2,
56
+ # a BLOCK) rather than exit 1 — a non-2 exit is a NON-blocking error under
57
+ # the Claude Code hook contract (_hooklib.py:11-15), which would silently
58
+ # ALLOW the very tool call this guard exists to scan. read_input()'s
59
+ # documented fail-OPEN behavior on malformed stdin is unaffected: it catches
60
+ # its own parse errors internally and returns {} before this wrapper is
61
+ # ever reached.
62
+ try:
63
+ _run(root)
64
+ except SystemExit:
65
+ raise
66
+ except Exception: # noqa: BLE001 — the fail-closed backstop of last resort
67
+ traceback.print_exc(file=sys.stderr)
68
+ block("H-00", "pre-bash guard crashed while scanning this command — failing "
69
+ "closed (ORCHESTRATOR §2) rather than silently allowing an "
70
+ "unscanned command. See the traceback above; retry, or report it.")
71
+
72
+
73
+ def run(host, argv=None):
74
+ """Host-seam entry point (ADR-0011): the __main__ guard calls this with the
75
+ plugin's loaded Host. Wraps main() unchanged — main() still communicates
76
+ via sys.exit/stdout/stderr, and its return value stays discarded exactly
77
+ as the old bare `main()` guard discarded it (so the process still exits 0
78
+ on a normal fall-through).
79
+
80
+ Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
81
+ `set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
82
+ to the SAME instance the caller passed here — no second
83
+ `hostapi.load_host()`, and `run(fake_host)` genuinely exercises
84
+ `fake_host`."""
85
+ return _entrylib.dispatch(host, argv, main, set_host,
86
+ pass_argv=False, propagate_result=False)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(run(hostapi.load_host()) or 0)
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter v2 — PreToolUse(Edit|MultiEdit|NotebookEdit) guard. Activation +
3
+ # audit-log + marker + ADR integrity. Python port of pre-edit.sh (#25): no jq,
4
+ # fails loud, blocks via exit 2.
5
+ #
6
+ # Guards (arbiter-enabled repo only), symlink-resolved via classify_protected
7
+ # (#162 — raw path AND realpath-resolved repo-relative form):
8
+ # H-18 CONTEXT.md may not be edited to drop `arbiter: enabled` (#159).
9
+ # H-19 .codearbiter/.markers/* are never edited via the Edit tools (#160).
10
+ # H-05 audit-log Edits must be pure, tail-anchored appends: old_string must
11
+ # be the file's REAL current trailing content, new_string must extend
12
+ # it, and replace_all is rejected outright (reliability-003, #172).
13
+ # H-11 ADRs under decisions/ are edited only via /adr.
14
+ # H-22 the protected-state registry (B1/#564): the SAME generic branch as
15
+ # pre-write.py — marker-gated admits only under a fresh authoring
16
+ # marker; helper-only and append-only are hard-blocked, no marker
17
+ # path at all.
18
+ #
19
+ # NotebookEdit is guarded too (a notebook has no append/frontmatter semantics, so
20
+ # a protected target is refused outright) — defense in depth; none of the
21
+ # protected files is a .ipynb, but the tool must not be a hole.
22
+ #
23
+ # Host seam (ADR-0011, #261): this file reasons entirely over the canonical
24
+ # per-file ops from hostapi.Host.iter_file_ops (the same seam pre-write.py
25
+ # already routes through) — it never branches on a native tool name
26
+ # ("MultiEdit", "NotebookEdit"). The properties that used to be read off the
27
+ # raw tool_input (old_string, replace_all, "this is a MultiEdit batch entry",
28
+ # "this is a notebook with no content semantics") are host-neutral op fields
29
+ # (old_string, replace_all, batched, notebook) instead — a future host whose
30
+ # edit tool shares those same shapes gets the same guard semantics without
31
+ # pre-edit.py knowing its tool names.
32
+
33
+ import os
34
+ import sys
35
+ import traceback
36
+
37
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
38
+ import hostapi # noqa: E402 — host seam (ADR-0011)
39
+ import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
40
+ from _hooklib import ( # noqa: E402
41
+ MARKER_FRESHNESS_MINUTES, arbiter_active, block, classify_protected,
42
+ frontmatter_enabled_text, get_host, is_tail_append, marker_fresh,
43
+ project_root, read_input, set_host, utf8_stdio,
44
+ )
45
+ from _protectedstatelib import ( # noqa: E402
46
+ ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path,
47
+ )
48
+
49
+
50
+ def _read_real_text(root, fpath):
51
+ """Best-effort current on-disk content of `fpath` (symlink-resolved), or
52
+ "" if unreadable. Shared by the #159 CONTEXT.md replay and the H-05
53
+ tail-anchor check (reliability-003, #172) — both need the file's REAL
54
+ current content, not just an edit op's own old_string, to verify what the
55
+ edit actually does to the file."""
56
+ path = fpath if os.path.isabs(fpath) else os.path.join(root, fpath)
57
+ try:
58
+ with open(os.path.realpath(path), encoding="utf-8", errors="replace") as f:
59
+ return f.read()
60
+ except Exception: # noqa: BLE001
61
+ return ""
62
+
63
+
64
+ def _resulting_context(root, fpath, ops):
65
+ """Best-effort resulting content of CONTEXT.md after ALL of `ops` (one or
66
+ more canonical edit ops targeting the same file — a MultiEdit call fans
67
+ out to several, applied as one atomic batch) are replayed in order, so
68
+ the #159 guard can vet whether the FULL call keeps the repo
69
+ arbiter-enabled. Reads the real (symlink-resolved) file and replays each
70
+ op's old_string/added_text/replace_all against the running text, exactly
71
+ as the pre-seam per-tool replay did — driven off op fields instead of a
72
+ native tool_input shape. An old_string that isn't present leaves the text
73
+ unchanged (the tool would error anyway) — harmless for the check."""
74
+ text = _read_real_text(root, fpath)
75
+ for op in ops:
76
+ old = op.get("old_string", "") or ""
77
+ new = op.get("added_text", "") or ""
78
+ if op.get("replace_all"):
79
+ text = text.replace(old, new)
80
+ else:
81
+ text = text.replace(old, new, 1)
82
+ return text
83
+
84
+
85
+ def _run(root):
86
+ payload = read_input()
87
+ # Host seam (ADR-0011, M2/#261): iter_file_ops maps this host's native
88
+ # payload to canonical per-file ops. Under Claude Code, Edit/NotebookEdit
89
+ # map to exactly one op and MultiEdit fans to one op per edits[] entry —
90
+ # all sharing the SAME file_path, since each of these tools targets one
91
+ # file per call. Codex does not register this entry at all — its edits
92
+ # arrive as apply_patch and are guarded per-op by pre-write.py
93
+ # (docs/parity.md).
94
+ ops = list(get_host().iter_file_ops(payload))
95
+ if not ops:
96
+ sys.exit(0)
97
+
98
+ fpath = ops[0].get("file_path", "") or ""
99
+ classes = classify_protected(fpath, root)
100
+
101
+ # The tag that names each protected class, in message priority order.
102
+ _CLASS_TAG = (("marker", "H-19"), ("context", "H-18"),
103
+ ("audit", "H-05"), ("decisions", "H-11"),
104
+ ("state", "H-22"))
105
+
106
+ # H-21: an "opaque" op is a host's signal that it could not map this
107
+ # payload to a known per-file shape at all (e.g. a FailClosedHost, or a
108
+ # future host's unparseable envelope) — ambiguity resolves CLOSED
109
+ # (ORCHESTRATOR §2), mirroring pre-write.py's opaque handling.
110
+ if any(op.get("kind") == "opaque" for op in ops):
111
+ block("H-21", "This edit could not be decomposed into per-file operations, so its "
112
+ "targets cannot be guarded. Failing closed. Retry, or report it.")
113
+
114
+ # A "notebook" op (NotebookEdit): its target has no append/frontmatter
115
+ # semantics to reason about — refuse any protected target outright with a
116
+ # class-appropriate tag (defense in depth). A notebook call always fans to
117
+ # exactly one op, so checking ops[0] speaks for the whole call.
118
+ if ops[0].get("notebook"):
119
+ for cls, tag in _CLASS_TAG:
120
+ if cls in classes:
121
+ block(tag, "NotebookEdit may not target a protected .codearbiter artifact "
122
+ "(CONTEXT.md, audit logs, ADRs, or markers) — use the sanctioned "
123
+ "skill path.")
124
+ sys.exit(0)
125
+
126
+ # H-19: markers are never edited via the Edit tools (#160) — the sanctioned
127
+ # producers write them; an Edit is tampering. Block outright.
128
+ if "marker" in classes:
129
+ block("H-19", "The .codearbiter/.markers/ gate tokens are not editable via the Edit "
130
+ "tools (#160) — editing one forges or tampers with a security/migration/ADR "
131
+ "gate pass. Markers are recorded only by the sanctioned gate producers.")
132
+
133
+ # H-18: an edit to CONTEXT.md may not drop `arbiter: enabled` or corrupt the
134
+ # frontmatter (#159) — replay every op against the file in order (a
135
+ # MultiEdit's edits apply as one atomic batch; the resulting text after
136
+ # ALL of them is what matters, not any single intermediate op) and
137
+ # require the FINAL result stays enabled.
138
+ if "context" in classes:
139
+ enabled, malformed = frontmatter_enabled_text(_resulting_context(root, fpath, ops))
140
+ if malformed or not enabled:
141
+ block("H-18", "This Edit would remove or alter the `arbiter: enabled` frontmatter in "
142
+ ".codearbiter/CONTEXT.md (#159) — the activation switch every enforcement "
143
+ "hook reads. Disabling it from inside the repo would make every gate "
144
+ "dormant. Keep `arbiter: enabled` in a well-formed frontmatter block.")
145
+
146
+ # H-05: the audit logs (and the /sprint decision record) are append-only — an
147
+ # Edit must be a verifiable TAIL-ANCHORED append: old_string must be the
148
+ # file's REAL current trailing content, and new_string must extend it.
149
+ # (path set: _hooklib.is_audit_log)
150
+ if "audit" in classes:
151
+ # A batched op (MultiEdit's edits[] fan) applies as part of a batch and
152
+ # no single entry's old_string/new_string can express a verifiable
153
+ # pure append to an append-only file — the sanctioned append path is a
154
+ # single Edit (or '>>'). Block it outright rather than reason about
155
+ # the batch.
156
+ if any(op.get("batched") for op in ops):
157
+ block("H-05", "MultiEdit cannot guarantee a pure append to an append-only "
158
+ ".codearbiter audit log (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) "
159
+ "(ORCHESTRATOR §7). Append with a single Edit or '>>'.")
160
+ # Exactly one non-batched op reaches this point (Edit).
161
+ op = ops[0]
162
+ # reliability-003 (#172): replace_all can never be a verifiable append —
163
+ # it rewrites EVERY occurrence of old_string, not just the tail (a
164
+ # single-occurrence replace_all is indistinguishable from a targeted
165
+ # rewrite, and a multi-occurrence one alters interior lines outright).
166
+ # Reject it outright before even looking at old_string/new_string.
167
+ if op.get("replace_all"):
168
+ block("H-05", "An Edit with replace_all=true on an append-only .codearbiter "
169
+ "audit log (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) cannot be a "
170
+ "verifiable pure append (ORCHESTRATOR §7) — replace_all rewrites "
171
+ "every matching occurrence, not just the file's tail. Append with "
172
+ "'>>', or a single non-replace_all Edit whose old_string is the "
173
+ "file's current trailing content.")
174
+ old = op.get("old_string", "") or ""
175
+ new = op.get("added_text", "") or ""
176
+ # migration-003: `new.startswith("")` is ALWAYS True, so an empty
177
+ # old_string defeats the append check entirely — it could prepend or
178
+ # replace arbitrary content with the guard never firing. An empty
179
+ # old_string can never be a verifiable append (there is no existing tail
180
+ # to extend); block it outright.
181
+ if not old:
182
+ block("H-05", "An Edit with an empty old_string on an append-only .codearbiter "
183
+ "audit log (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) cannot be a "
184
+ "verifiable pure append (ORCHESTRATOR §7) — every string starts with "
185
+ "the empty string. Append with '>>', or a single Edit whose old_string "
186
+ "is the file's current trailing content.")
187
+ if not new.startswith(old):
188
+ block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, "
189
+ "sprint-log.md) are append-only (ORCHESTRATOR §7). This Edit alters "
190
+ "existing audit lines; only pure appends are permitted (new text "
191
+ "must extend the old text).")
192
+ # reliability-003 (#172): new.startswith(old) alone is not enough — an
193
+ # old_string that matches an INTERIOR line (not the file's actual
194
+ # trailing content) still satisfies startswith, but inserts content
195
+ # BETWEEN existing lines rather than truly appending at the end.
196
+ # Tail-anchor against the REAL on-disk content: old_string must be
197
+ # exactly what the file currently ends with.
198
+ current = _read_real_text(root, fpath)
199
+ if not is_tail_append(current, old, new):
200
+ block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, "
201
+ "sprint-log.md) are append-only (ORCHESTRATOR §7). This Edit's "
202
+ "old_string is not the file's current TRAILING content — a mid-file "
203
+ "insertion reorders the audit record even though new_string extends "
204
+ "old_string. Append with '>>', or a single Edit whose old_string is "
205
+ "exactly the file's current tail.")
206
+
207
+ # H-11: ADRs may only be edited via /adr — any .md anywhere under decisions/
208
+ # (a non-numbered draft or a nested path is still an immutable decision).
209
+ # (path set: _hooklib.is_decisions_path)
210
+ if "decisions" in classes:
211
+ marker = os.path.join(root, ".codearbiter", ".markers", "adr-authoring-active")
212
+ if not os.path.isfile(marker):
213
+ block("H-11", "ADR files are edited only via /adr (ORCHESTRATOR §3) — user "
214
+ "attribution required.")
215
+ if not marker_fresh(marker, MARKER_FRESHNESS_MINUTES):
216
+ block("H-11", f"ADR authoring marker is stale (>{MARKER_FRESHNESS_MINUTES} min). Re-run /adr.")
217
+
218
+ # H-22: the protected-state registry (B1/#564) — the SAME generic branch
219
+ # pre-write.py carries (T-06/T-07 design ruling: one branch, not a
220
+ # per-tool special case). classify_protected already decided "state" is
221
+ # a hit; resolve_registered_path only resolves WHICH registered path and
222
+ # WHICH policy — never a second, independent membership check.
223
+ # marker-gated admits only under a fresh authoring marker; helper-only
224
+ # and append-only are flank-IDENTICAL here — both hard-block
225
+ # unconditionally, with NO marker path and no tail-anchored-append
226
+ # admission (unlike H-05's audit logs, an append-only registry entry's
227
+ # append verb lives in the sanctioned HELPER, not in this Edit flank).
228
+ if "state" in classes:
229
+ rel, policy = resolve_registered_path(fpath, root)
230
+ if policy == ProtectedPolicy.MARKER_GATED:
231
+ if not marker_gated_write_admitted(rel, root):
232
+ block("H-22", f"'{rel}' is marker-gated protected project state (#564) — an "
233
+ f"Edit is admitted only under a fresh authoring marker. Run the "
234
+ f"sanctioned authoring lane, or "
235
+ + get_host().cmd_ref("override") + ".")
236
+ else:
237
+ block("H-22", f"'{rel}' is protected project state (#564) — the Edit tools may "
238
+ f"never target it; there is no marker path for this policy. Use "
239
+ f"the sanctioned helper.")
240
+
241
+ sys.exit(0)
242
+
243
+
244
+ def main():
245
+ utf8_stdio()
246
+ root = project_root()
247
+ if not arbiter_active(root):
248
+ sys.exit(0)
249
+ # reliability-002 (#189): scoped to arbiter-enabled repos only (above), so a
250
+ # dormant/non-codeArbiter repo can never be bricked by a crash here. An
251
+ # uncaught exception in the scan below must fail CLOSED (exit 2 = BLOCK),
252
+ # not exit 1 — a non-2 exit is a NON-blocking error under the Claude Code
253
+ # hook contract (_hooklib.py:11-15) and would silently ALLOW the Edit.
254
+ # read_input()'s documented fail-OPEN parse behavior is unaffected: it
255
+ # catches its own errors and returns {} before this wrapper is reached.
256
+ try:
257
+ _run(root)
258
+ except SystemExit:
259
+ raise
260
+ except Exception: # noqa: BLE001 — the fail-closed backstop of last resort
261
+ traceback.print_exc(file=sys.stderr)
262
+ block("H-00", "pre-edit guard crashed while scanning this Edit — failing "
263
+ "closed (ORCHESTRATOR §2) rather than silently allowing an "
264
+ "unscanned edit. See the traceback above; retry, or report it.")
265
+
266
+
267
+ def run(host, argv=None):
268
+ """Host-seam entry point (ADR-0011): the __main__ guard calls this with the
269
+ plugin's loaded Host. Wraps main() unchanged — main() still communicates
270
+ via sys.exit/stdout/stderr, and its return value stays discarded exactly
271
+ as the old bare `main()` guard discarded it (so the process still exits 0
272
+ on a normal fall-through).
273
+
274
+ Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
275
+ `set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
276
+ to the SAME instance the caller passed here — no second
277
+ `hostapi.load_host()`, and `run(fake_host)` genuinely exercises
278
+ `fake_host`."""
279
+ return _entrylib.dispatch(host, argv, main, set_host,
280
+ pass_argv=False, propagate_result=False)
281
+
282
+
283
+ if __name__ == "__main__":
284
+ sys.exit(run(hostapi.load_host()) or 0)