@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,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)