@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,709 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — metrics helper foundation (T-01 + T-02, /ca:metrics).
3
+ #
4
+ # Builds the window-tiling layer for governance-log trend computation. Governance
5
+ # history is tiled into commit-count windows of N=20 commits (default, parameterized).
6
+ # Each log entry will later be mapped into a window by its ISO-8601 timestamp.
7
+ #
8
+ # Design principles (mirroring _previewlib.py / _prunelib.py):
9
+ # - Stdlib only; no third-party imports ever — runs on stock Python.
10
+ # - Zero side effects at import time: no git calls, no file I/O.
11
+ # - Pure functions are fully testable with synthetic data (no real git needed).
12
+ # - The thin git wrapper (commit_timeline) is the ONLY function that shells out.
13
+ #
14
+ # Public API (T-01 scope — window tiling):
15
+ # tile_windows(timestamps, window_size=20) -> list[WindowBand]
16
+ # map_to_window(timestamp, windows) -> int (window index or BEFORE_HISTORY)
17
+ # commit_timeline(root) -> list[datetime] (git wrapper, not unit-tested)
18
+ # BEFORE_HISTORY: int = -1 (sentinel for pre-history timestamps)
19
+ #
20
+ # Public API (T-02 scope — override-rate metric):
21
+ # override_rate(lines_or_text, windows) -> dict with keys: current, prior, arrow
22
+ # read_override_log(path) -> list[str] (thin file reader, not unit-tested)
23
+ #
24
+ # Public API (T-03 scope — small-lane-rate metric):
25
+ # small_lane_rate(lines_or_text, windows) -> dict with keys: current, prior, arrow
26
+ # read_triage_log(path) -> list[str] (thin file reader, not unit-tested)
27
+ #
28
+ # Public API (T-04 scope — sprint low-confidence ratio):
29
+ # sprint_low_confidence_ratio(lines_or_text, windows) -> dict: current, prior, arrow
30
+ # read_sprint_log(path) -> str (thin file reader, not unit-tested)
31
+ #
32
+ # [NEEDS-TRIAGE] Later tasks will add the public compute() API, the /ca:metrics
33
+ # command file, and command registration in plugin.json — none of that is
34
+ # implemented here.
35
+
36
+ import subprocess
37
+
38
+ from _gitexec import git_executable
39
+ from collections import namedtuple
40
+ from datetime import datetime, timezone
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Constants
44
+ # ---------------------------------------------------------------------------
45
+
46
+ # Default commit-count window size (parameterized on every call).
47
+ DEFAULT_WINDOW_SIZE = 20
48
+
49
+ # Sentinel returned by map_to_window when the timestamp precedes the entire
50
+ # known history. Negative so it can never be confused with a valid window index.
51
+ BEFORE_HISTORY = -1
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Data types
55
+ # ---------------------------------------------------------------------------
56
+
57
+ # One window band as produced by tile_windows.
58
+ #
59
+ # Fields:
60
+ # index — 0-based integer; 0 = oldest, highest = most recent ("current").
61
+ # start_dt — timezone-aware UTC datetime of the first (oldest) commit in the band.
62
+ # end_dt — timezone-aware UTC datetime of the last (newest) commit in the band.
63
+ #
64
+ # Window semantics: consecutive commit-count groups of `window_size` commits.
65
+ # When M commits are not evenly divisible by N, the OLDEST group is partial
66
+ # (smaller than N). All subsequent groups are full-size. This keeps the most
67
+ # recent window always "full" — the current window is never artificially short.
68
+ WindowBand = namedtuple("WindowBand", ["index", "start_dt", "end_dt"])
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # ISO-8601 parsing
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def _parse_ts(ts):
75
+ """Accept either a timezone-aware datetime or an ISO-8601 string with
76
+ trailing Z (e.g. '2026-06-14T02:06:45Z') and always return a
77
+ timezone-aware UTC datetime.
78
+
79
+ Raises ValueError on unrecognised string format.
80
+ Raises TypeError on values that are neither str nor datetime.
81
+ """
82
+ if isinstance(ts, datetime):
83
+ if ts.tzinfo is None:
84
+ # Treat naive datetimes as UTC for robustness; caller should pass
85
+ # aware datetimes. [NEEDS-TRIAGE] — consider rejecting naive
86
+ # datetimes once the call-sites are fully settled.
87
+ return ts.replace(tzinfo=timezone.utc)
88
+ return ts
89
+ if isinstance(ts, str):
90
+ # The governance logs emit '2026-06-14T02:06:45Z'. Python 3.11+
91
+ # fromisoformat handles the Z suffix, but earlier 3.x versions do not,
92
+ # so we normalise the Z to +00:00 for broad compatibility.
93
+ normalised = ts.rstrip()
94
+ if normalised.endswith("Z"):
95
+ normalised = normalised[:-1] + "+00:00"
96
+ return datetime.fromisoformat(normalised)
97
+ raise TypeError(f"timestamp must be a datetime or ISO-8601 str, got {type(ts)!r}")
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Pure function: tile_windows
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def tile_windows(timestamps, window_size=DEFAULT_WINDOW_SIZE):
104
+ """Tile a commit timeline into consecutive commit-count windows.
105
+
106
+ Args:
107
+ timestamps: ordered (oldest → newest) list of timezone-aware datetimes
108
+ OR ISO-8601 strings ending in Z. Empty list is accepted.
109
+ window_size: number of commits per full window (default 20, must be >= 1).
110
+
111
+ Returns:
112
+ list of WindowBand in ascending index order (index 0 = oldest).
113
+ Empty list when `timestamps` is empty.
114
+
115
+ Partial-window rule:
116
+ When len(timestamps) % window_size != 0, the OLDEST window is the
117
+ partial (smaller) group. All windows from index 1 onward are full-size.
118
+ Example: 45 commits, N=20 → window 0 has 5 commits (partial/oldest),
119
+ window 1 has 20, window 2 has 20.
120
+ """
121
+ if window_size < 1:
122
+ raise ValueError(f"window_size must be >= 1, got {window_size!r}")
123
+
124
+ parsed = [_parse_ts(t) for t in timestamps]
125
+ m = len(parsed)
126
+ if m == 0:
127
+ return []
128
+
129
+ # Compute the size of the (possibly partial) oldest group.
130
+ remainder = m % window_size
131
+ oldest_size = remainder if remainder != 0 else window_size
132
+
133
+ # Build slices: [oldest_group] + [full groups...]
134
+ # The oldest group starts at index 0 in the parsed list.
135
+ groups = []
136
+ pos = 0
137
+ # First group (partial when remainder != 0, full when m % window_size == 0)
138
+ groups.append(parsed[pos: pos + oldest_size])
139
+ pos += oldest_size
140
+ # Remaining full-size groups
141
+ while pos < m:
142
+ groups.append(parsed[pos: pos + window_size])
143
+ pos += window_size
144
+
145
+ return [
146
+ WindowBand(index=i, start_dt=grp[0], end_dt=grp[-1])
147
+ for i, grp in enumerate(groups)
148
+ ]
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Pure function: map_to_window
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def map_to_window(timestamp, windows):
155
+ """Map a single timestamp to a window index.
156
+
157
+ Boundary semantics (half-open intervals, higher-index wins at boundaries):
158
+ A timestamp T maps to the highest-index window whose start_dt <= T.
159
+ Concretely: if T >= windows[i].start_dt for multiple i, the largest
160
+ such i is returned. This ensures a timestamp exactly equal to a
161
+ window-boundary commit date maps to the more-recent (higher-index) window.
162
+
163
+ Args:
164
+ timestamp: timezone-aware datetime OR ISO-8601 Z string.
165
+ windows: list of WindowBand from tile_windows (may be empty).
166
+
167
+ Returns:
168
+ int — the matching window index (0-based), or BEFORE_HISTORY (-1)
169
+ when `timestamp` precedes all windows or `windows` is empty.
170
+
171
+ Never raises on valid input.
172
+ """
173
+ if not windows:
174
+ return BEFORE_HISTORY
175
+
176
+ ts = _parse_ts(timestamp)
177
+
178
+ # Walk from the most-recent window downward; return the first whose
179
+ # start_dt <= ts. This implements "highest-index wins at a boundary".
180
+ for band in reversed(windows):
181
+ if ts >= band.start_dt:
182
+ return band.index
183
+
184
+ # ts is older than every window's start_dt
185
+ return BEFORE_HISTORY
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Git wrapper: commit_timeline (thin shell; NOT unit-tested with real git)
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def commit_timeline(root):
192
+ """Return a list of timezone-aware UTC datetimes for every commit in the
193
+ repository at `root`, ordered oldest → newest.
194
+
195
+ Shells out to `git log --format=%cI --reverse` (ISO-8601 strict format,
196
+ with timezone offset). Returns an empty list if git is unavailable, if
197
+ `root` is not a git repository, or if any other error occurs — callers
198
+ must not assume a non-empty return.
199
+
200
+ This wrapper is intentionally thin: it exists only to bridge the git
201
+ boundary and feed the pure tile_windows / map_to_window functions with
202
+ real commit data. It is NOT unit-tested with real git; the pure functions
203
+ carry all the testable contract.
204
+
205
+ Args:
206
+ root: absolute path to a git repository root (or any worktree path).
207
+
208
+ Returns:
209
+ list[datetime] — timezone-aware UTC datetimes, oldest first.
210
+ Empty list on any error.
211
+ """
212
+ try:
213
+ result = subprocess.run(
214
+ [git_executable(), "log", "--format=%cI", "--reverse"],
215
+ cwd=root,
216
+ capture_output=True,
217
+ text=True,
218
+ encoding="utf-8",
219
+ errors="replace",
220
+ timeout=30,
221
+ )
222
+ except Exception: # noqa: BLE001 — missing git binary, timeout, etc.
223
+ return []
224
+
225
+ if result.returncode != 0:
226
+ return []
227
+
228
+ datetimes = []
229
+ for line in result.stdout.splitlines():
230
+ line = line.strip()
231
+ if not line:
232
+ continue
233
+ try:
234
+ datetimes.append(_parse_ts(line))
235
+ except (ValueError, TypeError):
236
+ # Malformed git output line — skip rather than crash.
237
+ continue
238
+ return datetimes
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Pure function: override_rate (T-02)
242
+ # ---------------------------------------------------------------------------
243
+
244
+ # Regex to extract the leading timestamp from an overrides.log data line.
245
+ # Format: [<ISO-8601-Z>] | BY: ...
246
+ # Only the timestamp portion inside the first [...] is captured.
247
+ import re as _re
248
+ _OVERRIDE_TS_RE = _re.compile(r"^\[([^\]]+)\]")
249
+
250
+
251
+ def override_rate(lines_or_text, windows):
252
+ """Compute the override rate for the current and prior windows.
253
+
254
+ Parses overrides.log content, maps each entry to a window, and returns
255
+ counts for the most-recent (current) window and the preceding (prior) window,
256
+ together with a trend arrow.
257
+
258
+ Args:
259
+ lines_or_text: either a list of strings (one per log line) or a single
260
+ multi-line string. Both forms are accepted so that callers
261
+ can pass raw file text or pre-split lines with equal ease.
262
+ windows: list of WindowBand from tile_windows (may be empty).
263
+
264
+ Returns:
265
+ dict with keys:
266
+ "current" (int) — count of override entries mapped to the current
267
+ window (highest index in `windows`).
268
+ "prior" (int) — count mapped to the prior window (current − 1),
269
+ or 0 when fewer than 2 windows exist.
270
+ "arrow" (str) — "↑" if current > prior, "↓" if current < prior,
271
+ "→" if equal.
272
+
273
+ Filtering rules:
274
+ - Lines that start with "#" (after stripping leading whitespace) are
275
+ comment lines and are excluded.
276
+ - Blank and whitespace-only lines are excluded.
277
+ - Entries whose parsed timestamp maps to BEFORE_HISTORY are excluded
278
+ from all window counts.
279
+ """
280
+ # Normalise input: accept either a str block or an iterable of strings.
281
+ if isinstance(lines_or_text, str):
282
+ raw_lines = lines_or_text.splitlines()
283
+ else:
284
+ raw_lines = list(lines_or_text)
285
+
286
+ # Determine current and prior window indices.
287
+ if windows:
288
+ current_idx = windows[-1].index
289
+ prior_idx = current_idx - 1 if len(windows) >= 2 else None
290
+ else:
291
+ current_idx = None
292
+ prior_idx = None
293
+
294
+ current_count = 0
295
+ prior_count = 0
296
+
297
+ for raw in raw_lines:
298
+ line = raw.strip()
299
+ # Skip blank lines and comment lines.
300
+ if not line or line.startswith("#"):
301
+ continue
302
+
303
+ # Extract the leading timestamp token "[<ts>]".
304
+ m = _OVERRIDE_TS_RE.match(line)
305
+ if not m:
306
+ # No recognisable timestamp; skip rather than crash.
307
+ continue
308
+
309
+ ts_str = m.group(1).strip()
310
+ try:
311
+ window_idx = map_to_window(ts_str, windows)
312
+ except (ValueError, TypeError):
313
+ # Malformed timestamp in log line; skip.
314
+ continue
315
+
316
+ if window_idx == BEFORE_HISTORY:
317
+ continue
318
+ if window_idx == current_idx:
319
+ current_count += 1
320
+ elif prior_idx is not None and window_idx == prior_idx:
321
+ prior_count += 1
322
+ # Entries in older windows (index < prior_idx) are intentionally ignored;
323
+ # they contribute to neither current nor prior.
324
+
325
+ if current_count > prior_count:
326
+ arrow = "↑" # ↑
327
+ elif current_count < prior_count:
328
+ arrow = "↓" # ↓
329
+ else:
330
+ arrow = "→" # →
331
+
332
+ return {"current": current_count, "prior": prior_count, "arrow": arrow}
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Pure function: small_lane_rate (T-03)
337
+ # ---------------------------------------------------------------------------
338
+
339
+ # Match "LANE: small" as a field value — the pipe-delimited field must read
340
+ # exactly "LANE: small" (not "LANE: smallX" or "LANE: full").
341
+ # We look for the literal token after optional surrounding whitespace.
342
+ _SMALL_LANE_RE = _re.compile(r"\|\s*LANE:\s*small\s*(?:\||$)")
343
+
344
+
345
+ def small_lane_rate(lines_or_text, windows):
346
+ """Compute the small-lane rate for the current and prior windows.
347
+
348
+ Parses triage.log content, counts only entries containing 'LANE: small',
349
+ maps each to a window, and returns counts for the most-recent (current)
350
+ window and the preceding (prior) window, together with a trend arrow.
351
+
352
+ triage.log line format (non-comment):
353
+ [<ISO-8601 Z timestamp>] | BY: <actor> | LANE: small | SCOPE: ... | BASIS: ...
354
+
355
+ Args:
356
+ lines_or_text: either a list of strings (one per log line) or a single
357
+ multi-line string.
358
+ windows: list of WindowBand from tile_windows (may be empty).
359
+
360
+ Returns:
361
+ dict with keys:
362
+ "current" (int) — count of LANE: small entries mapped to the
363
+ current window (highest index in `windows`).
364
+ "prior" (int) — count mapped to the prior window (current - 1),
365
+ or 0 when fewer than 2 windows exist.
366
+ "arrow" (str) — "↑" if current > prior, "↓" if current < prior,
367
+ "→" if equal.
368
+
369
+ Filtering rules:
370
+ - Lines that start with "#" (after stripping leading whitespace) are
371
+ comment lines and are excluded.
372
+ - Blank and whitespace-only lines are excluded.
373
+ - Lines that do NOT contain 'LANE: small' are excluded (e.g. LANE: full).
374
+ - Entries whose parsed timestamp maps to BEFORE_HISTORY are excluded
375
+ from all window counts.
376
+ """
377
+ # Normalise input: accept either a str block or an iterable of strings.
378
+ if isinstance(lines_or_text, str):
379
+ raw_lines = lines_or_text.splitlines()
380
+ else:
381
+ raw_lines = list(lines_or_text)
382
+
383
+ # Determine current and prior window indices.
384
+ if windows:
385
+ current_idx = windows[-1].index
386
+ prior_idx = current_idx - 1 if len(windows) >= 2 else None
387
+ else:
388
+ current_idx = None
389
+ prior_idx = None
390
+
391
+ current_count = 0
392
+ prior_count = 0
393
+
394
+ for raw in raw_lines:
395
+ line = raw.strip()
396
+ # Skip blank lines and comment lines.
397
+ if not line or line.startswith("#"):
398
+ continue
399
+
400
+ # Only count lines that contain the exact field 'LANE: small'.
401
+ if not _SMALL_LANE_RE.search(line):
402
+ continue
403
+
404
+ # Extract the leading timestamp token "[<ts>]".
405
+ m = _OVERRIDE_TS_RE.match(line)
406
+ if not m:
407
+ # No recognisable timestamp; skip rather than crash.
408
+ continue
409
+
410
+ ts_str = m.group(1).strip()
411
+ try:
412
+ window_idx = map_to_window(ts_str, windows)
413
+ except (ValueError, TypeError):
414
+ # Malformed timestamp in log line; skip.
415
+ continue
416
+
417
+ if window_idx == BEFORE_HISTORY:
418
+ continue
419
+ if window_idx == current_idx:
420
+ current_count += 1
421
+ elif prior_idx is not None and window_idx == prior_idx:
422
+ prior_count += 1
423
+ # Entries in older windows (index < prior_idx) are intentionally
424
+ # ignored; they contribute to neither current nor prior.
425
+
426
+ if current_count > prior_count:
427
+ arrow = "↑" # ↑
428
+ elif current_count < prior_count:
429
+ arrow = "↓" # ↓
430
+ else:
431
+ arrow = "→" # →
432
+
433
+ return {"current": current_count, "prior": prior_count, "arrow": arrow}
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Thin file reader: read_triage_log (T-03, not unit-tested)
438
+ # ---------------------------------------------------------------------------
439
+
440
+ def read_triage_log(path):
441
+ """Read triage.log from `path` and return its lines as a list of strings.
442
+
443
+ This is the only function in T-03 that performs file I/O. It is kept
444
+ intentionally thin — callers pass the result to the pure small_lane_rate()
445
+ function for all business logic.
446
+
447
+ Returns an empty list if the file does not exist or cannot be read.
448
+ """
449
+ try:
450
+ with open(path, encoding="utf-8", errors="replace") as fh:
451
+ return fh.readlines()
452
+ except OSError:
453
+ return []
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Thin file reader: read_override_log (T-02, not unit-tested)
458
+ # ---------------------------------------------------------------------------
459
+
460
+ def read_override_log(path):
461
+ """Read overrides.log from `path` and return its lines as a list of strings.
462
+
463
+ This is the only function in T-02 that performs file I/O. It is kept
464
+ intentionally thin — callers pass the result to the pure override_rate()
465
+ function for all business logic.
466
+
467
+ Returns an empty list if the file does not exist or cannot be read.
468
+ """
469
+ try:
470
+ with open(path, encoding="utf-8", errors="replace") as fh:
471
+ return fh.readlines()
472
+ except OSError:
473
+ return []
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # Pure function: sprint_low_confidence_ratio (T-04)
478
+ # ---------------------------------------------------------------------------
479
+
480
+ # Match a sprint-section header and capture the YYYY-MM-DD date.
481
+ # Handles both "# Sprint — name · YYYY-MM-DD" and "# Sprint: name · YYYY-MM-DD".
482
+ # The date must follow a " · " (space-middot-space) separator anywhere in the
483
+ # header line. Other "# Sprint …" headers without this pattern produce no match
484
+ # and their markers are excluded (documented below).
485
+ _SPRINT_HEADER_RE = _re.compile(r"^#\s+Sprint[^\n]*·\s*(\d{4}-\d{2}-\d{2})")
486
+
487
+ # Match exactly the bold tokens **high** and **low** (case-sensitive).
488
+ # The pattern anchors on the literal ** delimiters, so **strong**, **moderate**,
489
+ # **weak**, **medium**, **D-01**, etc. cannot match.
490
+ _CONFIDENCE_MARKER_RE = _re.compile(r"\*\*(high|low)\*\*")
491
+
492
+
493
+ def sprint_low_confidence_ratio(lines_or_text, windows):
494
+ """Compute the sprint low-confidence ratio for the current and prior windows.
495
+
496
+ Scans sprint-log.md content for SMARTS confidence markers. Only the literal
497
+ bold tokens ``**high**`` and ``**low**`` are counted — confidence markers only.
498
+ All other bold tokens, including the SMARTS strength words (``**strong**``,
499
+ ``**moderate**``, ``**weak**``) and ``**medium**``, are explicitly excluded.
500
+
501
+ Each marker is attributed to the sprint section it appears in. A section
502
+ begins at a header matching::
503
+
504
+ # Sprint[...] · YYYY-MM-DD
505
+
506
+ and extends until the next such header (or end-of-text). The header date is
507
+ parsed as midnight UTC and mapped to a window via ``map_to_window``. Markers
508
+ in sections whose header contains no parseable date are excluded entirely
509
+ (never mapped, never counted).
510
+
511
+ Args:
512
+ lines_or_text: either a list of strings (one per line) or a single
513
+ multi-line string. Both forms are accepted.
514
+ windows: list of WindowBand from tile_windows (may be empty).
515
+
516
+ Returns:
517
+ dict with keys:
518
+ "current" — ``round(L/(L+H), 2)`` for the current (highest-index)
519
+ window, or the sentinel string ``"n/a"`` when ``L+H == 0``
520
+ in that window.
521
+ "prior" — same for the prior window (current − 1); ``"n/a"`` when
522
+ no prior window exists or its ``L+H == 0``.
523
+ "arrow" — ``"↑"`` if current > prior, ``"↓"`` if current < prior,
524
+ ``"→"`` if equal. When EITHER ``current`` or ``prior``
525
+ is the ``"n/a"`` sentinel, ``arrow`` is always ``"→"``.
526
+
527
+ Exclusion rules (documented):
528
+ - Sections with no parseable date in their header: markers excluded.
529
+ - ``**medium**`` tokens: excluded (neither high nor low).
530
+ - ``**strong**``, ``**moderate**``, ``**weak**`` and all other bold
531
+ tokens: excluded (different SMARTS axis).
532
+ - Markers mapping to BEFORE_HISTORY: excluded (older than all windows).
533
+ """
534
+ # Normalise input.
535
+ if isinstance(lines_or_text, str):
536
+ raw_lines = lines_or_text.splitlines()
537
+ else:
538
+ raw_lines = list(lines_or_text)
539
+
540
+ # Determine the current and prior window indices.
541
+ if windows:
542
+ current_idx = windows[-1].index
543
+ prior_idx = current_idx - 1 if len(windows) >= 2 else None
544
+ else:
545
+ current_idx = None
546
+ prior_idx = None
547
+
548
+ # Accumulate high/low counts per window index.
549
+ # Keys: window index (int). Values: [low_count, high_count].
550
+ counts = {} # {window_idx: [low, high]}
551
+
552
+ # Walk lines, tracking which window the current sprint section belongs to.
553
+ # section_window_idx: the window index for the active sprint section,
554
+ # or None when no section is active (or the last header had no parseable date).
555
+ section_window_idx = None
556
+
557
+ for raw in raw_lines:
558
+ line = raw.rstrip("\n").rstrip("\r")
559
+
560
+ # Check whether this line starts a new sprint-section header.
561
+ m_header = _SPRINT_HEADER_RE.match(line)
562
+ if m_header:
563
+ date_str = m_header.group(1)
564
+ try:
565
+ # Treat the sprint date as midnight UTC.
566
+ dt = datetime.strptime(date_str, "%Y-%m-%d").replace(
567
+ tzinfo=timezone.utc
568
+ )
569
+ window_idx = map_to_window(dt, windows)
570
+ except (ValueError, TypeError):
571
+ # Malformed date — exclude this section's markers.
572
+ section_window_idx = None
573
+ continue
574
+
575
+ if window_idx == BEFORE_HISTORY:
576
+ # Predates all windows; exclude this section.
577
+ section_window_idx = None
578
+ else:
579
+ section_window_idx = window_idx
580
+ continue
581
+
582
+ # Not a sprint header: scan for confidence markers if inside a dated section.
583
+ if section_window_idx is None:
584
+ continue
585
+
586
+ for m_marker in _CONFIDENCE_MARKER_RE.finditer(line):
587
+ token = m_marker.group(1) # "high" or "low"
588
+ bucket = counts.setdefault(section_window_idx, [0, 0])
589
+ if token == "low":
590
+ bucket[0] += 1
591
+ else: # "high"
592
+ bucket[1] += 1
593
+
594
+ # Compute ratio for a given window index.
595
+ def _ratio(idx):
596
+ if idx is None or idx not in counts:
597
+ return "n/a"
598
+ low_c, high_c = counts[idx]
599
+ total = low_c + high_c
600
+ if total == 0:
601
+ return "n/a"
602
+ return round(low_c / total, 2)
603
+
604
+ current_val = _ratio(current_idx)
605
+ prior_val = _ratio(prior_idx)
606
+
607
+ # Arrow: "→" when either value is the sentinel string (no numeric comparison).
608
+ if current_val == "n/a" or prior_val == "n/a":
609
+ arrow = "→" # →
610
+ elif current_val > prior_val:
611
+ arrow = "↑" # ↑
612
+ elif current_val < prior_val:
613
+ arrow = "↓" # ↓
614
+ else:
615
+ arrow = "→" # →
616
+
617
+ return {"current": current_val, "prior": prior_val, "arrow": arrow}
618
+
619
+
620
+ # ---------------------------------------------------------------------------
621
+ # Thin file reader: read_sprint_log (T-04, not unit-tested)
622
+ # ---------------------------------------------------------------------------
623
+
624
+ def read_sprint_log(path):
625
+ """Read sprint-log.md from `path` and return its text as a single string.
626
+
627
+ This is the only function in T-04 that performs file I/O. It is kept
628
+ intentionally thin — callers pass the result to the pure
629
+ sprint_low_confidence_ratio() function for all business logic.
630
+
631
+ Returns an empty string if the file does not exist or cannot be read.
632
+ """
633
+ try:
634
+ with open(path, encoding="utf-8", errors="replace") as fh:
635
+ return fh.read()
636
+ except OSError:
637
+ return ""
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # Public entry point: compute() (T-06 — AC-07)
642
+ # ---------------------------------------------------------------------------
643
+
644
+ import os as _os
645
+
646
+
647
+ def compute(project_dir, window_size=DEFAULT_WINDOW_SIZE, *, _timeline=None):
648
+ """Compute all three governance metrics for `project_dir` and return them.
649
+
650
+ This is the single public entry point the ``/ca:metrics`` command calls.
651
+ It ties the window-tiling, log-reading, and metric-computation layers
652
+ together and returns a fixed-surface dict.
653
+
654
+ Args:
655
+ project_dir: absolute path to the project root (the directory that
656
+ contains ``.codearbiter/``). The git repository at this
657
+ path (or any of its parents) is used for the commit
658
+ timeline when ``_timeline`` is not supplied.
659
+ window_size: number of commits per window (default 20).
660
+ _timeline: [testability seam] optional list[datetime] injected in
661
+ place of ``commit_timeline(project_dir)``. When None
662
+ (the default), the real git wrapper is called. Tests
663
+ pass a synthetic timeline to keep the suite hermetic.
664
+ This parameter is keyword-only and intentionally not part
665
+ of the public /ca:metrics API surface — callers should
666
+ omit it.
667
+
668
+ Returns:
669
+ dict with EXACTLY these three keys and no others:
670
+ "override_rate" — result of override_rate(...)
671
+ "small_lane_rate" — result of small_lane_rate(...)
672
+ "sprint_low_conf_ratio" — result of sprint_low_confidence_ratio(...)
673
+
674
+ Each sub-value is a dict with keys "current", "prior", "arrow".
675
+
676
+ Degradation contract:
677
+ The function is READ-ONLY. It never writes, creates, or modifies any
678
+ file. On any missing resource (logs absent, .codearbiter/ not found,
679
+ git unavailable, etc.) the affected sub-metric returns its sentinel
680
+ (counts 0 / ratio "n/a") and the function never raises.
681
+ """
682
+ # -- Step 1: build the commit timeline and tile it into windows.
683
+ # The injected _timeline seam replaces the git call when testing.
684
+ try:
685
+ if _timeline is not None:
686
+ timeline = list(_timeline)
687
+ else:
688
+ timeline = commit_timeline(project_dir)
689
+ windows = tile_windows(timeline, window_size=window_size)
690
+ except Exception: # noqa: BLE001 — never crash regardless of git state
691
+ windows = []
692
+
693
+ # -- Step 2: resolve log paths under <project_dir>/.codearbiter/
694
+ ca_dir = _os.path.join(project_dir, ".codearbiter")
695
+ override_path = _os.path.join(ca_dir, "overrides.log")
696
+ triage_path = _os.path.join(ca_dir, "triage.log")
697
+ sprint_path = _os.path.join(ca_dir, "sprint-log.md")
698
+
699
+ # -- Step 3: read each log (thin readers degrade to empty on missing files).
700
+ override_lines = read_override_log(override_path)
701
+ triage_lines = read_triage_log(triage_path)
702
+ sprint_text = read_sprint_log(sprint_path)
703
+
704
+ # -- Step 4: compute the three metrics and assemble the fixed output dict.
705
+ return {
706
+ "override_rate": override_rate(override_lines, windows),
707
+ "small_lane_rate": small_lane_rate(triage_lines, windows),
708
+ "sprint_low_conf_ratio": sprint_low_confidence_ratio(sprint_text, windows),
709
+ }