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,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', '?'))} · phase {esc(state.get('phase', '?'))} · "
|
|
123
|
+
f"readiness {esc(state.get('readiness', '?'))} · evidence {esc(state.get('evidence', '?'))}"
|
|
124
|
+
f" · risk {esc(state.get('risk', '?'))} · "
|
|
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())
|