loopagent-gate 0.4.2__py3-none-any.whl
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.
- loopagent_gate-0.4.2.dist-info/METADATA +374 -0
- loopagent_gate-0.4.2.dist-info/RECORD +40 -0
- loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
- loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
- loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
- loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
- loopgate/__init__.py +7 -0
- loopgate/__main__.py +8 -0
- loopgate/cli.py +147 -0
- loopgate/gates/anchor_witness.py +249 -0
- loopgate/gates/assert_dims.py +88 -0
- loopgate/gates/bootstrap_state.py +111 -0
- loopgate/gates/change_health.py +232 -0
- loopgate/gates/check_loop.py +1267 -0
- loopgate/gates/check_product.py +463 -0
- loopgate/gates/check_visual.py +224 -0
- loopgate/gates/doctor.py +130 -0
- loopgate/gates/estimate_task.py +137 -0
- loopgate/gates/export_public.py +289 -0
- loopgate/gates/guard_card.py +519 -0
- loopgate/gates/loop_watch.py +118 -0
- loopgate/gates/regressions.py +463 -0
- loopgate/gates/render_status_page.py +151 -0
- loopgate/gates/render_task_ledger.py +312 -0
- loopgate/gates/review_pr.py +370 -0
- loopgate/gates/sync_issues_to_kanban.py +593 -0
- loopgate/gates/tree_parity.py +307 -0
- loopgate/gates/worker_brief.py +396 -0
- loopgate/init.py +128 -0
- loopgate/paths.py +43 -0
- loopgate/py.typed +0 -0
- loopgate/templates/__init__.py +1 -0
- loopgate/templates/configs/anchor_witness.example.json +14 -0
- loopgate/templates/configs/fixtures/issues_sample.json +75 -0
- loopgate/templates/configs/fixtures/review_approve.json +5 -0
- loopgate/templates/configs/fixtures/review_changes.json +5 -0
- loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
- loopgate/templates/configs/repo_profiles.example.json +63 -0
- loopgate/templates/configs/tree_parity.example.json +16 -0
- loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The third verdict: not "did it break" but "did it get worse".
|
|
3
|
+
|
|
4
|
+
`check_loop.py` answers whether the loop is disciplined; `check_product.py` whether a user can do their job.
|
|
5
|
+
Both are boolean and both look at the present. This one looks at the difference: the same measurements taken
|
|
6
|
+
before and after a change, and a refusal when one of them got worse while everything stayed green.
|
|
7
|
+
|
|
8
|
+
What it is not: a code-quality score. Every number here is countable from the tree, deterministic, and tied
|
|
9
|
+
to a policy in `configs/change_health.json` that says which direction is bad. Nothing here is a judgement
|
|
10
|
+
about taste, and a metric nobody can defend does not belong in the file.
|
|
11
|
+
|
|
12
|
+
python3 -m loopgate.gates.change_health # compare against the committed baseline
|
|
13
|
+
python3 -m loopgate.gates.change_health --base main # compare the working tree against a git ref
|
|
14
|
+
python3 -m loopgate.gates.change_health --write-baseline # re-record the baseline (a reviewable diff)
|
|
15
|
+
python3 -m loopgate.gates.change_health --metrics # print today's numbers, judge nothing
|
|
16
|
+
|
|
17
|
+
Verdict: CHANGE_HEALTH_PASS_0001 (exit 0) when nothing regressed, CHANGE_HEALTH_STOP_0001 (exit 1) naming
|
|
18
|
+
every metric that got worse and by how much.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from loopgate.paths import project_root, gate_file
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import datetime
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
|
|
34
|
+
ROOT = project_root()
|
|
35
|
+
PASS_MARKER = "CHANGE_HEALTH_PASS_0001"
|
|
36
|
+
STOP_MARKER = "CHANGE_HEALTH_STOP_0001"
|
|
37
|
+
POLICY_REL = "configs/change_health.json"
|
|
38
|
+
BASELINE_REL = "configs/change_health_baseline.json"
|
|
39
|
+
SCHEMA_VERSION = 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read(path: str) -> str:
|
|
43
|
+
try:
|
|
44
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
45
|
+
return fh.read()
|
|
46
|
+
except OSError:
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def python_files(root: str) -> list[str]:
|
|
51
|
+
out: list[str] = []
|
|
52
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
53
|
+
dirnames[:] = [d for d in dirnames if d not in {".git", "dist", "build", "__pycache__", ".venv",
|
|
54
|
+
"artifacts", "handoff", "course", "node_modules"}
|
|
55
|
+
and not d.endswith(".egg-info")]
|
|
56
|
+
for name in filenames:
|
|
57
|
+
if name.endswith(".py"):
|
|
58
|
+
out.append(os.path.join(dirpath, name))
|
|
59
|
+
return sorted(out)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def collect(root: str) -> dict:
|
|
63
|
+
"""Every metric is a count over files, so two operators on the same commit get the same numbers."""
|
|
64
|
+
files = python_files(root)
|
|
65
|
+
lines = [len(read(p).splitlines()) for p in files]
|
|
66
|
+
# The gates are packaged: count the gate's own dimensions and falsifications where they now live.
|
|
67
|
+
gate = read(gate_file("check_loop"))
|
|
68
|
+
return {
|
|
69
|
+
"gate_dims": len(set(re.findall(r'Dim\(\s*"(D\d\d)"', gate))),
|
|
70
|
+
"regression_cases": len(re.findall(r'^\s*\("', read(gate_file("regressions")) or "", re.M)),
|
|
71
|
+
"python_files": len(files),
|
|
72
|
+
"python_lines": sum(lines),
|
|
73
|
+
"largest_file_lines": max(lines) if lines else 0,
|
|
74
|
+
# Only comment markers count: matching the bare words also matched this line and the label of the
|
|
75
|
+
# falsification below, so the metric was measuring its own vocabulary.
|
|
76
|
+
"todo_markers": sum(len(re.findall(r"#\s*(?:TODO|FIXME|XXX)\b", read(p))) for p in files),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_policy(root: str) -> dict:
|
|
81
|
+
raw = read(os.path.join(root, POLICY_REL))
|
|
82
|
+
if not raw:
|
|
83
|
+
raise SystemExit(f"policy missing: {POLICY_REL}")
|
|
84
|
+
return json.loads(raw)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def judge(base: dict, head: dict, policy: dict) -> list[dict]:
|
|
88
|
+
"""One row per metric: value before, value after, and whether the policy calls that a regression."""
|
|
89
|
+
rows: list[dict] = []
|
|
90
|
+
for name, rule in (policy.get("metrics") or {}).items():
|
|
91
|
+
before, after = base.get(name), head.get(name)
|
|
92
|
+
kind = rule.get("kind")
|
|
93
|
+
tolerance = rule.get("tolerance", 0) or 0
|
|
94
|
+
row = {"metric": name, "base": before, "head": after, "kind": kind, "tolerance": tolerance,
|
|
95
|
+
"changed": before != after, "regressed": False, "why": rule.get("why", "")}
|
|
96
|
+
if not isinstance(before, int) or not isinstance(after, int):
|
|
97
|
+
row["status"] = "unknown"
|
|
98
|
+
rows.append(row)
|
|
99
|
+
continue
|
|
100
|
+
if kind == "must_not_decrease":
|
|
101
|
+
row["regressed"], better = after < before, after > before
|
|
102
|
+
elif kind == "must_not_increase":
|
|
103
|
+
row["regressed"], better = after > before + tolerance, after < before
|
|
104
|
+
elif kind == "max_growth_pct":
|
|
105
|
+
limit = before * (1 + tolerance / 100.0)
|
|
106
|
+
row["regressed"], better = after > limit, after < before
|
|
107
|
+
else:
|
|
108
|
+
row["status"] = f"unknown policy kind {kind!r}"
|
|
109
|
+
rows.append(row)
|
|
110
|
+
continue
|
|
111
|
+
# Direction is per policy: for a size metric an increase inside the tolerance is "grew", not
|
|
112
|
+
# "better" — calling it better would be the comparator flattering the author.
|
|
113
|
+
if row["regressed"]:
|
|
114
|
+
row["status"] = "regressed"
|
|
115
|
+
elif before == after:
|
|
116
|
+
row["status"] = "same"
|
|
117
|
+
elif better:
|
|
118
|
+
row["status"] = "improved"
|
|
119
|
+
else:
|
|
120
|
+
row["status"] = "within tolerance"
|
|
121
|
+
rows.append(row)
|
|
122
|
+
unknown = [m for m in (policy.get("metrics") or {}) if m not in base]
|
|
123
|
+
for name in unknown:
|
|
124
|
+
rows.append({"metric": name, "base": None, "head": head.get(name), "kind": "n/a",
|
|
125
|
+
"status": "unknown", "regressed": False, "changed": True,
|
|
126
|
+
"why": "not present in the baseline: record a new baseline to include it"})
|
|
127
|
+
return rows
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def snapshot_from_git(ref: str) -> tuple[str, tempfile.TemporaryDirectory]:
|
|
131
|
+
"""A scratch checkout of `ref`, so the comparison is between two commits and not two moods."""
|
|
132
|
+
tmp = tempfile.TemporaryDirectory(prefix="change-health-")
|
|
133
|
+
proc = subprocess.run(["git", "-C", ROOT, "worktree", "add", "--detach", tmp.name, ref],
|
|
134
|
+
capture_output=True, text=True)
|
|
135
|
+
if proc.returncode != 0:
|
|
136
|
+
tmp.cleanup()
|
|
137
|
+
raise SystemExit(f"cannot check out {ref}: {proc.stderr.strip()[:200]}")
|
|
138
|
+
return tmp.name, tmp
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def fmt(value) -> str:
|
|
142
|
+
return "—" if value is None else f"{value:,}" if isinstance(value, int) else str(value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv=None) -> int:
|
|
146
|
+
ap = argparse.ArgumentParser(description="Third verdict: compare countable metrics before and after.")
|
|
147
|
+
ap.add_argument("--base", help="git ref to compare against (default: the committed baseline)")
|
|
148
|
+
ap.add_argument("--write-baseline", action="store_true", help="record the current metrics as the baseline")
|
|
149
|
+
ap.add_argument("--metrics", action="store_true", help="print today's metrics and exit")
|
|
150
|
+
ap.add_argument("--json", action="store_true")
|
|
151
|
+
ap.add_argument("--root", default=ROOT)
|
|
152
|
+
args = ap.parse_args(argv)
|
|
153
|
+
|
|
154
|
+
root = os.path.abspath(args.root)
|
|
155
|
+
policy = load_policy(root)
|
|
156
|
+
head = collect(root)
|
|
157
|
+
|
|
158
|
+
if args.metrics:
|
|
159
|
+
print(json.dumps(head, indent=2, sort_keys=True) if args.json else
|
|
160
|
+
"\n".join(f" {k:<22} {fmt(v)}" for k, v in sorted(head.items())))
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
if args.write_baseline:
|
|
164
|
+
payload = {"schema": SCHEMA_VERSION,
|
|
165
|
+
"recorded_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
166
|
+
"note": ("Generated by loopgate/gates/change_health.py --write-baseline. A baseline is a claim "
|
|
167
|
+
"about the tree; re-record it in a reviewed commit, never to make a red verdict "
|
|
168
|
+
"green."),
|
|
169
|
+
"metrics": head}
|
|
170
|
+
path = os.path.join(root, BASELINE_REL)
|
|
171
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
172
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
173
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
174
|
+
fh.write("\n")
|
|
175
|
+
print(f"baseline written: {BASELINE_REL} ({len(head)} metrics)")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
if args.base:
|
|
179
|
+
base_root, tmp = snapshot_from_git(args.base)
|
|
180
|
+
try:
|
|
181
|
+
base_metrics = collect(base_root)
|
|
182
|
+
finally:
|
|
183
|
+
subprocess.run(["git", "-C", ROOT, "worktree", "remove", "--force", base_root],
|
|
184
|
+
capture_output=True, text=True)
|
|
185
|
+
tmp.cleanup()
|
|
186
|
+
label = f"git {args.base}"
|
|
187
|
+
else:
|
|
188
|
+
raw = read(os.path.join(root, BASELINE_REL))
|
|
189
|
+
if not raw:
|
|
190
|
+
print(f"no baseline ({BASELINE_REL}) and no --base: there is nothing to compare against, and a "
|
|
191
|
+
f"verdict without a reference is an opinion", file=sys.stderr)
|
|
192
|
+
print(STOP_MARKER)
|
|
193
|
+
return 1
|
|
194
|
+
payload = json.loads(raw)
|
|
195
|
+
if payload.get("schema") != SCHEMA_VERSION:
|
|
196
|
+
print(f"baseline schema {payload.get('schema')!r} != {SCHEMA_VERSION}: re-record it", file=sys.stderr)
|
|
197
|
+
print(STOP_MARKER)
|
|
198
|
+
return 1
|
|
199
|
+
base_metrics = payload.get("metrics") or {}
|
|
200
|
+
label = f"baseline {payload.get('recorded_at', '?')}"
|
|
201
|
+
|
|
202
|
+
rows = judge(base_metrics, head, policy)
|
|
203
|
+
regressed = [r for r in rows if r["regressed"]]
|
|
204
|
+
|
|
205
|
+
if args.json:
|
|
206
|
+
print(json.dumps({"marker": STOP_MARKER if regressed else PASS_MARKER, "against": label,
|
|
207
|
+
"base": base_metrics, "head": head, "rows": rows}, indent=2, ensure_ascii=False))
|
|
208
|
+
else:
|
|
209
|
+
print(f"change health — {label}")
|
|
210
|
+
print()
|
|
211
|
+
print(f" {'metric':<22}{'before':>10}{'after':>10} verdict")
|
|
212
|
+
for row in rows:
|
|
213
|
+
delta = ""
|
|
214
|
+
if isinstance(row["base"], int) and isinstance(row["head"], int) and row["base"] != row["head"]:
|
|
215
|
+
diff = row["head"] - row["base"]
|
|
216
|
+
delta = f" ({diff:+d})"
|
|
217
|
+
mark = {"regressed": "WORSE", "improved": "better", "same": "same",
|
|
218
|
+
"within tolerance": "grew, in tolerance"}.get(row["status"], row["status"])
|
|
219
|
+
print(f" {row['metric']:<22}{fmt(row['base']):>10}{fmt(row['head']):>10} {mark}{delta}")
|
|
220
|
+
print()
|
|
221
|
+
for row in regressed:
|
|
222
|
+
print(f" why this matters: {row['metric']} — {row['why']}")
|
|
223
|
+
unknown = [r["metric"] for r in rows if r["status"] == "unknown"]
|
|
224
|
+
if unknown:
|
|
225
|
+
print(f" not measured (named, not hidden): {', '.join(unknown)}")
|
|
226
|
+
|
|
227
|
+
print(STOP_MARKER if regressed else PASS_MARKER)
|
|
228
|
+
return 1 if regressed else 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
sys.exit(main())
|