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,463 @@
1
+ #!/usr/bin/env python3
2
+ """Regression suite for the loop gate (course lesson 09: review + *regression checks*).
3
+
4
+ Every case below is a failure that was found by hand during rounds 02-07 and is now pinned as an
5
+ automated regression: the suite copies the repository to a scratch dir, injects one defect per case,
6
+ runs the gate there and asserts the expected marker AND the expected failing dimension. A case fails
7
+ if the gate stays green (the check became vacuous) or if the wrong dimension reacts.
8
+
9
+ Usage:
10
+ python3 -m loopgate.gates.regressions # run all cases
11
+ python3 -m loopgate.gates.regressions --verbose # print each case's gate output
12
+
13
+ Exit 0 prints ``REGRESSION_SUITE_PASS_0001``; any failed case prints ``REGRESSION_SUITE_STOP_0001``
14
+ and exits 1.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from loopgate.paths import PACKAGE_DIR, project_root, gate_file
20
+
21
+ import argparse
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ from typing import Callable
31
+
32
+ ROOT = project_root()
33
+ GATE_PASS = "LOOP_ENGINEERING_PASS_0001"
34
+ GATE_STOP = "LOOP_ENGINEERING_STOP_0001"
35
+ # A tree that declared a cold start prints COLD, not STOP: the code dimensions are checked, the history
36
+ # dimensions (D03, D04, D05, D09, D12) are named but refused. An injection into one of those dimensions
37
+ # is therefore unobservable — the gate short-circuits it before and after — and must be skipped, never
38
+ # counted as a pass or a fail.
39
+ GATE_COLD = "LOOP_ENGINEERING_COLD_0001"
40
+ # The dimensions that describe *history* (rounds, handoff, the signed anchor). They may be red or COLD in a
41
+ # tree that has never run the loop; every other dimension describes the code and must be green everywhere.
42
+ STATE_DIMS = {"D03", "D04", "D05", "D09", "D12"}
43
+ PASS_MARKER = "REGRESSION_SUITE_PASS_0001"
44
+ STOP_MARKER = "REGRESSION_SUITE_STOP_0001"
45
+ IGNORE = shutil.ignore_patterns(".git", "__pycache__", "*.pyc", "ywcrew")
46
+
47
+
48
+ def make_copy() -> str:
49
+ """Copy the project AND the packaged gates. The injections edit the gate source inside the copy, so the
50
+ gate that runs is the one that was edited — never the installed package on the machine."""
51
+ tmp = tempfile.mkdtemp(prefix="loop_regress_")
52
+ dst = os.path.join(tmp, "repo")
53
+ shutil.copytree(ROOT, dst, ignore=IGNORE, symlinks=True)
54
+ pkg_src = os.path.join(PACKAGE_DIR, "loopgate")
55
+ shutil.copytree(pkg_src, os.path.join(dst, "loopgate"), ignore=IGNORE, symlinks=True,
56
+ dirs_exist_ok=True)
57
+ return dst
58
+
59
+
60
+ def run_gate(repo: str) -> tuple[str, list[str], list[str], str]:
61
+ # cwd=repo: `python -m loopgate...` resolves the package from the working directory first, so the
62
+ # injected copy's gate sources are the ones that run. Without it the original tree answers instead.
63
+ proc = subprocess.run(
64
+ [sys.executable, "-m", "loopgate.gates.check_loop", "--root", repo],
65
+ capture_output=True, text=True, cwd=repo,
66
+ )
67
+ out = proc.stdout
68
+ # Classify on the LAST non-empty line, never on a substring anywhere in stdout: dimension details quote
69
+ # markers by name (D01's failure reason literally contains "{MARKER_PASS}"), so an unanchored `in out`
70
+ # turns a red gate into a "pass" and a case into a false FAIL. The gate always prints its marker last.
71
+ lines = [line.strip() for line in out.splitlines() if line.strip()]
72
+ last = lines[-1] if lines else ""
73
+ if last == GATE_PASS:
74
+ marker = GATE_PASS
75
+ elif last == GATE_COLD:
76
+ marker = GATE_COLD
77
+ else:
78
+ marker = GATE_STOP
79
+ failures = []
80
+ for line in out.splitlines():
81
+ head = line.split()[0] if line.split() else ""
82
+ if head.startswith("D") and "[FAIL]" in line:
83
+ failures.append(head)
84
+ # Read the dimensions the gate refuses in a cold tree from their own status column, not from the prose
85
+ # line that summarises them: ``D12 anchor_witness [COLD] cold start: ...`` is a stable contract, while
86
+ # the summary sentence is free to be reworded (and its "dimension(s)" would trap a naive parenthesis).
87
+ cold_dims = re.findall(r"^(D\d{2})\s+\S.*\[COLD\]", out, re.M)
88
+ return marker, failures, cold_dims, out
89
+
90
+
91
+ # ------------------------------------------------------------------ injections
92
+
93
+ def wipe_loop_state(repo: str) -> None:
94
+ os.remove(os.path.join(repo, "artifacts", "loop_state.json"))
95
+
96
+
97
+ def drop_two_rounds(repo: str) -> None:
98
+ rounds = os.path.join(repo, "artifacts", "rounds")
99
+ names = sorted(os.listdir(rounds))
100
+ for name in names[-2:]:
101
+ os.remove(os.path.join(rounds, name))
102
+
103
+
104
+ def tamper_anchor(repo: str) -> None:
105
+ path = os.path.join(repo, "artifacts", "loop_gate_pass.json")
106
+ anchor = json.load(open(path, encoding="utf-8"))
107
+ anchor["verdict_hash"] = "0000000000000000"
108
+ json.dump(anchor, open(path, "w", encoding="utf-8"), indent=2)
109
+
110
+
111
+ def inflate_health_baseline(repo: str) -> None:
112
+ """Claim a better past than the tree ever had: the third verdict must notice."""
113
+ path = os.path.join(repo, "configs", "change_health_baseline.json")
114
+ payload = json.load(open(path, encoding="utf-8"))
115
+ metrics = payload.get("metrics") or {}
116
+ metrics["regression_cases"] = metrics.get("regression_cases", 0) + 20
117
+ payload["metrics"] = metrics
118
+ json.dump(payload, open(path, "w", encoding="utf-8"), indent=2)
119
+
120
+
121
+ def mint_anchor_for_wrong_state(repo: str) -> None:
122
+ """Self-consistent forgery: flip a dimension's recorded state and re-mint its hash."""
123
+ path = os.path.join(repo, "artifacts", "loop_gate_pass.json")
124
+ anchor = json.load(open(path, encoding="utf-8"))
125
+ for dim in anchor["dimensions"]:
126
+ if dim["id"] == "D08":
127
+ dim["status"], dim["skip"] = "SKIP", True
128
+ stable = {
129
+ "marker": "LOOP_ENGINEERING_PASS_0001",
130
+ "dimensions": [{"id": d["id"], "status": d["status"]}
131
+ for d in anchor["dimensions"] if d["id"] != "D05"],
132
+ }
133
+ anchor["verdict_hash"] = hashlib.sha256(
134
+ json.dumps(stable, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
135
+ json.dump(anchor, open(path, "w", encoding="utf-8"), indent=2)
136
+ hand = os.path.join(repo, "handoff", "latest_loop.json")
137
+ payload = json.load(open(hand, encoding="utf-8"))
138
+ payload["gate_report_verdict_hash"] = anchor["verdict_hash"]
139
+ json.dump(payload, open(hand, "w", encoding="utf-8"), indent=2)
140
+
141
+
142
+ def fabricate_handoff_hash(repo: str) -> None:
143
+ hand = os.path.join(repo, "handoff", "latest_loop.json")
144
+ payload = json.load(open(hand, encoding="utf-8"))
145
+ payload["status"] = "pass"
146
+ payload["marker"] = "LOOP_ENGINEERING_PASS_0001"
147
+ payload["gate_report_verdict_hash"] = "deadbeefdeadbeef"
148
+ payload["blockers"] = []
149
+ payload["roles_run"] = [{"role": "fake"}]
150
+ payload["reviewed_by"] = ["fake"]
151
+ json.dump(payload, open(hand, "w", encoding="utf-8"), indent=2)
152
+
153
+
154
+ def add_direct_cli_call(repo: str) -> None:
155
+ with open(os.path.join(repo, "loopgate", "gates", "_probe_direct_cli.sh"), "w", encoding="utf-8") as fh:
156
+ fh.write('#!/bin/sh\ncodex exec --full-auto "fix it"\n')
157
+
158
+
159
+ def add_secret_shaped_string(repo: str) -> None:
160
+ with open(os.path.join(repo, "secrets_probe.md"), "w", encoding="utf-8") as fh:
161
+ fh.write("token = 'sk-" + "A" * 32 + "'\n")
162
+
163
+
164
+ def hide_lessons(repo: str) -> None:
165
+ course = os.path.join(repo, "course")
166
+ victim = next(d for d in sorted(os.listdir(course)) if d[0].isdigit())
167
+ os.rename(os.path.join(course, victim), os.path.join(course, victim + ".hidden"))
168
+ for i, name in enumerate(sorted(d for d in os.listdir(course) if d[0].isdigit())):
169
+ if i >= 12:
170
+ os.rename(os.path.join(course, name), os.path.join(course, name + ".hidden"))
171
+
172
+
173
+ def pad_a_round(repo: str) -> None:
174
+ path = os.path.join(repo, "artifacts", "rounds", "round_09.md")
175
+ with open(path, "w", encoding="utf-8") as fh:
176
+ fh.write("# Round 09\n## Gap\nx\n## Change\nx\n## Evidence\n" + "filler " * 40 + "\n## Metrics\nx\n## Next\nx\n")
177
+
178
+
179
+ def gut_evidence_bundle(repo: str) -> None:
180
+ """Keep one command instead of the repository's full pre-commit bundle."""
181
+ path = os.path.join(repo, "configs", "repo_profiles.json")
182
+ payload = json.load(open(path, encoding="utf-8"))
183
+ for profile in payload["profiles"].values():
184
+ profile["evidence_bundle"] = profile["evidence_bundle"][:1]
185
+ json.dump(payload, open(path, "w", encoding="utf-8"), indent=2)
186
+
187
+
188
+ def neuter_evidence_validator(repo: str) -> None:
189
+ """Make the bundle validator always accept: the rule is then printed but not enforced."""
190
+ path = os.path.join(repo, "loopgate", "gates", "worker_brief.py")
191
+ text = open(path, encoding="utf-8").read()
192
+ marker = 'def verify_evidence(profile: dict, transcript: str) -> list[str]:'
193
+ assert marker in text
194
+ head, _, rest = text.partition(marker)
195
+ body_end = rest.index("\n\n\n")
196
+ replacement = (marker + "\n \"\"\"Neutered for the regression case.\"\"\"\n return []\n")
197
+ open(path, "w", encoding="utf-8").write(head + replacement + rest[body_end:])
198
+
199
+
200
+ def drop_issue_bridge(repo: str) -> None:
201
+ os.remove(os.path.join(repo, "loopgate", "gates", "sync_issues_to_kanban.py"))
202
+
203
+
204
+ def drop_dispatch_constraints(repo: str) -> None:
205
+ """The ywcrew task spec loses the bundle: the rule is prose again for the dispatch path."""
206
+ path = os.path.join(repo, "loopgate", "gates", "worker_brief.py")
207
+ text = open(path, encoding="utf-8").read()
208
+ assert '"constraints": constraints,' in text
209
+ open(path, "w", encoding="utf-8").write(text.replace('"constraints": constraints,', '"constraints": "",'))
210
+
211
+
212
+ def borrow_exit_code(repo: str) -> None:
213
+ """Re-introduce the hole the first live review found: one trailing exit code proves every gate.
214
+
215
+ The validator is patched to accept any exit line after a command (the old free-floating window)
216
+ instead of an exit line inside that command's own block; D10's fifth falsification must catch it.
217
+ """
218
+ path = os.path.join(repo, "loopgate", "gates", "worker_brief.py")
219
+ text = open(path, encoding="utf-8").read()
220
+ old = (" codes = [m.group(1) for _, text, _ in block for m in [EXIT_LINE_RE.match(text)] if m]")
221
+ assert old in text, "validator shape changed: update this injector"
222
+ new = (" codes = [m.group(1) for _, text, _ in lines[idx + 1:] "
223
+ "for m in [EXIT_LINE_RE.match(text)] if m]")
224
+ open(path, "w", encoding="utf-8").write(text.replace(old, new))
225
+
226
+
227
+ def first_match_only(repo: str) -> None:
228
+ """Review only the first occurrence of a command: a later failing rerun hides behind it.
229
+
230
+ Second live review (harness, read-only, run 2026-10-12-309f9098) found this: a transcript where a
231
+ command passed and then failed again reported a clean bundle. D10's sixth falsification must catch it.
232
+ """
233
+ path = os.path.join(repo, "loopgate", "gates", "worker_brief.py")
234
+ text = open(path, encoding="utf-8").read()
235
+ old = " for occurrence, idx in enumerate(matches, 1):"
236
+ assert old in text
237
+ open(path, "w", encoding="utf-8").write(text.replace(old, " for occurrence, idx in enumerate(matches[:1], 1):"))
238
+
239
+
240
+ def drop_scenario_whys(repo: str) -> None:
241
+ """Remove the user-facing 'why' from every scenario: the gate keeps passing but stops saying what a
242
+ user would feel. D11 must reject that (a check nobody can argue about is not an acceptance criterion)."""
243
+ path = os.path.join(repo, "configs", "product_scenarios.json")
244
+ payload = json.load(open(path, encoding="utf-8"))
245
+ for sc in payload["scenarios"]:
246
+ sc.pop("why", None)
247
+ json.dump(payload, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
248
+
249
+
250
+ def neuter_product_evaluator(repo: str) -> None:
251
+ """Make the product evaluator accept everything: the second gate becomes decoration."""
252
+ path = os.path.join(repo, "loopgate", "gates", "check_product.py")
253
+ text = open(path, encoding="utf-8").read()
254
+ old = ' exp = scenario.get("expect") or {}\n reasons: list[str] = []'
255
+ assert old in text, "product evaluator shape changed: update this injector"
256
+ open(path, "w", encoding="utf-8").write(text.replace(old, ' return []\n' + old))
257
+
258
+
259
+ def thin_product_scenarios(repo: str) -> None:
260
+ """Keep three scenarios: the product gate degrades into a smoke test."""
261
+ path = os.path.join(repo, "configs", "product_scenarios.json")
262
+ payload = json.load(open(path, encoding="utf-8"))
263
+ payload["scenarios"] = payload["scenarios"][:3]
264
+ json.dump(payload, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
265
+
266
+
267
+ def tamper_signed_anchor(repo: str) -> None:
268
+ """Rewrite the anchor after signing: the signature must no longer match (silent rewrite)."""
269
+ path = os.path.join(repo, "artifacts", "loop_gate_pass.json")
270
+ payload = json.load(open(path, encoding="utf-8"))
271
+ payload["verdict_hash"] = "deadbeefdeadbeef"
272
+ json.dump(payload, open(path, "w", encoding="utf-8"), indent=2)
273
+
274
+
275
+ def empty_allowed_signers(repo: str) -> None:
276
+ """Remove the trusted public key: nothing can be verified, so nothing may be accepted."""
277
+ open(os.path.join(repo, "configs", "allowed_signers"), "w", encoding="utf-8").write(
278
+ "# no trusted signers\n")
279
+
280
+
281
+ def point_witness_at_another_run(repo: str) -> None:
282
+ """Make the witness record describe a different verdict than the anchor on disk."""
283
+ path = os.path.join(repo, "artifacts", "anchor_witness.json")
284
+ record = json.load(open(path, encoding="utf-8"))
285
+ record["verdict_hash"] = "0" * 16
286
+ json.dump(record, open(path, "w", encoding="utf-8"), indent=2)
287
+
288
+
289
+ def strip_visual_whys(repo: str) -> None:
290
+ """Visual scenarios lose the user-facing consequence: checks nobody can argue about."""
291
+ path = os.path.join(repo, "configs", "visual_scenarios.json")
292
+ payload = json.load(open(path, encoding="utf-8"))
293
+ for sc in payload["scenarios"]:
294
+ sc.pop("why", None)
295
+ json.dump(payload, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
296
+
297
+
298
+ def neuter_visual_evaluator(repo: str) -> None:
299
+ """Make the visual evaluator accept every page: the visual gate becomes a screenshot generator."""
300
+ path = os.path.join(repo, "loopgate", "gates", "check_visual.py")
301
+ text = open(path, encoding="utf-8").read()
302
+ old = ' exp = scenario.get("expect") or {}\n dom = observation.get("dom") or ""'
303
+ assert old in text, "visual evaluator shape changed: update this injector"
304
+ open(path, "w", encoding="utf-8").write(text.replace(old, " return []\n" + old))
305
+
306
+
307
+ def drop_product_location(repo: str) -> None:
308
+ """The profile loses both ways to find the product: the gate cannot say what it is accepting."""
309
+ path = os.path.join(repo, "configs", "repo_profiles.json")
310
+ payload = json.load(open(path, encoding="utf-8"))
311
+ scenarios = json.load(open(os.path.join(repo, "configs", "product_scenarios.json"), encoding="utf-8"))
312
+ name = scenarios.get("profile") or next(iter(payload["profiles"]))
313
+ payload["profiles"][name].pop("local_path", None)
314
+ payload["profiles"][name].pop("local_path_env", None)
315
+ json.dump(payload, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
316
+
317
+
318
+ def corrupt_witness_signature(repo: str) -> None:
319
+ """Corrupt the signature: the anchor must not be accepted on an unverifiable signature."""
320
+ path = os.path.join(repo, "artifacts", "loop_gate_verdict.txt.sig")
321
+ text = open(path, encoding="utf-8").read()
322
+ open(path, "w", encoding="utf-8").write(text.replace(text.splitlines()[-2], "AAAA"))
323
+
324
+
325
+ CASES: list[tuple[str, Callable[[str], None], str, list[str]]] = [
326
+ ("positive: untouched copy stays green", lambda r: None, GATE_PASS, []),
327
+ ("R1 loop state deleted -> D03", wipe_loop_state, GATE_STOP, ["D03"]),
328
+ ("R2 round artifacts removed -> D04/D05", drop_two_rounds, GATE_STOP, ["D04", "D05"]),
329
+ ("R3 anchor hash tampered -> D05", tamper_anchor, GATE_STOP, ["D05"]),
330
+ # R4 used to be caught by D05's state comparison; now the witness catches it first: a re-minted anchor
331
+ # is no longer covered by the signature, so D12 fails and D05 no longer has to.
332
+ ("R4 anchor minted for a state we are not in -> D12", mint_anchor_for_wrong_state,
333
+ GATE_STOP, ["D12"]),
334
+ ("R5 handoff hash fabricated -> D05", fabricate_handoff_hash, GATE_STOP, ["D05"]),
335
+ ("R6 pasteable CLI-agent call -> D06", add_direct_cli_call, GATE_STOP, ["D06"]),
336
+ ("R7 credential-shaped string -> D07", add_secret_shaped_string, GATE_STOP, ["D07"]),
337
+ ("R8 corpus below threshold -> D08", hide_lessons, GATE_STOP, ["D08"]),
338
+ ("R9 headings with filler only -> D04", pad_a_round, GATE_STOP, ["D04"]),
339
+ ("R10 worker bundle cut to a subset -> D10", gut_evidence_bundle, GATE_STOP, ["D10"]),
340
+ ("R11 evidence validator neutered -> D10", neuter_evidence_validator, GATE_STOP, ["D10"]),
341
+ ("R12 issue bridge removed -> D10", drop_issue_bridge, GATE_STOP, ["D10"]),
342
+ ("R13 dispatch spec loses the bundle -> D10", drop_dispatch_constraints, GATE_STOP, ["D10"]),
343
+ ("R14 borrowed exit code -> D10", borrow_exit_code, GATE_STOP, ["D10"]),
344
+ ("R15 hidden failing rerun -> D10", first_match_only, GATE_STOP, ["D10"]),
345
+ ("R16 scenario loses its user-facing why -> D11", drop_scenario_whys, GATE_STOP, ["D11"]),
346
+ ("R17 product evaluator neutered -> D11", neuter_product_evaluator, GATE_STOP, ["D11"]),
347
+ ("R18 product gate thinned to a smoke test -> D11", thin_product_scenarios, GATE_STOP, ["D11"]),
348
+ ("R19 anchor rewritten after signing -> D12", tamper_signed_anchor, GATE_STOP, ["D12"]),
349
+ ("R20 trusted signers emptied -> D12", empty_allowed_signers, GATE_STOP, ["D12"]),
350
+ ("R21 witness record describes another run -> D12", point_witness_at_another_run, GATE_STOP, ["D12"]),
351
+ ("R22 visual scenario loses its why -> D13", strip_visual_whys, GATE_STOP, ["D13"]),
352
+ ("R23 visual evaluator neutered -> D13", neuter_visual_evaluator, GATE_STOP, ["D13"]),
353
+ ("R24 witness signature corrupted -> D12", corrupt_witness_signature, GATE_STOP, ["D12"]),
354
+ ("R25 profile loses local_path and local_path_env -> D11", drop_product_location, GATE_STOP, ["D11"]),
355
+ ("R26 baseline claims a better past than the tree has -> D14", inflate_health_baseline, GATE_STOP, ["D14"]),
356
+ ]
357
+
358
+
359
+ def main() -> int:
360
+ ap = argparse.ArgumentParser(description="Loop gate regression suite.")
361
+ ap.add_argument("--verbose", action="store_true")
362
+ args = ap.parse_args()
363
+
364
+ # The baseline decides what each injection is compared against. Where the loop state exists the
365
+ # untouched copy is green and any red is caused by the injection. Where it does not exist (a fresh
366
+ # clone: artifacts/ and handoff/ are gitignored state), the untouched copy is already red, and the
367
+ # honest reading is "these dimensions were red before you touched anything" — so the positive case is
368
+ # skipped with that reason, and injections that tamper with absent state are skipped instead of
369
+ # failing. What remains is still a real attack: the injected dimension must be red in both cases.
370
+ baseline_repo = make_copy()
371
+ baseline_marker, baseline_red, baseline_cold, _ = run_gate(baseline_repo)
372
+ shutil.rmtree(os.path.dirname(baseline_repo), ignore_errors=True)
373
+ baseline_green = baseline_marker == GATE_PASS
374
+
375
+ results: list[dict] = []
376
+ # A red *code* dimension in the untouched copy is not "history unprovable" — it is a broken tree, and it
377
+ # invalidates every injection below: the suite would compare attacks against a gate that is already down.
378
+ # History dimensions may be red in a tree that has never run the loop; code dimensions may not.
379
+ baseline_broken = [d for d in baseline_red if d not in STATE_DIMS]
380
+ if baseline_marker == GATE_STOP and baseline_broken:
381
+ results.append({"case": "baseline: the untouched copy is already red", "ok": False,
382
+ "marker": baseline_marker, "failed_dims": baseline_broken})
383
+ print(f"[FAIL] baseline: the untouched copy is already red -> {', '.join(baseline_broken)}")
384
+ print(" a red code dimension invalidates every injection: fix the tree, then re-run")
385
+
386
+ for name, inject, want_marker, want_dims in CASES:
387
+ repo = make_copy()
388
+ try:
389
+ if want_marker == GATE_PASS and not baseline_green:
390
+ # The only case that expects green: not applicable where the loop never ran. Skipped with
391
+ # the reason, never silently counted as a pass.
392
+ state = "cold" if baseline_cold else "red"
393
+ dims = ", ".join(baseline_cold) if baseline_cold else (", ".join(baseline_red) or "none")
394
+ results.append({"case": name, "ok": True, "skipped": True,
395
+ "reason": f"baseline not green ({state}: {dims})",
396
+ "marker": baseline_marker, "failed_dims": baseline_red})
397
+ print(f"[skip] {name}")
398
+ print(f" baseline is {state} ({dims}): this tree has never run the loop, "
399
+ "so there is no green baseline to break")
400
+ continue
401
+ try:
402
+ inject(repo)
403
+ except (FileNotFoundError, KeyError) as exc:
404
+ # The injection targets state this tree does not have: not applicable, not a pass.
405
+ results.append({"case": name, "ok": True, "skipped": True,
406
+ "reason": f"state absent: {exc.__class__.__name__}: {exc}",
407
+ "marker": None, "failed_dims": []})
408
+ print(f"[skip] {name}")
409
+ print(f" state absent: {exc.__class__.__name__}: {exc}")
410
+ continue
411
+ except Exception as exc: # a broken injector is a failed case, never a crashed suite
412
+ results.append({"case": name, "ok": False, "marker": None, "failed_dims": [],
413
+ "injector_error": f"{exc.__class__.__name__}: {exc}"})
414
+ print(f"[FAIL] {name}")
415
+ print(f" injector error: {exc.__class__.__name__}: {exc}")
416
+ continue
417
+ marker, failures, cold_dims, out = run_gate(repo)
418
+ # A cold tree refuses to evaluate its own history dimensions: they print COLD before and after
419
+ # any injection, so a case whose expected dimensions are all COLD is unobservable here. That is
420
+ # not a pass (the injection never ran against a real dimension) and not a fail (the gate is
421
+ # doing exactly what it promises) — it is skipped with the reason, like a missing state file.
422
+ if cold_dims and want_dims and all(d in cold_dims for d in want_dims):
423
+ results.append({"case": name, "ok": True, "skipped": True,
424
+ "reason": f"unobservable in a cold tree: {'/'.join(want_dims)} is COLD",
425
+ "marker": marker, "failed_dims": failures})
426
+ print(f"[skip] {name}")
427
+ print(f" {'/'.join(want_dims)} is COLD in this tree: the gate refuses to evaluate "
428
+ "its own history, so the injection cannot be observed")
429
+ continue
430
+ ok = marker == want_marker and (not want_dims or any(d in failures for d in want_dims))
431
+ results.append({"case": name, "ok": ok, "marker": marker, "failed_dims": failures})
432
+ status = "ok " if ok else "FAIL"
433
+ print(f"[{status}] {name}")
434
+ print(f" marker={marker} failed_dims={failures or '[]'}")
435
+ if args.verbose or not ok:
436
+ print("\n".join(" | " + line for line in out.splitlines()[-6:]))
437
+ finally:
438
+ shutil.rmtree(os.path.dirname(repo), ignore_errors=True)
439
+
440
+ bad = [r for r in results if not r["ok"]]
441
+ skipped = [r for r in results if r.get("skipped")]
442
+ print()
443
+ print(f"cases: {len(results) - len(bad) - len(skipped)} ok, {len(bad)} failed, "
444
+ f"{len(skipped)} skipped")
445
+ if skipped:
446
+ print("skipped cases target loop state this tree does not carry (artifacts/, handoff/); "
447
+ "they are not counted as passes")
448
+ report = {"marker": PASS_MARKER if not bad else STOP_MARKER, "cases": results}
449
+ # A fresh clone has no artifacts/ (it is gitignored state), and a suite that crashes on the last
450
+ # line is indistinguishable from a suite that failed: create the directory instead.
451
+ report_dir = os.path.join(ROOT, "artifacts")
452
+ os.makedirs(report_dir, exist_ok=True)
453
+ with open(os.path.join(report_dir, "regressions_report.json"), "w", encoding="utf-8") as fh:
454
+ json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
455
+ if bad:
456
+ print(STOP_MARKER)
457
+ return 1
458
+ print(PASS_MARKER)
459
+ return 0
460
+
461
+
462
+ if __name__ == "__main__":
463
+ sys.exit(main())
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """Operator status page — the human-readable surface the qa role screenshots and reviews.
3
+
4
+ Why: the loop produces two verdicts (technical gate, product acceptance) plus a queue, and none of them is
5
+ readable by a person without running commands. This renders them into one page so that "what would an
6
+ operator see right now?" becomes a thing you can look at — and, more importantly, a thing that can be
7
+ checked *visually* (screenshot + vision) instead of only by grepping JSON.
8
+
9
+ The page is deliberately static and local: no CDN, no JavaScript, no fonts to fetch, so a screenshot of it
10
+ is reproducible offline.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from loopgate.paths import project_root, gate_file
16
+
17
+ import argparse
18
+ import datetime as dt
19
+ import html
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+
25
+ ROOT = project_root()
26
+ OUT_REL = "artifacts/status.html"
27
+
28
+
29
+ def load(path: str) -> dict | None:
30
+ try:
31
+ with open(path, encoding="utf-8") as fh:
32
+ return json.load(fh)
33
+ except (OSError, json.JSONDecodeError):
34
+ return None
35
+
36
+
37
+ def queue_rows() -> list[dict]:
38
+ # `hermes` is the optional execution backend: a clone without it must still render the page (both
39
+ # verdicts come from files), so a missing binary degrades to an empty queue instead of a traceback.
40
+ try:
41
+ proc = subprocess.run(["hermes", "kanban", "list", "--json"], capture_output=True, text=True)
42
+ except OSError:
43
+ return []
44
+ if proc.returncode != 0:
45
+ return []
46
+ try:
47
+ tasks = json.loads(proc.stdout or "[]")
48
+ except json.JSONDecodeError:
49
+ return []
50
+ return [{"id": t.get("id"), "status": t.get("status"), "assignee": t.get("assignee"),
51
+ "title": clip(t.get("title") or "")} for t in tasks]
52
+
53
+
54
+ CELL_WIDTH = 70
55
+
56
+
57
+ def clip(text: str, width: int = CELL_WIDTH) -> str:
58
+ """Cut a table cell visibly: a reader must be able to see that something was cut.
59
+
60
+ The vision review of the first version of this page caught a title cut mid-word with no ellipsis
61
+ ("...proving nothin"). Every long cell goes through this, and the visual gate asserts the rule.
62
+ """
63
+ text = str(text)
64
+ return text if len(text) <= width else text[:width - 1].rstrip() + "…"
65
+
66
+
67
+ def render(root: str = ROOT) -> str:
68
+ gate = load(os.path.join(root, "artifacts", "loop_gate_report.json")) or {}
69
+ product = load(os.path.join(root, "artifacts", "product_acceptance_report.json")) or {}
70
+ state = load(os.path.join(root, "artifacts", "loop_state.json")) or {}
71
+ witness = load(os.path.join(root, "artifacts", "anchor_witness.json")) or {}
72
+
73
+ dims = gate.get("dimensions") or []
74
+ gate_ok = gate.get("marker") == "LOOP_ENGINEERING_PASS_0001"
75
+ product_scenarios = product.get("scenarios") or []
76
+ product_ok = product.get("marker") == "PRODUCT_ACCEPTANCE_PASS_0001"
77
+ rows = queue_rows()
78
+
79
+ def esc(value) -> str:
80
+ return html.escape(str(value))
81
+
82
+ parts = [
83
+ "<!doctype html>",
84
+ '<html lang="en"><head><meta charset="utf-8">',
85
+ "<title>loop-system status</title>",
86
+ "<style>",
87
+ "body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin:2rem;color:#111}",
88
+ "h1{font-size:1.3rem}h2{font-size:1rem;margin-top:1.6rem}",
89
+ "table{border-collapse:collapse;margin-top:.4rem}td,th{border:1px solid #ccc;padding:.25rem .6rem;"
90
+ "text-align:left;font-size:.85rem}",
91
+ ".verdict{font-weight:700}",
92
+ ".ok{color:#0a7f2e}.fail{color:#b00020}.skip{color:#8a6d00}",
93
+ "</style></head><body>",
94
+ "<h1>loop-system status</h1>",
95
+ f"<p>generated {esc(dt.datetime.now().astimezone().isoformat(timespec='seconds'))}</p>",
96
+ "<h2>Verdicts</h2>",
97
+ "<table><tr><th>gate</th><th>verdict</th><th>marker</th></tr>",
98
+ f"<tr><td>technical gate (loop discipline)</td>"
99
+ f"<td class='verdict {'ok' if gate_ok else 'fail'}'>"
100
+ f"{'accepted' if gate_ok else 'not accepted'}</td><td>{esc(gate.get('marker', 'no report'))}</td></tr>",
101
+ f"<tr><td>product acceptance (user-facing)</td>"
102
+ f"<td class='verdict {'ok' if product_ok else 'fail'}'>"
103
+ f"{'product accepted' if product_ok else 'product not accepted'}</td>"
104
+ f"<td>{esc(product.get('marker', 'no report'))}</td></tr>",
105
+ "</table>",
106
+ "<h2>Technical gate dimensions</h2>",
107
+ "<table><tr><th>id</th><th>dimension</th><th>status</th></tr>",
108
+ ]
109
+ for dim in dims:
110
+ status = str(dim.get("status", "?"))
111
+ css = "ok" if status == "OK" else ("skip" if status == "SKIP" else "fail")
112
+ parts.append(f"<tr><td>{esc(dim.get('id'))}</td><td>{esc(clip(dim.get('name')))}</td>"
113
+ f"<td class='{css}'>{esc(status)}</td></tr>")
114
+ parts += ["</table>", "<h2>Product scenarios</h2>",
115
+ "<table><tr><th>id</th><th>scenario</th><th>status</th></tr>"]
116
+ for sc in product_scenarios:
117
+ status = str(sc.get("status", "?"))
118
+ css = "ok" if status == "OK" else ("skip" if status == "SKIP" else "fail")
119
+ parts.append(f"<tr><td>{esc(sc.get('id'))}</td><td>{esc(clip(sc.get('title')))}</td>"
120
+ f"<td class='{css}'>{esc(status)}</td></tr>")
121
+ parts += ["</table>", "<h2>Loop state</h2>",
122
+ f"<p>round {esc(state.get('round', '?'))} &middot; phase {esc(state.get('phase', '?'))} &middot; "
123
+ f"readiness {esc(state.get('readiness', '?'))} &middot; evidence {esc(state.get('evidence', '?'))}"
124
+ f" &middot; risk {esc(state.get('risk', '?'))} &middot; "
125
+ f"blockers {len(state.get('blockers') or [])}</p>",
126
+ f"<p>anchor verdict {esc(witness.get('verdict_hash', 'unsigned'))}</p>",
127
+ "<h2>Queue</h2>",
128
+ "<table><tr><th>task</th><th>status</th><th>assignee</th><th>title</th></tr>"]
129
+ for row in rows:
130
+ parts.append(f"<tr><td>{esc(row['id'])}</td><td>{esc(row['status'])}</td>"
131
+ f"<td>{esc(row['assignee'])}</td><td>{esc(row['title'])}</td></tr>")
132
+ parts += ["</table>", "</body></html>", ""]
133
+ return "\n".join(parts)
134
+
135
+
136
+ def main(argv=None) -> int:
137
+ ap = argparse.ArgumentParser(description="Render the operator status page.")
138
+ ap.add_argument("--root", default=ROOT)
139
+ ap.add_argument("--out", default=OUT_REL)
140
+ args = ap.parse_args(argv)
141
+ page = render(args.root)
142
+ path = os.path.join(args.root, args.out)
143
+ os.makedirs(os.path.dirname(path), exist_ok=True)
144
+ with open(path, "w", encoding="utf-8") as fh:
145
+ fh.write(page)
146
+ print(f"status page: {args.out} ({len(page)} bytes)")
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())