@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,460 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — host-abstraction seam (ADR-0011, codex-support M1).
3
+ #
4
+ # The hooks were written against exactly one host: Claude Code. Every
5
+ # host-coupled assumption — which env var names the project dir, which env var
6
+ # names the plugin root, what the tool names in a PreToolUse payload mean,
7
+ # whether a statusline or a Read tool even exists — used to be inlined at each
8
+ # call site. This module is the ONE seam those assumptions flow through, so a
9
+ # second host (Codex CLI, M2) can ship its own `_host.py` beside the vendored
10
+ # hooks without forking any of them.
11
+ #
12
+ # Design (stdlib only, ADR-0004):
13
+ # * `Host` is a plain class whose DEFAULTS are exactly today's Claude Code
14
+ # behavior — the built-in fallback load_host() uses when no `_host.py`
15
+ # exists. A different host subclasses Host and overrides what differs.
16
+ # * `_host.py` (per-plugin, NOT part of the shared core) defines `HOST`,
17
+ # the host instance for the plugin it ships in. load_host() loads it by
18
+ # FILE PATH from this module's own directory — never via sys.path — so
19
+ # the resolution cannot be hijacked by the caller's import state.
20
+ # * Canonical tool categories: "EXEC" | "WRITE" | "EDIT" | "READ" | "OTHER".
21
+ # Canonical tool-input shapes: {file_path, content} for WRITE,
22
+ # {file_path, old_string, new_string} for EDIT, {command} for EXEC.
23
+ # Claude Code's payloads already ARE the canonical shape, so the Claude
24
+ # host passes tool_input through unchanged; a host whose native shape
25
+ # differs (e.g. Codex's apply_patch) translates in normalize_tool_input.
26
+ #
27
+ # Behavioral contract for M1: under Claude Code, everything routed through
28
+ # this seam resolves to byte-identical results as the pre-seam inline code.
29
+
30
+ import os
31
+ import subprocess
32
+ import sys
33
+
34
+ from _gitexec import git_executable
35
+
36
+
37
+ def git_toplevel(cwd=None):
38
+ """`git rev-parse --show-toplevel`, run FROM `cwd` when given (`git -C
39
+ cwd ...`) rather than the process's own cwd. Returns the resolved
40
+ toplevel path, or None on any failure (not a repo, git missing, timeout,
41
+ empty output) — callers decide the fallback.
42
+
43
+ Shared by every Host.project_root payload-cwd leg (base Host and
44
+ CodexHost, #260/reliability-005): a hook payload's `cwd` can be a repo
45
+ SUBDIRECTORY (e.g. a Codex session started below the repo root), and the
46
+ project root must be the repo TOPLEVEL, not that subdirectory verbatim —
47
+ `.codearbiter/` state lives at the root."""
48
+ args = [git_executable()]
49
+ if cwd:
50
+ args += ["-C", cwd]
51
+ args += ["rev-parse", "--show-toplevel"]
52
+ try:
53
+ out = subprocess.run(
54
+ args, capture_output=True, text=True, encoding="utf-8",
55
+ errors="replace", timeout=5,
56
+ )
57
+ if out.returncode == 0:
58
+ top = out.stdout.strip()
59
+ if top:
60
+ return top
61
+ except Exception: # noqa: BLE001
62
+ pass
63
+ return None
64
+
65
+
66
+ def git_worktree_main_root(root):
67
+ """When `root` (an already-resolved project root — a `project_root()`
68
+ answer, NOT necessarily a fresh `git_toplevel` call) is itself the
69
+ checkout of a LINKED git worktree, the MAIN checkout's root that owns the
70
+ shared `.git` directory — else `None`.
71
+
72
+ #604: `security-pass.py`/`migration-pass.py` and the H-09b/H-10b/H-14
73
+ guards were found resolving DIFFERENT roots for GATE MARKERS in a
74
+ linked-worktree session — a hook subprocess trusts a (possibly stale)
75
+ `CLAUDE_PROJECT_DIR` naming the MAIN checkout, while `security-pass.py`
76
+ run bare via Bash (no `CLAUDE_PROJECT_DIR` in that shell) fell through to
77
+ `git_toplevel()`, which names the WORKTREE's own checkout — so a gate
78
+ pass recorded by one was never seen by the other. `.codearbiter/
79
+ .markers/` is gitignored, so a linked worktree's own checkout never has
80
+ one freshly — the git-hook guard's diff/branch resolution already
81
+ carries this exact split (`_bashguardlib._effective_exec_root`'s
82
+ docstring, D-2: gate markers stay pinned to the main checkout regardless
83
+ of the command's effective exec root).
84
+
85
+ Deliberately NOT wired into `Host.project_root()` itself: `project_root()`
86
+ also backs `security-pass.py`'s DIFF SCAN (`candidate_lines()`), which
87
+ must stay worktree-local — escalating the general project root to "main"
88
+ would bind digests to the wrong (unrelated, possibly dirty) tree and
89
+ silently drop coverage for the diff actually being committed, the exact
90
+ trap the issue this closes warns against. Callers that specifically need
91
+ the gate-MARKER root call this as a targeted escalation on top of an
92
+ already-resolved `project_root()` answer instead — e.g.
93
+ `git_worktree_main_root(root) or root` — so every OTHER project_root()
94
+ consumer (diff scans, `arbiter_active`, …) is entirely unaffected, and
95
+ the two callers agree on marker location without `git_toplevel`'s own
96
+ `git rev-parse` mechanism being replaced anywhere (deliberate: symlink/
97
+ 8.3 canonicalization, #125).
98
+
99
+ Distinguishes a linked worktree from a submodule — both have a `.git`
100
+ FILE, but only a worktree's `gitdir:` pointer names a path under
101
+ `.git/worktrees/<name>`; a submodule's names `.git/modules/<name>`, which
102
+ is not a "main root" to climb to and must fall through untouched (mirrors
103
+ `_durabilitylib._gitfile_points_at_worktree`'s same distinction)."""
104
+ git_meta = os.path.join(root, ".git")
105
+ if not os.path.isfile(git_meta):
106
+ return None
107
+ try:
108
+ with open(git_meta, encoding="utf-8", errors="replace") as f:
109
+ pointer = f.read().strip()
110
+ except OSError:
111
+ return None
112
+ if not pointer.startswith("gitdir: "):
113
+ return None
114
+ gitdir = pointer[len("gitdir: "):].strip().replace("\\", "/")
115
+ marker = "/.git/worktrees/"
116
+ idx = gitdir.find(marker)
117
+ if idx == -1:
118
+ return None # not a linked worktree (e.g. a submodule) — nothing to climb to
119
+ main_git_dir = gitdir[:idx + len("/.git")]
120
+ return os.path.dirname(main_git_dir) or None
121
+
122
+
123
+ class Host:
124
+ """One host's answers to the host-coupled questions the hooks ask.
125
+
126
+ The base class IS the Claude Code host (name "claude"): its defaults are
127
+ the exact pre-seam behavior of the inlined call sites, and load_host()
128
+ falls back to a bare Host() when no per-plugin `_host.py` is present so
129
+ nothing breaks on a partial install.
130
+ """
131
+
132
+ name = "claude"
133
+
134
+ # Capability flags — what surfaces this host actually has. A hook that
135
+ # heals/queries a statusline gates on has_statusline; a hook registered
136
+ # for a Read tool gates on has_read_tool (Codex has neither).
137
+ # has_prunable_transcript gates the prune ENGINE only (it rewrites
138
+ # Claude-Code-format transcript JSONL); the audit staleness-warn sharing
139
+ # that entry reads .codearbiter logs and runs on every host.
140
+ has_statusline = True
141
+ has_read_tool = True
142
+ has_prunable_transcript = True
143
+
144
+ # The noun the startup briefing uses for the command channel. Claude Code
145
+ # namespaces plugin commands behind slash commands; a host without a
146
+ # command namespace overrides this (and cmd_ref below).
147
+ command_noun = "slash command"
148
+
149
+ def cmd_ref(self, name):
150
+ """The host-native spelling of governance command `name` in any
151
+ RUNTIME-emitted string (startup briefing, block reason, doctor line,
152
+ scaffold text). The markdown surface handles the same divergence at
153
+ build time via {{CMD:name}} in tools/build-surface.py; this method is
154
+ the runtime twin — the two must agree per host (codex-support M3)."""
155
+ return "/ca:" + name
156
+
157
+ # Native tool name -> canonical category. Anything unlisted is "OTHER".
158
+ TOOL_MAP = {
159
+ "Bash": "EXEC",
160
+ "PowerShell": "EXEC",
161
+ "Write": "WRITE",
162
+ "Edit": "EDIT",
163
+ "MultiEdit": "EDIT",
164
+ "NotebookEdit": "EDIT",
165
+ "Read": "READ",
166
+ }
167
+
168
+ def project_root(self, payload=None):
169
+ """The project root. Resolution order (matches the pre-seam
170
+ _hooklib.project_root exactly — see its docstring for the rationale):
171
+
172
+ 1. CLAUDE_PROJECT_DIR, when set and an existing directory — the
173
+ harness's own authoritative signal, trusted first.
174
+ 2. the hook payload's `cwd`, when a payload is given and its cwd is
175
+ an existing directory — climbed to the git TOPLEVEL from that
176
+ cwd (git_toplevel), falling back to the cwd itself when that
177
+ climb fails (not a git repo). No Claude call site passes a
178
+ payload today (architecture-006, #260), so this leg is inert
179
+ under Claude Code; it exists for hosts with no project-dir env
180
+ var.
181
+ 3. `git rev-parse --show-toplevel` from the process cwd.
182
+ 4. the process cwd.
183
+
184
+ Deliberately climbs no further than the WORKTREE's own toplevel in a
185
+ linked-worktree session — a caller wanting the gate-MARKER root (which
186
+ must agree on the MAIN checkout, #604) calls `marker_root()` instead;
187
+ see its docstring for why the two must not be conflated."""
188
+ env_root = os.environ.get("CLAUDE_PROJECT_DIR")
189
+ if env_root and os.path.isdir(env_root):
190
+ return env_root
191
+ if payload:
192
+ cwd = payload.get("cwd") if isinstance(payload, dict) else None
193
+ if cwd and os.path.isdir(cwd):
194
+ return git_toplevel(cwd) or cwd
195
+ top = git_toplevel()
196
+ if top:
197
+ return top
198
+ return os.getcwd()
199
+
200
+ def marker_root(self, payload=None):
201
+ """The root `.codearbiter/.markers/` gate passes (security-pass.py,
202
+ migration-pass.py, and the H-09b/H-10b/H-14 guards) are written to
203
+ and read from (#604).
204
+
205
+ Identical to `project_root(payload)` in every case except one: when
206
+ that resolves to a LINKED git worktree's own checkout, this escalates
207
+ to the MAIN checkout that owns the shared `.git` directory instead
208
+ (`git_worktree_main_root`) — `.codearbiter/.markers/` is gitignored,
209
+ so a linked worktree's own checkout never has one freshly, and the
210
+ git-hook guard already anchors every marker READ at the main
211
+ checkout regardless of a command's real exec root (D-2,
212
+ `_bashguardlib._effective_exec_root`'s docstring). This gives every
213
+ marker WRITER that same answer even when it runs without
214
+ `CLAUDE_PROJECT_DIR` set — `security-pass.py` invoked bare via a Bash
215
+ tool call, rather than as a registered hook subprocess that inherits
216
+ it from the harness — closing the loop without touching
217
+ `project_root()` itself, which other callers (diff scans in
218
+ particular) need to stay worktree-local."""
219
+ root = self.project_root(payload)
220
+ return git_worktree_main_root(root) or root
221
+
222
+ def plugin_root(self):
223
+ """The plugin payload root: CLAUDE_PLUGIN_ROOT when set, else derived
224
+ from this file's own location (<root>/hooks/hostapi.py -> <root>) —
225
+ exactly the pre-seam per-entry-script derivation, which resolved
226
+ relative to a file in the same hooks/ directory."""
227
+ env = os.environ.get("CLAUDE_PLUGIN_ROOT")
228
+ if env:
229
+ return env
230
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
231
+
232
+ def manifest_relpath(self):
233
+ """The plugin manifest's path, relative to plugin_root() (#263,
234
+ reliability-001/002 + observability-003): Claude Code's marketplace
235
+ convention is `.claude-plugin/plugin.json` — the ONLY location this
236
+ host ever reads or writes. A host whose manifest ships elsewhere
237
+ (e.g. Codex's `.codex-plugin/`) overrides this so doctor.py's
238
+ check_payload and _updatelib.installed_version resolve the manifest
239
+ that ACTUALLY exists for the running host, instead of hard-coding the
240
+ Claude path and reporting a healthy Codex install as UNHEALTHY /
241
+ silently never firing the update-available notice."""
242
+ return os.path.join(".claude-plugin", "plugin.json")
243
+
244
+ def mcp_config_sources(self, project_root):
245
+ """Where THIS host declares its MCP servers — `[(path, key), ...]`,
246
+ each entry a configuration file and the mapping key under which that
247
+ file declares servers (#270, tribunal appsec-002).
248
+
249
+ MCP server tools (`mcp__<server>__<tool>`) sit OUTSIDE the write gate
250
+ on every host: they miss Claude's `Write`/`Edit` matchers, and they
251
+ carry no TOOL_MAP entry on Codex so they normalize to "OTHER" and
252
+ match neither the write hooks nor the exec hook. That is ACCEPTED
253
+ residual risk under ADR-0010 (see .codearbiter/security-controls.md),
254
+ conditioned on the gap being *visible* to the consumer carrying it —
255
+ this repo builds codeArbiter, so the risk never lands here. This seam
256
+ is how `doctor.check_mcp` asks the ACTIVE host where to look, instead
257
+ of hard-coding one host's layout; doctor counts what it finds and
258
+ never reads, echoes, or names a server, command, or argument.
259
+
260
+ Claude Code declares servers in two places, and BOTH have to be seen
261
+ or the commonest real configuration reports as "none":
262
+ * `<project>/.mcp.json` — the project scope, checked into the repo.
263
+ * `~/.claude.json` — user scope (top-level `mcpServers`) and local
264
+ scope (`projects.<path>.mcpServers`, same key, nested).
265
+
266
+ Return `[]` to mean "this host's MCP configuration surface is not
267
+ known" — doctor then reports NOTHING rather than guessing another
268
+ host's paths. Overrides must not raise; doctor treats a raising seam
269
+ as unknown, but a quiet seam is the contract.
270
+ """
271
+ home = os.path.expanduser("~")
272
+ return [
273
+ (os.path.join(project_root, ".mcp.json"), "mcpServers"),
274
+ (os.path.join(home, ".claude.json"), "mcpServers"),
275
+ ]
276
+
277
+ def normalize_tool(self, tool_name):
278
+ """Canonical category for a native tool name:
279
+ "EXEC" | "WRITE" | "EDIT" | "READ" | "OTHER"."""
280
+ return self.TOOL_MAP.get(tool_name or "", "OTHER")
281
+
282
+ def normalize_tool_input(self, tool_name, tool_input):
283
+ """The canonical tool-input dict for a native (tool_name, tool_input)
284
+ pair. Claude Code's shapes already are the canonical ones
285
+ ({file_path, content} / {file_path, old_string, new_string} /
286
+ {command}), so this passes through unchanged; a host with a different
287
+ native shape overrides this to translate."""
288
+ return tool_input if isinstance(tool_input, dict) else {}
289
+
290
+ def iter_file_ops(self, payload):
291
+ """The canonical per-file operations a WRITE/EDIT-category hook payload
292
+ performs (codex-support M2): a list of dicts, one per touched file —
293
+
294
+ {"file_path": the native path string,
295
+ "kind": "write" | "edit" | "delete",
296
+ "content": the file's FULL resulting content when knowable
297
+ (a Write / patch Add File), else None,
298
+ "added_text": the raw text this op introduces (content for a
299
+ write, new_string for an edit, joined + lines for
300
+ a patch hunk),
301
+ "added_lines": added_text split into lines,
302
+ "old_string": the text this op's added_text replaces, when the
303
+ native shape carries one (Edit/MultiEdit's
304
+ old_string); "" when there is none to reason
305
+ about (Write, NotebookEdit, a patch hunk),
306
+ "replace_all": True iff this op's native shape asked to replace
307
+ EVERY occurrence of old_string rather than one
308
+ (Edit's replace_all); always False otherwise,
309
+ "batched": True iff this op is one of several fanned from a
310
+ SINGLE tool call against the same file whose
311
+ per-op old_string/new_string cannot, on its own,
312
+ certify a property of the call AS A WHOLE (e.g.
313
+ MultiEdit's edits array — the edits apply as one
314
+ atomic batch, so no single entry's old_string/
315
+ new_string alone certifies something like "this
316
+ call is a pure append"); callers that need a
317
+ whole-call verdict must not trust an individual
318
+ batched op in isolation,
319
+ "notebook": True iff this op's target has no content/append/
320
+ frontmatter semantics worth reasoning about
321
+ (NotebookEdit) — a content-sensitive guard must
322
+ treat it as opaque rather than replaying it.}
323
+
324
+ This is the seam the shared pre-write.py / pre-edit.py / post-write-
325
+ edit.py entries iterate, so a host whose one write tool carries MANY
326
+ file operations (Codex's apply_patch envelope) hits the same per-file
327
+ guard logic as Claude's one-file-per-call Write/Edit/MultiEdit/
328
+ NotebookEdit — and pre-edit.py's guards never have to branch on a
329
+ native tool name to reconstruct these properties (ADR-0011, #261).
330
+
331
+ The Claude mapping preserves the pre-seam behavior EXACTLY:
332
+ * Edit -> one "edit" op carrying its own old_string/replace_all.
333
+ * MultiEdit -> one "edit" op per edits[] entry, each carrying that
334
+ entry's own old_string, "batched": True, and "replace_all": False
335
+ (Claude's MultiEdit entries carry no per-edit replace_all field).
336
+ * NotebookEdit -> one "edit" op, "notebook": True, empty old_string
337
+ (NotebookEdit carries notebook_path and introduces no reasoned-
338
+ about content).
339
+ * Write — and ANY unrecognized/missing tool_name — -> one "write" op.
340
+ The pre-seam pre-write.py never read tool_name at all (it guarded
341
+ every payload carrying a file_path), so the default branch must
342
+ not narrow that: an unrecognized payload is guarded as a write,
343
+ with added_text falling back to new_string exactly as the
344
+ pre-seam post-write-edit.py's `content or new_string` read did.
345
+ """
346
+ payload = payload if isinstance(payload, dict) else {}
347
+ tool = payload.get("tool_name", "") or ""
348
+ ti = payload.get("tool_input", {}) or {}
349
+ if not isinstance(ti, dict):
350
+ return []
351
+ if tool == "MultiEdit":
352
+ fpath = ti.get("file_path", "") or ""
353
+ ops = []
354
+ for e in ti.get("edits", []) or []:
355
+ e = e or {}
356
+ old = e.get("old_string", "") or ""
357
+ new = e.get("new_string", "") or ""
358
+ ops.append({"file_path": fpath, "kind": "edit", "content": None,
359
+ "added_text": new, "added_lines": new.splitlines(),
360
+ "old_string": old, "replace_all": False,
361
+ "batched": True, "notebook": False})
362
+ return ops
363
+ if tool == "Edit":
364
+ fpath = ti.get("file_path", "") or ""
365
+ old = ti.get("old_string", "") or ""
366
+ new = ti.get("new_string", "") or ""
367
+ return [{"file_path": fpath, "kind": "edit", "content": None,
368
+ "added_text": new, "added_lines": new.splitlines(),
369
+ "old_string": old, "replace_all": bool(ti.get("replace_all")),
370
+ "batched": False, "notebook": False}]
371
+ if tool == "NotebookEdit":
372
+ fpath = ti.get("notebook_path", "") or ""
373
+ new = ti.get("new_source", "") or ""
374
+ return [{"file_path": fpath, "kind": "edit", "content": None,
375
+ "added_text": new, "added_lines": new.splitlines(),
376
+ "old_string": "", "replace_all": False,
377
+ "batched": False, "notebook": True}]
378
+ # Write, and the guard-everything default (see docstring).
379
+ fpath = ti.get("file_path", "") or ""
380
+ content = ti.get("content", "") or ""
381
+ added = content or (ti.get("new_string", "") or "")
382
+ return [{"file_path": fpath, "kind": "write", "content": content,
383
+ "added_text": added, "added_lines": added.splitlines(),
384
+ "old_string": "", "replace_all": False,
385
+ "batched": False, "notebook": False}]
386
+
387
+
388
+ class FailClosedHost(Host):
389
+ """The host returned when a `_host.py` is PRESENT but fails to load.
390
+
391
+ A broken `_host.py` means the plugin declared a host we could not
392
+ construct, so we do NOT know whether this install is Claude, Codex, or a
393
+ future host. Silently substituting the Claude-default `Host()` (as
394
+ load_host once did on ANY failure) is unsafe: on a Codex install the base
395
+ `iter_file_ops` cannot decompose an apply_patch envelope, so it yields an
396
+ empty-path "write" op and every pre-write guard skips — the write gate
397
+ silently fails OPEN (tribunal architecture-004 / typesafety-001, both
398
+ reproduced). This host fails CLOSED instead: every write-batching payload
399
+ resolves to a single "opaque" op, which pre-write.py blocks (H-21), and the
400
+ capability flags are the conservative (absent-surface) values. load_host()
401
+ emits a stderr breadcrumb when it returns this, so a broken host is never
402
+ silent (observability-002)."""
403
+
404
+ name = "unknown"
405
+ has_statusline = False
406
+ has_read_tool = False
407
+ has_prunable_transcript = False
408
+
409
+ def iter_file_ops(self, payload):
410
+ # Host identity is unknown, so no payload can be safely mapped to
411
+ # per-file ops. Force the fail-closed "opaque" op unconditionally;
412
+ # pre-write blocks it (H-21) rather than guessing a host's semantics.
413
+ return [{"file_path": "", "kind": "opaque", "content": None,
414
+ "added_text": "", "added_lines": [],
415
+ "old_string": "", "replace_all": False,
416
+ "batched": False, "notebook": False}]
417
+
418
+ def mcp_config_sources(self, project_root):
419
+ # Same reason as iter_file_ops: the host is unknown, so no config
420
+ # path can be claimed. Reading ~/.claude.json here would report a
421
+ # DIFFERENT host's MCP configuration as this install's. Diagnostics
422
+ # fail quiet (#270) — the write-path guards are what fail closed.
423
+ return []
424
+
425
+
426
+ def load_host(hooks_dir=None):
427
+ """The Host instance for this plugin: `HOST` from the `_host.py` sitting
428
+ beside this file (or in `hooks_dir`, an override for tests), loaded by
429
+ explicit file path (each plugin ships its own `_host.py`; the shared core
430
+ deliberately does not). The failure semantics distinguish two cases so a
431
+ broken host can never silently degrade to the WRONG host's guards:
432
+
433
+ * `_host.py` ABSENT -> the bare-core / partial-install fallback: return
434
+ the built-in Claude default `Host()`, exactly as before. No plugin
435
+ declared a host, so Claude defaults are the only meaningful answer and
436
+ "degrade to today's behavior" is safe.
437
+ * `_host.py` PRESENT but fails to load (syntax error, import error,
438
+ unreadable, or no `HOST` symbol) -> a declared host we could not
439
+ construct. Emit a stderr breadcrumb and return `FailClosedHost()`,
440
+ which blocks every write rather than assuming Claude semantics
441
+ (architecture-004 / observability-002 / typesafety-001). The old
442
+ blanket fallback was only safe when the intended host IS Claude."""
443
+ base = hooks_dir or os.path.dirname(os.path.abspath(__file__))
444
+ path = os.path.join(base, "_host.py")
445
+ if not os.path.isfile(path):
446
+ return Host()
447
+ try:
448
+ import importlib.util
449
+ spec = importlib.util.spec_from_file_location("_host", path)
450
+ mod = importlib.util.module_from_spec(spec)
451
+ spec.loader.exec_module(mod)
452
+ return mod.HOST
453
+ except Exception as e: # noqa: BLE001 — declared-but-broken host -> fail closed
454
+ sys.stderr.write(
455
+ "codeArbiter: _host.py is present but failed to load "
456
+ "(%s: %s) — failing closed (writes blocked) rather than assuming "
457
+ "the default host. Reinstall or fix the plugin's _host.py.\n"
458
+ % (type(e).__name__, e)
459
+ )
460
+ return FailClosedHost()