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.
Files changed (40) hide show
  1. loopagent_gate-0.4.2.dist-info/METADATA +374 -0
  2. loopagent_gate-0.4.2.dist-info/RECORD +40 -0
  3. loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
  4. loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
  5. loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
  6. loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
  7. loopgate/__init__.py +7 -0
  8. loopgate/__main__.py +8 -0
  9. loopgate/cli.py +147 -0
  10. loopgate/gates/anchor_witness.py +249 -0
  11. loopgate/gates/assert_dims.py +88 -0
  12. loopgate/gates/bootstrap_state.py +111 -0
  13. loopgate/gates/change_health.py +232 -0
  14. loopgate/gates/check_loop.py +1267 -0
  15. loopgate/gates/check_product.py +463 -0
  16. loopgate/gates/check_visual.py +224 -0
  17. loopgate/gates/doctor.py +130 -0
  18. loopgate/gates/estimate_task.py +137 -0
  19. loopgate/gates/export_public.py +289 -0
  20. loopgate/gates/guard_card.py +519 -0
  21. loopgate/gates/loop_watch.py +118 -0
  22. loopgate/gates/regressions.py +463 -0
  23. loopgate/gates/render_status_page.py +151 -0
  24. loopgate/gates/render_task_ledger.py +312 -0
  25. loopgate/gates/review_pr.py +370 -0
  26. loopgate/gates/sync_issues_to_kanban.py +593 -0
  27. loopgate/gates/tree_parity.py +307 -0
  28. loopgate/gates/worker_brief.py +396 -0
  29. loopgate/init.py +128 -0
  30. loopgate/paths.py +43 -0
  31. loopgate/py.typed +0 -0
  32. loopgate/templates/__init__.py +1 -0
  33. loopgate/templates/configs/anchor_witness.example.json +14 -0
  34. loopgate/templates/configs/fixtures/issues_sample.json +75 -0
  35. loopgate/templates/configs/fixtures/review_approve.json +5 -0
  36. loopgate/templates/configs/fixtures/review_changes.json +5 -0
  37. loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
  38. loopgate/templates/configs/repo_profiles.example.json +63 -0
  39. loopgate/templates/configs/tree_parity.example.json +16 -0
  40. 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())