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