@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.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- 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()
|