@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,196 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter - ACTIVATION: which Host this process is running under, where the
3
+ # project root is, and whether the arbiter is switched on for it.
4
+ #
5
+ # Extracted from _hooklib (issue #321, architecture-002) as slice 4, the last and
6
+ # the one with real teeth. Measured like the others: this cluster references
7
+ # NOTHING from the rest of _hooklib. The edge runs the other way for the first
8
+ # time - _log_gate_event (which stays behind) calls get_host() and
9
+ # project_root() - so _hooklib imports from here and never the reverse. Still
10
+ # one-way, still no cycle.
11
+ #
12
+ # WHY THESE BELONG TOGETHER: they are the three answers every hook needs before
13
+ # it can decide anything, and they share two process-lifetime caches. `_HOST` and
14
+ # `_ROOT_CACHE` exist because a hook is a single-shot process whose host identity
15
+ # and project root cannot change mid-run; arbiter_active reads the root to find
16
+ # CONTEXT.md, and project_root reads the host. Splitting them would put a cache
17
+ # in one module and its only writer in another.
18
+ #
19
+ # ON THE MUTABLE GLOBALS, because this is the trap in this slice:
20
+ # `_HOST` is deliberately NOT re-exported by _hooklib. `from _activationlib
21
+ # import _HOST` binds the VALUE at import time, so a later set_host() would
22
+ # rebind the name HERE and leave _hooklib holding a stale None forever - the
23
+ # exact silent-divergence bug the partition exists to prevent. The three
24
+ # accessors ARE re-exported, and they all read and write this module's global,
25
+ # so every caller sees one cache no matter which module it imported them from.
26
+ # That is the opposite of slice 3's lesson (re-export the privates) and for a
27
+ # precise reason: a FUNCTION binding stays live, a VALUE binding does not.
28
+ # Verified: nothing in the repo reads _hooklib._HOST directly.
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+
35
+ import hostapi
36
+
37
+
38
+ # The loaded Host is process-cached: hooks are single-shot processes, and the
39
+ # host's identity cannot change mid-process (its methods read env/payload state
40
+ # live at call time, so caching the OBJECT changes no verdict).
41
+ _HOST = None
42
+
43
+ def get_host():
44
+ """The process's Host instance (hostapi.load_host(), cached)."""
45
+ global _HOST
46
+ if _HOST is None:
47
+ _HOST = hostapi.load_host()
48
+ return _HOST
49
+
50
+
51
+ def set_host(host):
52
+ """Dependency-injection seam (#257 architecture-001/performance-002).
53
+
54
+ Primes the module-cached `_HOST` that `get_host()` reads. Every entry
55
+ script's `run(host, argv=None)` calls this BEFORE `main()`, so the Host
56
+ instance the `__main__` guard already resolved via `hostapi.load_host()`
57
+ is the SAME object `get_host()` serves inside `main()` — closing two
58
+ defects at once: (1) `main()` no longer triggers its own redundant
59
+ `hostapi.load_host()` (a second `_host.py` load per invocation), and
60
+ (2) `run(host)` stops silently ignoring its `host` argument — a test that
61
+ calls `run(fake_host)` now genuinely exercises `fake_host`, not whatever
62
+ `load_host()` resolves from disk. In production the injected host IS the
63
+ `load_host()` result the guard already computed, so this changes no
64
+ behavior — it only removes the redundant second load and makes the
65
+ existing `run(host)` parameter live."""
66
+ global _HOST
67
+ _HOST = host
68
+
69
+
70
+ def reset_host():
71
+ """Test-only: clear the injected/cached `_HOST` so the next `get_host()`
72
+ lazy-loads afresh. Production hook processes are single-shot and never need
73
+ this; but `set_host()` makes `_HOST` a process-lifetime singleton, so a test
74
+ that calls `run(fake_host)` must reset it in tearDown — otherwise the fake
75
+ leaks into any later in-process test that calls `get_host()` without its own
76
+ patch, silently running against the wrong host and masking a gate
77
+ regression (security review #257, LOW)."""
78
+ global _HOST
79
+ _HOST = None
80
+
81
+ # project_root() memoization (performance-001/003, #260). A hook is a
82
+ # single-shot process, so CLAUDE_PROJECT_DIR and the process cwd cannot
83
+ # change mid-process — but the resolved VALUE is cached keyed on those two
84
+ # inputs (not unconditionally) rather than as one bare value, so an env/cwd
85
+ # change is a cache MISS, never a stale hit. This keeps the production
86
+ # single-shot contract (the same hook process always sees an unchanging
87
+ # env/cwd, so it resolves at most once) while staying correct for the
88
+ # in-process integration-test harnesses that legitimately re-target
89
+ # project_root() across many fixtures/envs within one Python process
90
+ # (`python -m unittest discover` runs the whole suite in ONE interpreter —
91
+ # an unconditional single-value cache would leak the FIRST test's resolved
92
+ # root into every later test that calls project_root() or warn()/block()/
93
+ # remind() in-process). A payload's `cwd` is deliberately NOT part of the
94
+ # cache key: within one real hook process the payload is parsed at most once
95
+ # and never changes, so a payload-bearing call and a later no-payload call in
96
+ # the SAME (env, cwd) context are the SAME logical resolution and must return
97
+ # the SAME value — exactly the "payload-bearing first call, later no-arg
98
+ # calls stay consistent" contract. (A payload-only scenario — Codex, no
99
+ # CLAUDE_PROJECT_DIR — still resolves once: the first call's payload wins and
100
+ # is cached against the current (env, cwd); env/cwd don't change either.)
101
+ _ROOT_CACHE = {}
102
+
103
+
104
+ def _root_cache_key():
105
+ return (os.environ.get("CLAUDE_PROJECT_DIR"), os.getcwd())
106
+
107
+
108
+ def _reset_root_cache():
109
+ """Test-only: drop every memoized project_root() resolution. Production
110
+ hook processes never need this (each is single-shot); integration tests
111
+ that simulate MANY logical hook invocations in one Python process and
112
+ need a resolution to be genuinely re-computed (rather than served from an
113
+ still-valid (env, cwd) cache entry) call this between scenarios."""
114
+ _ROOT_CACHE.clear()
115
+
116
+
117
+ def marker_root(payload=None):
118
+ """The root `.codearbiter/.markers/` gate passes (security-pass.py,
119
+ migration-pass.py, and the H-09b/H-10b/H-14 guards) are written to and
120
+ read from (#604) — see `hostapi.Host.marker_root`'s docstring for why
121
+ this is NOT the same thing as `project_root()` in a linked worktree.
122
+
123
+ Not memoized like `project_root()` above: called at most once or twice
124
+ per hook process (the marker checks, or a single `security-pass.py` /
125
+ `migration-pass.py` run), so the extra git spawn a linked-worktree
126
+ escalation occasionally costs is not worth a second cache to avoid."""
127
+ return get_host().marker_root(payload)
128
+
129
+ ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)
130
+
131
+ def frontmatter_enabled_text(text):
132
+ """(enabled, malformed) for CONTEXT.md *content* (see frontmatter_enabled).
133
+ Split out so the #159 Write/Edit guard can vet the RESULTING content of an
134
+ edit — 'does this edit keep the repo arbiter-enabled?' — without going to
135
+ disk, sharing one parser with the on-disk activation check so the two never
136
+ disagree on what 'enabled' means."""
137
+ lines = (text or "").split("\n")
138
+ if not lines:
139
+ return (False, False)
140
+ first = lines[0].lstrip("") # tolerate a leading UTF-8 BOM
141
+ if first.strip() != "---":
142
+ return (False, False) # no opening delimiter — dormant, not malformed
143
+ found = False
144
+ for ln in lines[1:]:
145
+ if ln.strip() == "---":
146
+ return (found, False) # closing delimiter — decision is final
147
+ if ARBITER_RE.match(ln):
148
+ found = True
149
+ return (False, True) # opened but never closed — malformed
150
+
151
+
152
+ def frontmatter_enabled(ctx_path):
153
+ """Return (enabled, malformed) for CONTEXT.md ON DISK. `enabled` iff
154
+ `arbiter: enabled` appears in a properly-closed leading YAML frontmatter
155
+ block. `malformed` iff a block opens (`---` on line 1) but never closes — the
156
+ fail-loud case. A file with no frontmatter at all is simply dormant (not
157
+ malformed). Unreadable file -> (False, False)."""
158
+ try:
159
+ with open(ctx_path, encoding="utf-8", errors="replace") as f:
160
+ text = f.read()
161
+ except Exception: # noqa: BLE001
162
+ return (False, False)
163
+ return frontmatter_enabled_text(text)
164
+
165
+
166
+ def arbiter_active(root):
167
+ """True iff this repo opted in (`arbiter: enabled` in CONTEXT.md frontmatter).
168
+ Every enforcement hook gates on this so the plugin is genuinely dormant in
169
+ repos that never opted in — the plugin.json activation contract."""
170
+ enabled, _ = frontmatter_enabled(os.path.join(root, ".codearbiter", "CONTEXT.md"))
171
+ return enabled
172
+
173
+ def project_root(payload=None):
174
+ """The project root. `CLAUDE_PROJECT_DIR` is the harness's own authoritative
175
+ signal and is trusted first: a hook subprocess is not guaranteed to start
176
+ with the project directory as its cwd, and a `git rev-parse` from elsewhere
177
+ can resolve to a different repo entirely (e.g. the plugin's own marketplace
178
+ clone). The env-first read also saves one git spawn per hook invocation.
179
+ Test harnesses that spawn hooks into fixture repos must pin the variable to
180
+ the fixture, as the production harness pins it to the project.
181
+
182
+ The resolution itself lives on the Host seam (hostapi.Host.project_root,
183
+ ADR-0011) — this function keeps its public signature (now accepting an
184
+ optional `payload`, architecture-006/#260, so a caller that already has
185
+ the parsed hook payload can hand it through to the payload-cwd leg) and
186
+ delegates, so every existing no-arg caller/import keeps working unchanged.
187
+
188
+ Memoized per (CLAUDE_PROJECT_DIR, process cwd) — see _ROOT_CACHE above for
189
+ the full contract (performance-001/003, #260): at most one resolution
190
+ (and at most one git spawn) per that key, so the repeated project_root()
191
+ reads inside block()/remind()/warn()'s gate-event logging don't each pay
192
+ a fresh subprocess."""
193
+ key = _root_cache_key()
194
+ if key not in _ROOT_CACHE:
195
+ _ROOT_CACHE[key] = get_host().project_root(payload)
196
+ return _ROOT_CACHE[key]
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — statusline arbiter-state parsing (extracted from statusline.py,
3
+ # architecture-004).
4
+ #
5
+ # Owns reading the .codearbiter/ project-state directory into the compact dict the
6
+ # arbiter segment renders: frontmatter parsing, override/question counting, the
7
+ # mtime-keyed cache (a StopHook-driven statusline re-renders on every tool-call
8
+ # completion, so re-parsing 5 small files on every render would be wasteful), and
9
+ # the dev-mode marker check.
10
+ #
11
+ # The task-in-flight count and the arbiter-enabled gate are OWNED elsewhere
12
+ # (_taskboardlib.count_in_flight / _hooklib.frontmatter_enabled) so the box and the
13
+ # enforcement hooks agree on both contracts. This module never imports them
14
+ # directly — the caller (statusline.py) passes its own guarded-imported references
15
+ # through, so a test that monkeypatches statusline's fallback (e.g.
16
+ # `mod._count_in_flight = None`) is observed correctly on the next call.
17
+ #
18
+ # Design principles (mirroring _ledgerlib.py):
19
+ # - Stdlib only; no third-party imports ever.
20
+ # - Zero side effects at import time.
21
+ # - Never raise on malformed user input — degrade to a safe default.
22
+ #
23
+ # Public API:
24
+ # frontmatter(path) -> dict parsed leading YAML frontmatter block
25
+ # frontmatter_text(text) -> dict same parse, given already-read text (performance-003)
26
+ # count_matches(path, pattern) -> int regex match count in a file (0 on any I/O error)
27
+ # count_matches_text(text, pattern) -> int same count, given already-read text (performance-003)
28
+ # arbiter_state(root, count_in_flight=None, read_board=None, frontmatter_enabled=None,
29
+ # ctx_text=None, ot_text=None, oq_text=None) -> dict|None
30
+ # dev_active(root) -> bool True when the /dev marker is present
31
+ #
32
+ # performance-003 (#194): SessionStart's main() already reads CONTEXT.md,
33
+ # open-tasks.md, and open-questions.md before the display-only governance line
34
+ # is ever rendered. The optional ctx_text/ot_text/oq_text kwargs on
35
+ # arbiter_state let a caller that already holds that content thread it through
36
+ # instead of paying for a second disk read of the same file in the same
37
+ # process. None (the default) means "not supplied" -> falls back to the
38
+ # original read-from-disk behavior, so every existing caller (the standalone
39
+ # statusline.py render, direct arbiter_state(root) calls in tests) is
40
+ # unaffected.
41
+
42
+ import os
43
+ import re
44
+
45
+ # mtime-keyed memo: statusline.py is a short-lived subprocess, but a single render
46
+ # can resolve arbiter_state more than once (safe() probes), and the StopHook fires
47
+ # the whole script on every tool-call completion. Caching on max(input mtime) makes
48
+ # the 5 .codearbiter/ reads happen at most once per (root, change), re-reading only
49
+ # when one of the inputs actually changes between renders.
50
+ _ARBITER_CACHE = {} # root -> (mtime_key, result)
51
+ _ARBITER_FILES = ("CONTEXT.md", "overrides.log", "last-checkpoint",
52
+ "open-tasks.md", "open-questions.md", "sprint-active")
53
+
54
+
55
+ def frontmatter_text(text):
56
+ """Parse a properly-closed leading YAML frontmatter block out of already-read
57
+ `text` into a key map (see frontmatter() for the on-disk counterpart — this is
58
+ the pure text half, extracted for performance-003 so a caller holding the
59
+ content already can skip a second disk read). Tolerates a leading UTF-8 BOM
60
+ character (\\ufeff) on the first line the same way frontmatter()'s utf-8-sig
61
+ decode does, regardless of how the caller's text was originally decoded."""
62
+ fm = {}
63
+ lines = (text or "").splitlines()
64
+ if not lines:
65
+ return fm
66
+ first = lines[0].lstrip("")
67
+ if first.strip() != "---":
68
+ return fm
69
+ closed = False
70
+ for ln in lines[1:]:
71
+ if ln.strip() == "---":
72
+ closed = True
73
+ break
74
+ m = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", ln)
75
+ if m:
76
+ fm[m.group(1)] = m.group(2).strip()
77
+ # A valid YAML frontmatter block is bounded by BOTH delimiters; an unterminated
78
+ # block (no closing "---") is malformed — don't honor keys parsed to EOF.
79
+ return fm if closed else {}
80
+
81
+
82
+ def frontmatter(path):
83
+ """Parse a properly-closed leading YAML frontmatter block into a key map. The
84
+ *arbiter-enabled* decision is NOT made here — that activation contract is owned
85
+ by _hooklib.frontmatter_enabled (see arbiter_state) so the box and the
86
+ enforcement hooks read it one way. This reader exists only to surface the
87
+ remaining display keys (e.g. `stage`) the boolean gate doesn't carry."""
88
+ try:
89
+ # utf-8-sig transparently strips a leading BOM (Windows editors / PowerShell
90
+ # Out-File default to UTF-8-with-BOM); plain utf-8 would leave it on line 1
91
+ # and break the "---" frontmatter check.
92
+ with open(path, encoding="utf-8-sig", errors="replace") as f:
93
+ text = f.read()
94
+ except OSError:
95
+ return {}
96
+ return frontmatter_text(text)
97
+
98
+
99
+ def count_matches_text(text, pattern):
100
+ """Regex match count against already-read `text` (see count_matches() for the
101
+ on-disk counterpart — the pure text half, extracted for performance-003)."""
102
+ return len(re.findall(pattern, text or "", re.MULTILINE))
103
+
104
+
105
+ def count_matches(path, pattern):
106
+ try:
107
+ with open(path, encoding="utf-8", errors="replace") as f:
108
+ text = f.read()
109
+ except OSError:
110
+ return 0
111
+ return count_matches_text(text, pattern)
112
+
113
+
114
+ def _arbiter_mtime_key(cad):
115
+ """Max mtime across the arbiter input files (missing files stat as -1.0). Two
116
+ renders with the same key saw identical inputs, so the cached state is valid."""
117
+ latest = -1.0
118
+ for nm in _ARBITER_FILES:
119
+ try:
120
+ latest = max(latest, os.stat(os.path.join(cad, nm)).st_mtime)
121
+ except OSError:
122
+ pass
123
+ return latest
124
+
125
+
126
+ def _arbiter_enabled(ctx_path, frontmatter_enabled=None, ctx_text=None):
127
+ """The arbiter-enabled gate, owned by _hooklib.frontmatter_enabled when the lib
128
+ is importable (so the box and the enforcement hooks agree on the activation
129
+ contract). Falls back to the local frontmatter() parser only if the import
130
+ failed — the defensive degrade path, never a hard dependency.
131
+
132
+ When `ctx_text` is supplied (performance-003: the caller already read
133
+ CONTEXT.md), the decision is made from that text — no re-read of ctx_path."""
134
+ if ctx_text is not None:
135
+ return frontmatter_text(ctx_text).get("arbiter", "").lower() == "enabled"
136
+ if frontmatter_enabled is not None:
137
+ try:
138
+ enabled, _malformed = frontmatter_enabled(ctx_path)
139
+ return enabled
140
+ except Exception: # noqa: BLE001 — degrade to the local parser, never crash
141
+ pass
142
+ return frontmatter(ctx_path).get("arbiter", "").lower() == "enabled"
143
+
144
+
145
+ def arbiter_state(root, count_in_flight=None, read_board=None, frontmatter_enabled=None,
146
+ ctx_text=None, ot_text=None, oq_text=None):
147
+ cad = os.path.join(root, ".codearbiter")
148
+ mkey = _arbiter_mtime_key(cad)
149
+ cached = _ARBITER_CACHE.get(root)
150
+ if cached is not None and cached[0] == mkey:
151
+ return cached[1]
152
+ result = _arbiter_state_uncached(cad, count_in_flight, read_board, frontmatter_enabled,
153
+ ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text)
154
+ _ARBITER_CACHE[root] = (mkey, result)
155
+ return result
156
+
157
+
158
+ def _arbiter_state_uncached(cad, count_in_flight=None, read_board=None, frontmatter_enabled=None,
159
+ ctx_text=None, ot_text=None, oq_text=None):
160
+ ctx_path = os.path.join(cad, "CONTEXT.md")
161
+ if not _arbiter_enabled(ctx_path, frontmatter_enabled, ctx_text=ctx_text):
162
+ return None
163
+ # performance-003: reuse the caller's already-read CONTEXT.md/open-tasks.md/
164
+ # open-questions.md text when supplied, instead of a second disk read. `None`
165
+ # (the default) preserves the exact original read-from-disk behavior.
166
+ fm = frontmatter_text(ctx_text) if ctx_text is not None else frontmatter(ctx_path)
167
+ total_over = count_matches(os.path.join(cad, "overrides.log"), r"^(?!\s*#)(?!\s*$).+")
168
+ # last-checkpoint holds the override COUNT at the last /ca:checkpoint. A value
169
+ # outside [0, total] is not a valid count (e.g. a timestamp from a stale writer)
170
+ # -> fail safe to 0 so overrides are surfaced, never silently hidden.
171
+ try:
172
+ with open(os.path.join(cad, "last-checkpoint"), encoding="utf-8") as f:
173
+ base = int(f.read().strip() or "0")
174
+ except (OSError, ValueError):
175
+ base = 0
176
+ if base < 0 or base > total_over:
177
+ base = 0
178
+ ot_path = os.path.join(cad, "open-tasks.md")
179
+ if count_in_flight is not None:
180
+ board_text = ot_text if ot_text is not None else (read_board(ot_path) or "")
181
+ tasks = count_in_flight(board_text)
182
+ else:
183
+ # Degraded fallback (only if _taskboardlib failed to import): mirror
184
+ # count_in_flight's done-exclusion inline so the segment never silently
185
+ # re-inflates to the pre-schema count. Never crashes the box.
186
+ tasks = (count_matches_text(ot_text, r"^- (?!\[[xX]\])") if ot_text is not None
187
+ else count_matches(ot_path, r"^- (?!\[[xX]\])"))
188
+ q = (count_matches_text(oq_text, r"CONFIRM-[0-9]+") if oq_text is not None
189
+ else count_matches(os.path.join(cad, "open-questions.md"), r"CONFIRM-[0-9]+"))
190
+ return {
191
+ "stage": fm.get("stage", "-"),
192
+ "tasks": tasks,
193
+ "q": q,
194
+ "over": max(0, total_over - base),
195
+ "sprint": os.path.exists(os.path.join(cad, "sprint-active")),
196
+ }
197
+
198
+
199
+ def dev_active(root):
200
+ """True when /dev developer-override mode is on — signalled by a transient marker
201
+ the orchestrator drops on /dev and clears on /arbiter (a local UI flag, not a log).
202
+
203
+ Presence-only by design, unaffected by #271 C-5's session-scoped clearing:
204
+ the marker still means "dev mode is on for SOMEONE" regardless of which
205
+ session owns it. Session-scoping only changes WHEN SessionStart is willing
206
+ to clear a live marker (never a different, possibly still-live session's
207
+ own marker) — it does not change what "present" means to this reader."""
208
+ return os.path.exists(os.path.join(root, ".codearbiter", ".markers", "dev-active"))
@@ -0,0 +1,76 @@
1
+ """pr-babysitter config resolution (Feature 2).
2
+
3
+ A pure config-resolution unit for the pr-babysitter command. This module holds
4
+ NO command behavior — only the env-driven resolver that decides whether the
5
+ babysitter is enabled and how it reacts to a red gate. The command behavior is
6
+ authored as prose in a later task.
7
+
8
+ Environment variables
9
+ ---------------------
10
+ CODEARBITER_BABYSIT
11
+ Master switch. Accepted "on" spellings (case-insensitive): ``on``, ``true``,
12
+ ``1``. Anything else — including absent, empty, or an unknown value —
13
+ resolves to OFF. Default: OFF (PB-8). Even when set on, the babysitter is
14
+ two-layer gated on arbiter dormancy: it stays OFF unless ``arbiter_active``
15
+ reports the repo opted in (PB-10). It is NEVER auto-enabled.
16
+
17
+ CODEARBITER_BABYSIT_ONRED
18
+ What to do when the gate goes red. Normalized lowercase; one of ``propose``
19
+ or ``branch``. Any unknown, empty, or absent value resolves to ``propose``.
20
+ Default: ``propose`` (PB-5).
21
+ """
22
+
23
+ _ON_VALUES = ("on", "true", "1")
24
+ _ONRED_VALUES = ("propose", "branch")
25
+ _ONRED_DEFAULT = "propose"
26
+
27
+
28
+ def babysit_config(env, root, arbiter_active=None):
29
+ """Resolve the pr-babysitter config from ``env`` (a dict) for ``root``.
30
+
31
+ ``env`` and ``root`` are explicit parameters — this resolver never reads or
32
+ mutates ``os.environ``. ``arbiter_active`` is injected (defaulting to
33
+ ``_hooklib.arbiter_active``) so tests can pass a stub for the dormancy gate.
34
+
35
+ Returns a dict with at least ``enabled`` (bool) and ``on_red`` (str).
36
+ """
37
+ if arbiter_active is None:
38
+ import _hooklib
39
+ arbiter_active = _hooklib.arbiter_active
40
+
41
+ raw = (env.get("CODEARBITER_BABYSIT", "off") or "off").lower()
42
+ switched_on = raw in _ON_VALUES
43
+ # PB-10: two-layer gate — the env switch never overrides arbiter dormancy.
44
+ enabled = switched_on and bool(arbiter_active(root))
45
+
46
+ on_red = (env.get("CODEARBITER_BABYSIT_ONRED", "") or "").lower()
47
+ if on_red not in _ONRED_VALUES:
48
+ on_red = _ONRED_DEFAULT # PB-5
49
+
50
+ return {"enabled": enabled, "on_red": on_red}
51
+
52
+
53
+ def main(argv=None):
54
+ """CLI shim: resolve the babysitter config against the live environment and
55
+ print it as one JSON line, so /ca:pr and /ca:watch invoke this single
56
+ resolver instead of re-implementing the flag check in prose (no drift from
57
+ the accepted on|true|1 spellings or the PB-10 dormancy gate). Fail-safe:
58
+ any error degrades to the OFF default and still exits 0 — a broken resolver
59
+ must never become a reason to auto-attach a watcher.
60
+
61
+ Import-only module (no `__main__` entry point, per the thin-entry-hook
62
+ convention): the thin entry hook `babysit.py` imports and calls this
63
+ function; this underscore module is never run as a script directly."""
64
+ import argparse
65
+ import json
66
+ import os
67
+
68
+ parser = argparse.ArgumentParser(add_help=True)
69
+ parser.add_argument("--root", default=os.getcwd())
70
+ args = parser.parse_args(argv)
71
+ try:
72
+ cfg = babysit_config(os.environ, args.root)
73
+ except Exception: # noqa: BLE001
74
+ cfg = {"enabled": False, "on_red": _ONRED_DEFAULT}
75
+ print(json.dumps(cfg))
76
+ return 0