@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,420 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter v2 — install-health doctor (the mechanical half of /ca:doctor).
|
|
3
|
+
#
|
|
4
|
+
# Silent dormancy is the worst failure shape this plugin has: the gates look
|
|
5
|
+
# installed but never fire, and nothing says so. It has happened in the wild —
|
|
6
|
+
# a stale plugin cache survived `claude plugin update` because the version
|
|
7
|
+
# string was unchanged, leaving months-old hooks in place until a full
|
|
8
|
+
# uninstall+reinstall. This script checks everything checkable from a single
|
|
9
|
+
# process; host-specific command surfaces add their supported execution
|
|
10
|
+
# evidence separately.
|
|
11
|
+
#
|
|
12
|
+
# Output: one OK / WARN / FAIL line per check, then a verdict. Exit 0 when
|
|
13
|
+
# nothing FAILed; exit 1 otherwise. Read-only — changes nothing.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
from _gitexec import git_executable # noqa: E402
|
|
23
|
+
import hostapi # noqa: E402 — host seam (ADR-0011): plugin-root resolution
|
|
24
|
+
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
25
|
+
import _githooks # noqa: E402 — #556: git-hook drop-in registry freshness
|
|
26
|
+
from _hooklib import frontmatter_enabled, get_host, set_host, utf8_stdio # noqa: E402
|
|
27
|
+
|
|
28
|
+
HOOK_SCRIPTS = ("session-start.py", "pre-bash.py", "pre-write.py",
|
|
29
|
+
"pre-edit.py", "post-write-edit.py", "prune-transcript.py")
|
|
30
|
+
PI_BRIDGE_SCRIPTS = ("pi-bridge.py", "git-enforce.py", "_githooks.py")
|
|
31
|
+
|
|
32
|
+
# MCP config files are read whole to be counted. `~/.claude.json` also carries
|
|
33
|
+
# session history, so it can be large; past this ceiling the source counts as
|
|
34
|
+
# unreadable and the check goes quiet rather than stalling /ca:doctor.
|
|
35
|
+
MCP_CONFIG_MAX_BYTES = 16 * 1024 * 1024
|
|
36
|
+
# Depth ceiling for the `mcpServers` walk below. Claude's deepest real nesting
|
|
37
|
+
# is projects -> <path> -> mcpServers (depth 2); the ceiling just stops a
|
|
38
|
+
# pathological document from costing a full traversal.
|
|
39
|
+
MCP_SCAN_MAX_DEPTH = 6
|
|
40
|
+
|
|
41
|
+
results = [] # (level, line)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ok(line):
|
|
45
|
+
results.append(("OK", line))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warn(line):
|
|
49
|
+
results.append(("WARN", line))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fail(line):
|
|
53
|
+
results.append(("FAIL", line))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_cmd(args, **kw):
|
|
57
|
+
# Renamed from `run` (ADR-0011): `run(host)` is now the host-seam entry
|
|
58
|
+
# point every hook script exposes, so the subprocess helper cedes the name.
|
|
59
|
+
return subprocess.run(args, capture_output=True, text=True,
|
|
60
|
+
encoding="utf-8", errors="replace", timeout=15, **kw)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plugin_root():
|
|
64
|
+
# Host seam (ADR-0011): CLAUDE_PLUGIN_ROOT then file-relative, exactly the
|
|
65
|
+
# prior inline lookup; abspath preserved from the pre-seam behavior here.
|
|
66
|
+
# get_host() (#257), not a direct hostapi.load_host(): resolves the SAME
|
|
67
|
+
# Host run(host) injected instead of triggering a second disk load.
|
|
68
|
+
return os.path.abspath(get_host().plugin_root())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_interpreters():
|
|
72
|
+
"""The hooks need python3 OR python on PATH; the dual registration in
|
|
73
|
+
hooks.json tolerates a Store-alias python3 stub as long as real `python`
|
|
74
|
+
exists behind it."""
|
|
75
|
+
p3, p = shutil.which("python3"), shutil.which("python")
|
|
76
|
+
stub = False
|
|
77
|
+
if p3:
|
|
78
|
+
try:
|
|
79
|
+
stub = _run_cmd([p3, "-c", ""]).returncode != 0
|
|
80
|
+
except Exception: # noqa: BLE001
|
|
81
|
+
stub = True
|
|
82
|
+
if p3 and not stub:
|
|
83
|
+
ok(f"python3 resolves and runs ({p3})")
|
|
84
|
+
elif p3 and stub:
|
|
85
|
+
(warn if p else fail)(
|
|
86
|
+
f"python3 resolves but does not run ({p3}) — the Microsoft Store "
|
|
87
|
+
f"alias stub. " + ("The hook fallback entries cover this via `python`."
|
|
88
|
+
if p else "And no `python` behind it: EVERY GATE IS "
|
|
89
|
+
"DORMANT. Install Python or disable the Store alias."))
|
|
90
|
+
elif not p3 and p:
|
|
91
|
+
ok("no python3 on PATH, but `python` resolves — the hook fallback entries cover this")
|
|
92
|
+
else:
|
|
93
|
+
fail("neither python3 nor python resolves on PATH — every hook fails loud, "
|
|
94
|
+
"no gate evaluates. Install Python 3.")
|
|
95
|
+
if p and not (p3 and not stub):
|
|
96
|
+
ok(f"python resolves ({p})")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def check_payload(root, host=None):
|
|
100
|
+
# host-aware manifest path (#263): defaults to get_host() (#257 — not a
|
|
101
|
+
# direct hostapi.load_host(), so this stays the SAME injected instance)
|
|
102
|
+
# so every pre-seam call site (main() below, and every existing test that
|
|
103
|
+
# calls check_payload(root) positionally) keeps resolving the manifest
|
|
104
|
+
# for whichever host is actually running, without threading a host
|
|
105
|
+
# through every caller.
|
|
106
|
+
host = host or get_host()
|
|
107
|
+
manifest = os.path.join(root, host.manifest_relpath())
|
|
108
|
+
version = None
|
|
109
|
+
try:
|
|
110
|
+
with open(manifest, encoding="utf-8") as f:
|
|
111
|
+
version = json.load(f).get("version")
|
|
112
|
+
ok(f"plugin payload at {root} (version {version})")
|
|
113
|
+
except Exception as e: # noqa: BLE001
|
|
114
|
+
fail(f"plugin.json unreadable at {manifest}: {e}")
|
|
115
|
+
if getattr(host, "name", "unknown") == "pi":
|
|
116
|
+
try:
|
|
117
|
+
with open(manifest, encoding="utf-8") as f:
|
|
118
|
+
package = json.load(f)
|
|
119
|
+
pi_config = package.get("pi") if isinstance(package, dict) else None
|
|
120
|
+
extensions = pi_config.get("extensions") if isinstance(pi_config, dict) else None
|
|
121
|
+
skills = pi_config.get("skills") if isinstance(pi_config, dict) else None
|
|
122
|
+
if (package.get("name") == "ca-pi"
|
|
123
|
+
and extensions == ["./extensions/codearbiter.js"]
|
|
124
|
+
and skills == ["./skills"]):
|
|
125
|
+
ok("package.json declares only the ca-pi extension and generated skills")
|
|
126
|
+
else:
|
|
127
|
+
fail("package.json has an invalid ca-pi package/discovery contract; reinstall ca-pi")
|
|
128
|
+
except Exception as e: # noqa: BLE001
|
|
129
|
+
fail(f"package.json unreadable at {manifest}: {e}")
|
|
130
|
+
required = HOOK_SCRIPTS + PI_BRIDGE_SCRIPTS
|
|
131
|
+
missing = [s for s in required
|
|
132
|
+
if not os.path.isfile(os.path.join(root, "hooks", s))]
|
|
133
|
+
if missing:
|
|
134
|
+
fail(f"Pi shared core script(s) missing: {', '.join(missing)}")
|
|
135
|
+
else:
|
|
136
|
+
ok(f"Pi shared core present: pi-bridge.py and {len(required) - 1} support scripts")
|
|
137
|
+
return
|
|
138
|
+
hooks_json = os.path.join(root, "hooks", "hooks.json")
|
|
139
|
+
try:
|
|
140
|
+
with open(hooks_json, encoding="utf-8") as f:
|
|
141
|
+
config = json.load(f)["hooks"]
|
|
142
|
+
entries = sum(len(g["hooks"]) for groups in config.values() for g in groups)
|
|
143
|
+
ok(f"hooks.json parses: {len(config)} events, {entries} entries")
|
|
144
|
+
except Exception as e: # noqa: BLE001
|
|
145
|
+
fail(f"hooks/hooks.json unreadable or malformed: {e} — no gate is registered")
|
|
146
|
+
missing = [s for s in HOOK_SCRIPTS
|
|
147
|
+
if not os.path.isfile(os.path.join(root, "hooks", s))]
|
|
148
|
+
if missing:
|
|
149
|
+
fail(f"hook script(s) missing from the payload: {', '.join(missing)}")
|
|
150
|
+
else:
|
|
151
|
+
ok(f"all {len(HOOK_SCRIPTS)} hook scripts present")
|
|
152
|
+
|
|
153
|
+
# The stale-cache trap: `claude plugin update` no-ops when the version
|
|
154
|
+
# string is unchanged, so a cache dir can hold a payload months older
|
|
155
|
+
# than the marketplace. Multiple cached versions are the breadcrumb.
|
|
156
|
+
norm = root.replace("\\", "/")
|
|
157
|
+
if "/plugins/cache/" in norm:
|
|
158
|
+
versions_dir = os.path.dirname(root)
|
|
159
|
+
try:
|
|
160
|
+
siblings = sorted(d for d in os.listdir(versions_dir)
|
|
161
|
+
if os.path.isdir(os.path.join(versions_dir, d)))
|
|
162
|
+
if len(siblings) > 1:
|
|
163
|
+
warn(f"multiple cached plugin versions ({', '.join(siblings)}) under "
|
|
164
|
+
f"{versions_dir} — if behavior looks stale, uninstall+reinstall; "
|
|
165
|
+
f"`claude plugin update` no-ops on an unchanged version string")
|
|
166
|
+
else:
|
|
167
|
+
ok("single cached plugin version (no stale siblings)")
|
|
168
|
+
except Exception: # noqa: BLE001
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def check_repo():
|
|
173
|
+
"""Returns the resolved repo root (for check_git_hook_freshness below), or
|
|
174
|
+
None when this process isn't inside a git repository at all."""
|
|
175
|
+
r = _run_cmd([git_executable(), "rev-parse", "--show-toplevel"])
|
|
176
|
+
if r.returncode != 0:
|
|
177
|
+
warn("not inside a git repository — repo-level checks skipped")
|
|
178
|
+
return None
|
|
179
|
+
root = r.stdout.strip()
|
|
180
|
+
ctx = os.path.join(root, ".codearbiter", "CONTEXT.md")
|
|
181
|
+
if not os.path.isfile(ctx):
|
|
182
|
+
ok(f"no .codearbiter/CONTEXT.md in {root} — codeArbiter is dormant here "
|
|
183
|
+
f"by design (run {get_host().cmd_ref('init')} to opt in)")
|
|
184
|
+
return root
|
|
185
|
+
enabled, malformed = frontmatter_enabled(ctx)
|
|
186
|
+
if malformed:
|
|
187
|
+
fail("CONTEXT.md frontmatter opens with --- but never closes — the "
|
|
188
|
+
"activation check cannot evaluate; fix the frontmatter")
|
|
189
|
+
elif enabled:
|
|
190
|
+
ok("repo is arbiter-enabled (CONTEXT.md frontmatter)")
|
|
191
|
+
else:
|
|
192
|
+
ok("CONTEXT.md present but not arbiter-enabled — gates dormant by design")
|
|
193
|
+
if enabled:
|
|
194
|
+
with open(ctx, encoding="utf-8", errors="replace") as f:
|
|
195
|
+
body = f.read()
|
|
196
|
+
if "<!--INITIALIZED-->" in body:
|
|
197
|
+
ok("project is initialized (<!--INITIALIZED--> marker present)")
|
|
198
|
+
else:
|
|
199
|
+
warn(f"no <!--INITIALIZED--> marker — startup will route to "
|
|
200
|
+
f"{get_host().cmd_ref('decompose')} or {get_host().cmd_ref('create-context')}")
|
|
201
|
+
email = _run_cmd([git_executable(), "config", "user.email"]).stdout.strip()
|
|
202
|
+
if email:
|
|
203
|
+
ok(f"git identity for audit attribution: {email}")
|
|
204
|
+
else:
|
|
205
|
+
warn("git user.email is unset — overrides/ADRs cannot be attributed; "
|
|
206
|
+
"set it before gated work")
|
|
207
|
+
return root
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_git_hook_freshness(root):
|
|
211
|
+
"""#556 (AC-3): the git-level hook backstop (#161) can be running from a
|
|
212
|
+
host's plugin cache that nobody has refreshed in a long time — a cache
|
|
213
|
+
that predates a fix THIS checkout already carries (the #279
|
|
214
|
+
sensitive-scan exemption, in the issue that motivated this check) is
|
|
215
|
+
still wired into `.git/hooks` and can still block on stale logic. Before
|
|
216
|
+
this check existed, nothing surfaced that until it produced a false
|
|
217
|
+
block. Runs the SAME freshness probe the generated shim itself runs at
|
|
218
|
+
commit/push time (`_githooks.stale_registered_plugins`), so this can
|
|
219
|
+
never disagree with what actually happens at commit time.
|
|
220
|
+
|
|
221
|
+
A no-op (nothing printed) when `root` is None (not inside a git repo —
|
|
222
|
+
already reported by check_repo) or the drop-in registry doesn't exist
|
|
223
|
+
yet (nothing installed, or a host that predates ADR-0014)."""
|
|
224
|
+
if root is None:
|
|
225
|
+
return
|
|
226
|
+
dropin_dir = _githooks._dropin_dir(root)
|
|
227
|
+
if dropin_dir is None or not os.path.isdir(dropin_dir):
|
|
228
|
+
return
|
|
229
|
+
stale = _githooks.stale_registered_plugins(dropin_dir)
|
|
230
|
+
if stale:
|
|
231
|
+
names = ", ".join(sorted(stale))
|
|
232
|
+
plural = "entries" if len(stale) != 1 else "entry"
|
|
233
|
+
warn(f"the git-hook backstop's drop-in registry (#161/#556) has a stale "
|
|
234
|
+
f"enforcer {plural} for: {names} — a fresher registered sibling "
|
|
235
|
+
f"exists (.git/codearbiter-hooksd), and the shim already skips the "
|
|
236
|
+
f"stale {plural} rather than let it false-block. Start a session for "
|
|
237
|
+
f"that host, or reinstall it, to refresh {names}.")
|
|
238
|
+
else:
|
|
239
|
+
ok("git-hook backstop registry (.git/codearbiter-hooksd) has no stale "
|
|
240
|
+
"enforcer entries")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def check_host(host):
|
|
244
|
+
"""observability-004 (#268): surface which host was resolved — a dormant
|
|
245
|
+
Codex install (or any broken `_host.py`) was previously indistinguishable
|
|
246
|
+
from a working one, since nothing in doctor's output named the host at
|
|
247
|
+
all. `host.name` is "claude" / "codex" under a working install, or
|
|
248
|
+
"unknown" for the FailClosedHost hostapi.load_host() returns when a
|
|
249
|
+
declared `_host.py` is present but failed to load (#255) — that "unknown"
|
|
250
|
+
case is exactly the dormant install this check exists to surface, so it
|
|
251
|
+
WARNs (not OKs) with an actionable pointer rather than reporting green."""
|
|
252
|
+
name = getattr(host, "name", "unknown")
|
|
253
|
+
if name == "unknown":
|
|
254
|
+
warn("host resolution failed — enforcing as 'unknown' (a declared "
|
|
255
|
+
"_host.py was present but failed to load); writes fail closed. "
|
|
256
|
+
"Reinstall or fix the plugin's _host.py.")
|
|
257
|
+
else:
|
|
258
|
+
ok(f"resolved host: {name}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _mcp_json_count(doc, key, depth=0):
|
|
262
|
+
"""How many servers a parsed JSON document declares under `key`.
|
|
263
|
+
|
|
264
|
+
Walks nested mappings because Claude Code uses the SAME key at two
|
|
265
|
+
depths: `mcpServers` at the top level of `~/.claude.json` (user scope)
|
|
266
|
+
and under `projects.<path>.mcpServers` (local scope). Only the number of
|
|
267
|
+
entries is taken — no name, command, or argument value is ever read out
|
|
268
|
+
(#449: doctor output is redaction-sensitive)."""
|
|
269
|
+
if not isinstance(doc, dict) or depth > MCP_SCAN_MAX_DEPTH:
|
|
270
|
+
return 0
|
|
271
|
+
total = 0
|
|
272
|
+
for k, v in doc.items():
|
|
273
|
+
if k == key and isinstance(v, dict):
|
|
274
|
+
total += len(v)
|
|
275
|
+
elif isinstance(v, dict):
|
|
276
|
+
total += _mcp_json_count(v, key, depth + 1)
|
|
277
|
+
return total
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _mcp_source_count(path, key):
|
|
281
|
+
"""Servers declared in one config file: an int, or None when the file
|
|
282
|
+
exists but cannot be read as configuration (unparseable, oversize,
|
|
283
|
+
unreadable, or a format this build cannot parse). An ABSENT file is a
|
|
284
|
+
legitimate "none configured", so it counts 0 — only a file we cannot
|
|
285
|
+
interpret is unknown."""
|
|
286
|
+
if not os.path.isfile(path):
|
|
287
|
+
return 0
|
|
288
|
+
try:
|
|
289
|
+
if os.path.getsize(path) > MCP_CONFIG_MAX_BYTES:
|
|
290
|
+
return None
|
|
291
|
+
with open(path, encoding="utf-8") as f:
|
|
292
|
+
text = f.read()
|
|
293
|
+
if path.lower().endswith(".toml"):
|
|
294
|
+
import tomllib # 3.11+; older interpreters degrade to unknown
|
|
295
|
+
table = tomllib.loads(text).get(key)
|
|
296
|
+
return len(table) if isinstance(table, dict) else 0
|
|
297
|
+
return _mcp_json_count(json.loads(text), key)
|
|
298
|
+
except Exception: # noqa: BLE001 — a diagnostic must never be the failure
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def check_mcp(host):
|
|
303
|
+
"""#270 (tribunal appsec-002): report that MCP tools are configured, and
|
|
304
|
+
that writes performed through them are outside the write gate.
|
|
305
|
+
|
|
306
|
+
The gap is real on every host — `mcp__<server>__<tool>` misses Claude's
|
|
307
|
+
`Write`/`Edit` matchers and normalizes to "OTHER" on Codex — and it is
|
|
308
|
+
ACCEPTED residual risk under ADR-0010, NOT something this check denies.
|
|
309
|
+
What it does is make the acceptance visible where the risk actually
|
|
310
|
+
lives: codeArbiter is BUILT in this repo but RUN in consumers' repos, so
|
|
311
|
+
a build-time check here would never fire and security-controls.md alone
|
|
312
|
+
reaches nobody who is carrying the risk. `/ca:doctor` does.
|
|
313
|
+
|
|
314
|
+
Host-agnostic by construction: the config locations come from
|
|
315
|
+
`host.mcp_config_sources()`, never from a hard-coded path. Every failure
|
|
316
|
+
mode degrades to SILENCE — a host too old for the seam, a host that
|
|
317
|
+
declares no sources, a raising seam, an unreadable or oversize file.
|
|
318
|
+
/ca:doctor is the tool of last resort when enforcement misbehaves, so a
|
|
319
|
+
diagnostic that errors is worse than one that is quiet.
|
|
320
|
+
|
|
321
|
+
Reports a COUNT only. Server names, commands, arguments, and environment
|
|
322
|
+
are never read out (#449)."""
|
|
323
|
+
try:
|
|
324
|
+
sources = host.mcp_config_sources(host.project_root())
|
|
325
|
+
except Exception: # noqa: BLE001 — no seam, or a seam that raised
|
|
326
|
+
return
|
|
327
|
+
if not sources:
|
|
328
|
+
return # this host's MCP surface is unknown — say nothing
|
|
329
|
+
total, unknown = 0, False
|
|
330
|
+
for source in sources or ():
|
|
331
|
+
try:
|
|
332
|
+
path, key = source
|
|
333
|
+
count = _mcp_source_count(path, key)
|
|
334
|
+
except Exception: # noqa: BLE001 — malformed source descriptor
|
|
335
|
+
count = None
|
|
336
|
+
if count is None:
|
|
337
|
+
unknown = True
|
|
338
|
+
else:
|
|
339
|
+
total += count
|
|
340
|
+
if total > 0:
|
|
341
|
+
warn(f"{total} MCP server{'s' if total != 1 else ''} configured for "
|
|
342
|
+
f"this host — file writes made through an MCP tool "
|
|
343
|
+
f"(mcp__<server>__<tool>) bypass the codeArbiter write gate: no "
|
|
344
|
+
f"pre-write/pre-edit guard evaluates them and no gate event is "
|
|
345
|
+
f"recorded. Accepted residual risk under ADR-0010 — keep "
|
|
346
|
+
f"protected writes on this host's native write tools, or remove "
|
|
347
|
+
f"MCP servers that can write files.")
|
|
348
|
+
elif not unknown:
|
|
349
|
+
ok("no MCP servers configured for this host — nothing bypasses the "
|
|
350
|
+
"write gate through an MCP tool")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def check_statusline(root):
|
|
354
|
+
settings = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
|
355
|
+
try:
|
|
356
|
+
with open(settings, encoding="utf-8") as f:
|
|
357
|
+
cmd = (json.load(f).get("statusLine") or {}).get("command", "")
|
|
358
|
+
except Exception: # noqa: BLE001
|
|
359
|
+
cmd = ""
|
|
360
|
+
if "statusline.py" in cmd:
|
|
361
|
+
ok("statusline wired in ~/.claude/settings.json")
|
|
362
|
+
else:
|
|
363
|
+
ok(f"statusline not wired (optional — "
|
|
364
|
+
f"{get_host().cmd_ref('statusline')} installs it)")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def main():
|
|
368
|
+
utf8_stdio()
|
|
369
|
+
root = plugin_root()
|
|
370
|
+
# get_host() (#257): resolves the SAME Host run(host) already primed via
|
|
371
|
+
# set_host(), instead of a second hostapi.load_host() disk/probe.
|
|
372
|
+
host = get_host()
|
|
373
|
+
check_host(host)
|
|
374
|
+
check_interpreters()
|
|
375
|
+
check_payload(root, host)
|
|
376
|
+
repo_root = check_repo()
|
|
377
|
+
check_git_hook_freshness(repo_root)
|
|
378
|
+
check_mcp(host)
|
|
379
|
+
if getattr(host, "has_statusline", True):
|
|
380
|
+
# A host with no statusline surface (Codex) must not read
|
|
381
|
+
# ~/.claude/settings.json or advertise a statusline install path.
|
|
382
|
+
# getattr with the Claude default so a duck-typed host (tests) that
|
|
383
|
+
# declares no capability flags keeps today's behavior.
|
|
384
|
+
check_statusline(root)
|
|
385
|
+
width = max(len(lvl) for lvl, _ in results)
|
|
386
|
+
for lvl, line in results:
|
|
387
|
+
print(f"{lvl:<{width}} {line}")
|
|
388
|
+
fails = sum(1 for lvl, _ in results if lvl == "FAIL")
|
|
389
|
+
warns = sum(1 for lvl, _ in results if lvl == "WARN")
|
|
390
|
+
print(f"\ndoctor: {fails} FAIL, {warns} WARN, "
|
|
391
|
+
f"{len(results) - fails - warns} OK")
|
|
392
|
+
if fails:
|
|
393
|
+
print("verdict: UNHEALTHY — at least one gate cannot function as installed")
|
|
394
|
+
sys.exit(1)
|
|
395
|
+
if get_host().name == "pi":
|
|
396
|
+
print("verdict: healthy (static checks) — /ca-doctor adds a wrapper "
|
|
397
|
+
"self-test and reports the active-dispatch coverage gap")
|
|
398
|
+
else:
|
|
399
|
+
print(f"verdict: healthy (static checks) — the live-fire probe in "
|
|
400
|
+
f"{get_host().cmd_ref('doctor')} proves hooks actually fire")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def run(host, argv=None):
|
|
404
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
405
|
+
plugin's loaded Host. Wraps main() unchanged — main() still communicates
|
|
406
|
+
via sys.exit/stdout/stderr, and its return value stays discarded exactly
|
|
407
|
+
as the old bare `main()` guard discarded it (so the process still exits 0
|
|
408
|
+
on a normal fall-through).
|
|
409
|
+
|
|
410
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
411
|
+
`set_host()` BEFORE main() runs, so `plugin_root()`/`main()`'s `get_host()`
|
|
412
|
+
calls resolve to the SAME instance the caller passed here — no second
|
|
413
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
414
|
+
`fake_host`."""
|
|
415
|
+
return _entrylib.dispatch(host, argv, main, set_host,
|
|
416
|
+
pass_argv=False, propagate_result=False)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
sys.exit(run(hostapi.load_host()) or 0)
|