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,1267 @@
1
+ #!/usr/bin/env python3
2
+ """Local gate for the loop-system project (the "Prove" step of the loop).
3
+
4
+ Contract
5
+ --------
6
+ * Prints ``LOOP_ENGINEERING_PASS_0001`` and exits 0 only when every mandatory
7
+ dimension passes (and, in ``--strict`` mode, when no optional dimension is skipped).
8
+ * Two honest non-PASS verdicts exist: ``LOOP_ENGINEERING_COLD_0001`` (a tree that never ran the loop
9
+ cannot prove its own history) and dimensions marked ``COLD`` / ``N/A``, which are named, never counted
10
+ as passes. A dimension whose input the project does not ship (D08, the study corpus) is ``N/A``, not a
11
+ failure: ``--strict`` means every dimension that applies is green, not that every input exists.
12
+ * Otherwise prints one ``[FAIL]`` line per dimension plus a numbered failure list
13
+ and ``LOOP_ENGINEERING_STOP_0001``, exit 1.
14
+ * **Read-only.** The gate never repairs anything and never writes to the tree; the only writes are its
15
+ own transcripts (``--report``) and the D05 binding anchor ``artifacts/loop_gate_pass.json``. Repair
16
+ happens in the loop, not inside the checker.
17
+
18
+ Hardened after an independent review round (see ``artifacts/rounds/round_04.md``):
19
+ D01/D02 are line-anchored and command-bound instead of substring checks, D03 checks
20
+ monotone history, D04 requires evidence bound to a command/artifact instead of headings,
21
+ D05 binds the handoff to the gate's own machine report instead of hand-written prose,
22
+ D08 reports real bytes, D09 checks role artifacts for substance and attribution.
23
+ Residual, documented limitation: every input is still author-reachable on this machine —
24
+ the gate makes fabrication *detectable by a command*, not impossible.
25
+
26
+ Usage
27
+ -----
28
+ python3 -m loopgate.gates.check_loop # normal mode: optional dims may SKIP
29
+ python3 -m loopgate.gates.check_loop --strict # SKIP becomes FAIL (project acceptance)
30
+ python3 -m loopgate.gates.check_loop --report artifacts/loop_gate_report.json
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import re
40
+ import shutil
41
+ import sys
42
+
43
+ from loopgate.paths import gate_file, project_root
44
+
45
+ MARKER_PASS = "LOOP_ENGINEERING_PASS_0001"
46
+ MARKER_STOP = "LOOP_ENGINEERING_STOP_0001"
47
+ # Third verdict for a tree that has never run the loop: the code can be checked, the history cannot be
48
+ # proven and must not be invented. See loopgate/gates/bootstrap_state.py and PLANS п.29.
49
+ MARKER_COLD = "LOOP_ENGINEERING_COLD_0001"
50
+ COLD_DIMS = {"D03", "D04", "D05", "D09", "D12"}
51
+ HEALTH_SCRIPT = "loopgate/gates/change_health.py"
52
+ HEALTH_POLICY_REL = "configs/change_health.json"
53
+ HEALTH_BASELINE_REL = "configs/change_health_baseline.json"
54
+ # Inputs the project deliberately does not ship. Absent input is not a defect of the tree under test.
55
+ NOT_APPLICABLE_DIMS = {"D08"}
56
+
57
+ TEXT_EXT = {".py", ".md", ".txt", ".sh", ".json", ".yml", ".yaml", ".toml", ".cfg", ".ini", ".swift"}
58
+ SKIP_DIRS = {".git", "course", "__pycache__", ".venv", "venv", "node_modules", ".idea", ".DS_Store",
59
+ # build output: a publication tree is a copy of this repository, and auditing the copy as if
60
+ # it were source makes the gate flag its own guardrail patterns (`dist/public/scripts/...`).
61
+ # `build/` is what `pip wheel` leaves inside the project: a copy of the package, not source.
62
+ "dist", "build"}
63
+
64
+ DIM_IDS = ["D01", "D02", "D03", "D04", "D05", "D06", "D07", "D08", "D09", "D10"]
65
+ # Dimensions that may legitimately SKIP in normal mode (their input is produced by an optional stage, or
66
+ # is not shipped in this tree: the course corpus is study material, not part of the tool).
67
+ OPTIONAL_DIMS = {"D09", "D08"}
68
+ GATE_SCRIPT = "loopgate/gates/check_loop.py"
69
+ GATE_CMD = "python3 -m loopgate.gates.check_loop"
70
+ REPORT_REL = "artifacts/loop_gate_report.json"
71
+ # Stage 6 (issue queue): the bridge plus the worker brief it renders.
72
+ BRIDGE_SCRIPT = "loopgate/gates/sync_issues_to_kanban.py"
73
+ BRIEF_SCRIPT = "loopgate/gates/worker_brief.py"
74
+ PROFILES_REL = "configs/repo_profiles.json"
75
+ # Stage 7 (product acceptance): the second verdict, with its own owner and its own gate.
76
+ PRODUCT_SCRIPT = "loopgate/gates/check_product.py"
77
+ PRODUCT_SCENARIOS_REL = "configs/product_scenarios.json"
78
+ # Stage 7b (external anchor): the verdict leaves the machine.
79
+ WITNESS_SCRIPT = "loopgate/gates/anchor_witness.py"
80
+ WITNESS_CONFIG_REL = "configs/anchor_witness.json"
81
+ ALLOWED_SIGNERS_REL = "configs/allowed_signers"
82
+ ANCHOR_SIG_REL = "artifacts/loop_gate_verdict.txt.sig"
83
+ WITNESS_RECORD_REL = "artifacts/anchor_witness.json"
84
+ # Stage 7c (visual checks): what a user sees, not only what the JSON says.
85
+ VISUAL_SCRIPT = "loopgate/gates/check_visual.py"
86
+ VISUAL_SCENARIOS_REL = "configs/visual_scenarios.json"
87
+ STATUS_PAGE_SCRIPT = "loopgate/gates/render_status_page.py"
88
+ # The brief must itself carry the evidence rule, and the validator must be able to reject a bundle
89
+ # that is incomplete or failing — the exact gap that produced the first PR's REJECT.
90
+ BRIEF_REQUIREMENTS = ["evidence bundle", "verbatim", "exit", "one task = one pr", "NOT RUN"]
91
+ # Binding anchor: written whenever every dimension EXCEPT the anchor's own certifiers is green, so a
92
+ # dimension never has to certify itself. Two dimensions read the anchor instead of the tree — D05 binds the
93
+ # handoff to it and D12 verifies its signature — so both are excluded from the anchor's verdict hash and
94
+ # from the "every other dimension is green" gate. Without that exclusion a tree whose anchor was lost could
95
+ # never mint one again: D12 needs a signature, a signature needs the anchor, the anchor needs D12 green.
96
+ PASS_REL = "artifacts/loop_gate_pass.json"
97
+ BINDING_DIM = "D05"
98
+ SELF_CERTIFYING_DIMS = {BINDING_DIM, "D12"}
99
+
100
+ # Narrow patterns only: a real shell/subprocess call to a CLI agent. Prose mentions are fine.
101
+ DIRECT_CLI_PATTERNS = [
102
+ (r"\bcodex\s+exec\b", "direct `codex exec`"),
103
+ (r"\bclaude\s+(-p|--print)\b", "direct `claude -p`"),
104
+ (r"\b(agy|grok|kimi|dsh)\s+(-p|run|exec)\b", "direct CLI agent invocation"),
105
+ (r"subprocess\.[A-Za-z_.]*\(\s*[\[(]?\s*[\"'](codex|claude|grok|kimi|agy|dsh)[\"']",
106
+ "subprocess call to a CLI agent binary"),
107
+ ]
108
+ SHELL_LANGS = {"bash", "sh", "shell", "zsh", "console", "pwsh", "powershell"}
109
+ INVOCATION_AT_LINE_START = re.compile(r"^\s*[$>]?\s*(codex|claude|grok|kimi|agy|dsh)\s+\S")
110
+ SECRET_PATTERNS = [
111
+ (r"\bsk-[A-Za-z0-9]{20,}", "OpenAI-style secret"),
112
+ (r"\bghp_[A-Za-z0-9]{20,}", "GitHub PAT"),
113
+ (r"\bAKIA[0-9A-Z]{16}\b", "AWS access key id"),
114
+ (r"-----BEGIN [A-Z ]*PRIVATE KEY-----", "private key"),
115
+ (r"(?i)\b(api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*[\"'][A-Za-z0-9_\-]{24,}[\"']",
116
+ "hardcoded credential"),
117
+ ]
118
+
119
+ ROUND_HEADINGS = ["## Gap", "## Change", "## Evidence", "## Metrics", "## Next"]
120
+ CONTRACT_FIELDS = ["Goal:", "Sources:", "Constraints:", "Done-when:", "Stop-if:"]
121
+ ROLE_FILES = {
122
+ "explorer_findings": ("## ", 300),
123
+ "reviewer_risks": ("## ", 300),
124
+ "worker_changes": ("## ", 300),
125
+ "evaluator_verification": ("## ", 300),
126
+ }
127
+ ARTIFACT_REF = re.compile(r"\b[\w./-]+\.(?:py|json|md|sh|swift|txt)\b")
128
+ EXIT_CODE_REF = re.compile(r"exit[=:\s]+\d|exit code", re.I)
129
+ FENCED_BLOCK = re.compile(r"```[^\n]*\n(.*?)```", re.S)
130
+
131
+
132
+ class Dim:
133
+ def __init__(self, ident, title, ok, detail, skip=False, reasons=None):
134
+ self.ident, self.title, self.ok, self.detail, self.skip = ident, title, ok, detail, skip
135
+ self.reasons = reasons or []
136
+
137
+
138
+ def _present(root, rel):
139
+ """Packaged gate scripts live in the installed tool; everything else lives in the judged project."""
140
+ if rel.startswith("loopgate/gates/"):
141
+ return os.path.exists(gate_file(os.path.basename(rel)[:-3]))
142
+ return os.path.exists(os.path.join(root, rel))
143
+
144
+
145
+ def read(path):
146
+ try:
147
+ with open(path, encoding="utf-8", errors="replace") as fh:
148
+ return fh.read()
149
+ except OSError:
150
+ return None
151
+
152
+
153
+ def walk_text(root, skip_paths):
154
+ for dirpath, dirnames, filenames in os.walk(root):
155
+ dirnames[:] = [d for d in dirnames
156
+ if d not in SKIP_DIRS and not d.startswith(".git")]
157
+ for name in filenames:
158
+ path = os.path.join(dirpath, name)
159
+ if os.path.realpath(path) in skip_paths:
160
+ continue
161
+ if os.path.splitext(name)[1].lower() in TEXT_EXT:
162
+ yield path
163
+
164
+
165
+ def cold_start(root):
166
+ """True when this tree declares a cold start and nothing contradicts it.
167
+
168
+ The declaration alone is not enough: if rounds or handoff artifacts exist, the tree has run the loop
169
+ and the state dimensions must be proven normally. That is what stops "cold" from becoming a way to
170
+ hide a broken loop behind an empty directory.
171
+ """
172
+ text = read(os.path.join(root, "artifacts", "loop_state.json"))
173
+ if text is None:
174
+ return False
175
+ try:
176
+ state = json.loads(text)
177
+ except json.JSONDecodeError:
178
+ return False
179
+ if state.get("state") != "cold":
180
+ return False
181
+ rounds = os.path.join(root, "artifacts", "rounds")
182
+ if os.path.isdir(rounds) and any(n.startswith("round_") for n in os.listdir(rounds)):
183
+ return False
184
+ handoff = os.path.join(root, "handoff")
185
+ if os.path.isdir(handoff):
186
+ for dirpath, _dirnames, filenames in os.walk(handoff):
187
+ if any(n.endswith(".md") for n in filenames):
188
+ return False
189
+ return True
190
+
191
+
192
+ def section_body(text, heading):
193
+ """Body of a '## X' section, up to the next '## ' heading."""
194
+ m = re.search(rf"^{re.escape(heading)}\s*$", text, re.M)
195
+ if not m:
196
+ return ""
197
+ rest = text[m.end():]
198
+ nxt = re.search(r"^##\s", rest, re.M)
199
+ return rest[:nxt.start()] if nxt else rest
200
+
201
+
202
+ # ---------------------------------------------------------------- dimensions
203
+
204
+ def dim_contract_skill(root):
205
+ rel = ".agents/skills/loop-engineering/SKILL.md"
206
+ text = read(os.path.join(root, rel))
207
+ if text is None:
208
+ return Dim("D01", "contract_skill", False, f"{rel} missing", reasons=[f"{rel} does not exist"])
209
+ reasons = []
210
+ for field in CONTRACT_FIELDS:
211
+ m = re.search(rf"^{re.escape(field)}[ \t]*(\S.*)$", text, re.M)
212
+ if not m:
213
+ reasons.append(f"{rel}: contract field {field!r} missing or empty (must start a line and carry a value)")
214
+ dw = re.search(r"^Done-when:[ \t]*(\S.*)$", text, re.M)
215
+ if dw and (GATE_SCRIPT not in dw.group(1) or MARKER_PASS not in dw.group(1)):
216
+ reasons.append(f"{rel}: 'Done-when' must name {GATE_SCRIPT} and the marker {MARKER_PASS}")
217
+ if "ywcrew" not in text:
218
+ reasons.append(f"{rel}: the CLI-agent rule (ywcrew-only) is not stated")
219
+ if MARKER_PASS not in text:
220
+ reasons.append(f"{rel}: {MARKER_PASS} not documented")
221
+ return Dim("D01", "contract_skill", not reasons,
222
+ f"{rel} contract complete" if not reasons else f"{rel} incomplete", reasons=reasons)
223
+
224
+
225
+ def dim_sources_of_truth(root):
226
+ reasons = []
227
+ agents = read(os.path.join(root, "AGENTS.md"))
228
+ if agents is None:
229
+ reasons.append("AGENTS.md missing")
230
+ else:
231
+ for needle in ("Sources of truth", "Done-when", "## Стоп-условия", GATE_SCRIPT):
232
+ if needle not in agents:
233
+ reasons.append(f"AGENTS.md missing {needle!r}")
234
+ plans = read(os.path.join(root, "PLANS.md"))
235
+ if plans is None:
236
+ reasons.append("PLANS.md missing")
237
+ elif GATE_CMD not in plans:
238
+ reasons.append(f"PLANS.md must contain the gate command {GATE_CMD!r}")
239
+ readme = read(os.path.join(root, "README.md"))
240
+ if readme is None:
241
+ reasons.append("README.md missing")
242
+ elif GATE_SCRIPT not in readme:
243
+ reasons.append(f"README.md must reference {GATE_SCRIPT!r}")
244
+ return Dim("D02", "sources_of_truth", not reasons,
245
+ "AGENTS.md + PLANS.md + README.md carry the contract and the gate command",
246
+ reasons=reasons)
247
+
248
+
249
+ def dim_loop_state(root):
250
+ rel = "artifacts/loop_state.json"
251
+ if cold_start(root):
252
+ return Dim("D03", "loop_state", False,
253
+ f"cold start: {rel} declares no round has run in this tree",
254
+ skip=True, reasons=["run the loop once (a round artifact plus a state update) and this "
255
+ "dimension becomes provable; a synthesised history is a fake"])
256
+ text = read(os.path.join(root, rel))
257
+ if text is None:
258
+ return Dim("D03", "loop_state", False, f"{rel} missing", reasons=[f"{rel} does not exist"])
259
+ reasons = []
260
+ try:
261
+ state = json.loads(text)
262
+ except json.JSONDecodeError as exc:
263
+ return Dim("D03", "loop_state", False, f"{rel} not valid JSON", reasons=[f"{rel}: {exc}"])
264
+ for key, lo, hi in (("readiness", 0, 100), ("evidence", 0, 100), ("risk", 0, 100)):
265
+ val = state.get(key)
266
+ if not isinstance(val, int) or not lo <= val <= hi:
267
+ reasons.append(f"{rel}: '{key}' must be an int in {lo}..{hi}, got {val!r}")
268
+ if not isinstance(state.get("round"), int) or state.get("round", 0) < 1:
269
+ reasons.append(f"{rel}: 'round' must be an int >= 1")
270
+ if not isinstance(state.get("blockers"), list):
271
+ reasons.append(f"{rel}: 'blockers' must be a list")
272
+ if state.get("marker") != MARKER_PASS:
273
+ reasons.append(f"{rel}: 'marker' must be {MARKER_PASS!r}")
274
+ if state.get("phase") not in {"build", "fail", "repair", "prove", "review", "handoff"}:
275
+ reasons.append(f"{rel}: 'phase' must be one of build/fail/repair/prove/review/handoff")
276
+ if not state.get("updated_at"):
277
+ reasons.append(f"{rel}: 'updated_at' missing")
278
+
279
+ # Metric provenance: the declared history must be monotone and must end at the current values.
280
+ hist = state.get("history")
281
+ if not isinstance(hist, list) or len(hist) < 2:
282
+ reasons.append(f"{rel}: 'history' must be a list with at least 2 entries (provenance of the metrics)")
283
+ else:
284
+ prev = None
285
+ for i, entry in enumerate(hist):
286
+ if not isinstance(entry, dict):
287
+ reasons.append(f"{rel}: history[{i}] must be an object")
288
+ continue
289
+ for key in ("round", "readiness", "evidence", "risk", "blockers"):
290
+ if not isinstance(entry.get(key), int) or isinstance(entry.get(key), bool):
291
+ reasons.append(f"{rel}: history[{i}]['{key}'] must be an int")
292
+ if prev is not None:
293
+ if entry.get("readiness", 0) < prev.get("readiness", 0):
294
+ reasons.append(f"{rel}: history[{i}] readiness decreased ({prev.get('readiness')} -> {entry.get('readiness')})")
295
+ if entry.get("evidence", 0) < prev.get("evidence", 0):
296
+ reasons.append(f"{rel}: history[{i}] evidence decreased ({prev.get('evidence')} -> {entry.get('evidence')})")
297
+ if entry.get("risk", 100) > prev.get("risk", 100):
298
+ reasons.append(f"{rel}: history[{i}] risk increased ({prev.get('risk')} -> {entry.get('risk')})")
299
+ if entry.get("blockers", 0) > prev.get("blockers", 0):
300
+ reasons.append(f"{rel}: history[{i}] blockers increased ({prev.get('blockers')} -> {entry.get('blockers')})")
301
+ prev = entry
302
+ last = hist[-1]
303
+ for key in ("round", "readiness", "evidence", "risk"):
304
+ if last.get(key) != state.get(key):
305
+ reasons.append(f"{rel}: history[-1]['{key}']={last.get(key)!r} does not match current {key}={state.get(key)!r}")
306
+ if last.get("blockers") != len(state.get("blockers") or []):
307
+ reasons.append(f"{rel}: history[-1]['blockers'] must equal len(current blockers)")
308
+ return Dim("D03", "loop_state", not reasons,
309
+ f"{rel} schema + monotone history ok" if not reasons else f"{rel} invalid", reasons=reasons)
310
+
311
+
312
+ def dim_rounds(root, min_rounds=3):
313
+ rdir = os.path.join(root, "artifacts", "rounds")
314
+ if cold_start(root):
315
+ return Dim("D04", "rounds", False, "cold start: no round artifact exists in this tree", skip=True,
316
+ reasons=["a round file records a real iteration; inventing one would be the exact "
317
+ "failure this gate exists to catch"])
318
+ names = sorted(n for n in (os.listdir(rdir) if os.path.isdir(rdir) else [])
319
+ if re.fullmatch(r"round_\d+\.md", n))
320
+ reasons = []
321
+ if len(names) < min_rounds:
322
+ reasons.append(f"artifacts/rounds/: {len(names)} round artifact(s), need >= {min_rounds}")
323
+ for name in names:
324
+ rel = f"artifacts/rounds/{name}"
325
+ text = read(os.path.join(rdir, name)) or ""
326
+ if len(text) < 200:
327
+ reasons.append(f"{rel}: too thin ({len(text)} chars)")
328
+ continue
329
+ for heading in ROUND_HEADINGS:
330
+ if heading not in text:
331
+ reasons.append(f"{rel}: missing {heading!r}")
332
+ blocks = FENCED_BLOCK.findall(text)
333
+ if not blocks:
334
+ reasons.append(f"{rel}: no fenced evidence block (a round must show a command or an artifact)")
335
+ body = section_body(text, "## Evidence")
336
+ if len(body.strip()) < 150:
337
+ reasons.append(f"{rel}: '## Evidence' body too thin ({len(body.strip())} chars)")
338
+ bound = bool(ARTIFACT_REF.search(body)) or bool(EXIT_CODE_REF.search(text)) \
339
+ or MARKER_PASS in text or MARKER_STOP in text or GATE_SCRIPT in text
340
+ if not bound:
341
+ reasons.append(f"{rel}: evidence not bound to a command, exit code, marker or artifact path")
342
+ return Dim("D04", "rounds", not reasons, f"{len(names)} round artifact(s) with bound evidence", reasons=reasons)
343
+
344
+
345
+ def _verdict_hash(payload, exclude=SELF_CERTIFYING_DIMS):
346
+ """Stable hash over a run's verdict set (statuses only, no timestamps or paths).
347
+
348
+ The self-certifying dimensions (``D05`` binds the handoff to the anchor, ``D12`` verifies its
349
+ signature) are excluded: the hash covers exactly the dimensions that read the tree, not the anchor.
350
+ """
351
+ stable = {
352
+ "marker": payload.get("marker"),
353
+ "dimensions": [{"id": d.get("id"), "status": d.get("status")}
354
+ for d in payload.get("dimensions", []) if d.get("id") not in exclude],
355
+ }
356
+ return hashlib.sha256(json.dumps(stable, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]
357
+
358
+
359
+ def dim_handoff(root, other_pairs=None):
360
+ """D05: bind the handoff to a gate run.
361
+
362
+ ``other_pairs`` maps the non-binding dimensions (D01-D04, D06-D09) to ``(ok, skip)`` as computed by
363
+ *this* run; comparing the raw state instead of the printed status keeps the check independent of
364
+ normal/strict mode. When every one of them is green here, the anchor must match them exactly — so an anchor
365
+ minted for a status set the machine is not actually in cannot help: a passing marker still requires
366
+ the run itself to be green. When another dimension is already failing, only the handoff's internal
367
+ binding is checked (the failure is reported by that dimension anyway).
368
+ """
369
+ if cold_start(root):
370
+ return Dim("D05", "handoff", False, "cold start: no handoff has been written in this tree", skip=True,
371
+ reasons=["a handoff binds role artifacts to a real gate run; with no run there is "
372
+ "nothing to bind"])
373
+ reasons = []
374
+ rel = "handoff/latest_loop.json"
375
+ text = read(os.path.join(root, rel))
376
+ payload = None
377
+ if text is None:
378
+ reasons.append(f"{rel} missing")
379
+ else:
380
+ try:
381
+ payload = json.loads(text)
382
+ except json.JSONDecodeError as exc:
383
+ reasons.append(f"{rel}: {exc}")
384
+ if payload is not None:
385
+ if payload.get("status") != "pass":
386
+ reasons.append(f"{rel}: 'status' must be 'pass'")
387
+ if payload.get("marker") != MARKER_PASS:
388
+ reasons.append(f"{rel}: 'marker' must be {MARKER_PASS!r}")
389
+ for key in ("gate_command", "generated_at"):
390
+ if not payload.get(key):
391
+ reasons.append(f"{rel}: '{key}' missing")
392
+ # A passing handoff must not carry open blockers; accepted limits go to 'deferrals'.
393
+ blockers = payload.get("blockers")
394
+ if not isinstance(blockers, list):
395
+ reasons.append(f"{rel}: 'blockers' must be a list (use 'deferrals' for accepted limits)")
396
+ elif blockers:
397
+ reasons.append(f"{rel}: 'blockers' is not empty while status=pass -> {blockers}")
398
+ roles = payload.get("roles_run")
399
+ if not isinstance(roles, list) or not roles:
400
+ reasons.append(f"{rel}: 'roles_run' must list the role runs behind this handoff")
401
+ reviewed = payload.get("reviewed_by")
402
+ if not isinstance(reviewed, list) or not reviewed:
403
+ reasons.append(f"{rel}: 'reviewed_by' must name the independent reviewer(s)")
404
+ fp = payload.get("gate_report_verdict_hash")
405
+ if not fp:
406
+ reasons.append(f"{rel}: 'gate_report_verdict_hash' missing (binds the handoff to a gate run)")
407
+ arts = payload.get("artifacts")
408
+ if not isinstance(arts, list) or not arts:
409
+ reasons.append(f"{rel}: 'artifacts' must be a non-empty list")
410
+ else:
411
+ for a in arts:
412
+ if not os.path.exists(os.path.join(root, a)):
413
+ reasons.append(f"{rel}: listed artifact {a!r} does not exist")
414
+ # Bind the handoff to the gate's own machine transcript, not to hand-written prose. The anchor is
415
+ # written whenever every dimension except D05 is green, so the binding dimension never certifies
416
+ # itself; a handoff that quotes the wrong hash describes a different gate run and fails.
417
+ anchor_rel = PASS_REL
418
+ anchor_text = read(os.path.join(root, anchor_rel))
419
+ if anchor_text is None:
420
+ reasons.append(f"{anchor_rel} missing (no run has been green in every other dimension yet)")
421
+ else:
422
+ try:
423
+ anchor = json.loads(anchor_text)
424
+ except json.JSONDecodeError as exc:
425
+ anchor = None
426
+ reasons.append(f"{anchor_rel}: {exc}")
427
+ if anchor is not None:
428
+ if anchor.get("all_other_dimensions_ok") is not True:
429
+ reasons.append(f"{anchor_rel}: all_other_dimensions_ok is not true")
430
+ anchor_statuses = {d.get("id"): d.get("status") for d in anchor.get("dimensions", [])}
431
+ anchor_pairs = {d.get("id"): (bool(d.get("ok")), bool(d.get("skip")))
432
+ for d in anchor.get("dimensions", [])
433
+ if d.get("id") not in SELF_CERTIFYING_DIMS}
434
+ record = {"marker": MARKER_PASS,
435
+ "dimensions": [{"id": k, "status": v} for k, v in anchor_statuses.items()]}
436
+ actual = _verdict_hash(record)
437
+ if anchor.get("verdict_hash") != actual:
438
+ reasons.append(f"{anchor_rel}: recorded verdict hash {anchor.get('verdict_hash')} "
439
+ f"does not match its own contents {actual}")
440
+ if other_pairs is not None:
441
+ expected = {k: v for k, v in other_pairs.items() if k not in SELF_CERTIFYING_DIMS}
442
+ if all(ok for ok, _ in expected.values()):
443
+ # The machine is green right now: the anchor must describe exactly this run's state,
444
+ # otherwise it was written for a different (or invented) one.
445
+ if anchor_pairs != expected:
446
+ reasons.append(f"{anchor_rel}: recorded state {anchor_pairs} != this run's "
447
+ f"{expected} — the anchor does not describe this run")
448
+ if payload is not None and payload.get("gate_report_verdict_hash"):
449
+ if payload["gate_report_verdict_hash"] != actual:
450
+ reasons.append(f"{anchor_rel}: verdict hash {actual} != handoff "
451
+ f"gate_report_verdict_hash {payload['gate_report_verdict_hash']} "
452
+ f"(the handoff describes a different gate run)")
453
+ syn = read(os.path.join(root, "handoff", "supervisor_synthesis.md"))
454
+ if syn is None:
455
+ reasons.append("handoff/supervisor_synthesis.md missing")
456
+ else:
457
+ if MARKER_PASS not in syn:
458
+ reasons.append(f"handoff/supervisor_synthesis.md missing {MARKER_PASS}")
459
+ if GATE_SCRIPT not in syn:
460
+ reasons.append(f"handoff/supervisor_synthesis.md must name the gate command")
461
+ return Dim("D05", "handoff", not reasons,
462
+ "handoff bound to the gate's machine report" if not reasons else "handoff invalid", reasons=reasons)
463
+
464
+
465
+ def _scan_lines(path):
466
+ """Lines to inspect for direct CLI calls, as ``(lineno, line, strict)``.
467
+
468
+ Code files are scanned whole (``strict``). Markdown is scanned for invocation-shaped lines only:
469
+ a line that *starts* with an agent binary (optionally after a `$`/`>` prompt char) is a pasteable
470
+ command, while transcripts and reports that merely quote a finding (round artifacts, gate output)
471
+ are evidence and must stay verbatim. Fenced blocks tagged as a shell language are scanned strictly,
472
+ because that is where a reader would copy-paste from.
473
+ """
474
+ text = read(path)
475
+ if text is None:
476
+ return []
477
+ if os.path.splitext(path)[1].lower() != ".md":
478
+ return [(i, line, True) for i, line in enumerate(text.splitlines(), 1)]
479
+ fence_lang, out = "", []
480
+ for lineno, line in enumerate(text.splitlines(), 1):
481
+ stripped = line.lstrip()
482
+ if stripped.startswith("```"):
483
+ if fence_lang:
484
+ fence_lang = ""
485
+ else:
486
+ fence_lang = stripped[3:].strip().lower() or "text"
487
+ continue
488
+ out.append((lineno, line, fence_lang in SHELL_LANGS))
489
+ return out
490
+
491
+
492
+ def dim_no_direct_cli(root, skip_paths):
493
+ hits = []
494
+ for path in walk_text(root, skip_paths):
495
+ for lineno, line, strict in _scan_lines(path):
496
+ if not strict:
497
+ if not INVOCATION_AT_LINE_START.search(line):
498
+ continue
499
+ hits.append(f"{os.path.relpath(path, root)}:{lineno}: CLI agent invoked at line start")
500
+ continue
501
+ for pattern, label in DIRECT_CLI_PATTERNS:
502
+ if re.search(pattern, line):
503
+ hits.append(f"{os.path.relpath(path, root)}:{lineno}: {label}")
504
+ return Dim("D06", "guardrail_no_direct_cli", not hits,
505
+ "no direct CLI-agent invocation in repo code or pasteable commands" if not hits
506
+ else f"{len(hits)} hit(s)",
507
+ reasons=hits)
508
+
509
+
510
+ def dim_no_secrets(root, skip_paths):
511
+ hits = []
512
+ for path in walk_text(root, skip_paths):
513
+ text = read(path)
514
+ if not text:
515
+ continue
516
+ for lineno, line in enumerate(text.splitlines(), 1):
517
+ for pattern, label in SECRET_PATTERNS:
518
+ if re.search(pattern, line):
519
+ hits.append(f"{os.path.relpath(path, root)}:{lineno}: {label}")
520
+ return Dim("D07", "guardrail_no_secrets", not hits,
521
+ "no credential-shaped strings in repo files" if not hits else f"{len(hits)} hit(s)",
522
+ reasons=hits)
523
+
524
+
525
+ def dim_course_corpus(root, min_lessons=17, min_words=40000, min_prompt_bytes=15000):
526
+ cdir = os.path.join(root, "course")
527
+ # The course corpus is study material, not part of the tool: a tree that does not ship it is not
528
+ # broken, and demanding it would make the gate pass only on its author's machine. A tree that ships a
529
+ # partial corpus is broken, and must fail — an absent input SKIPs with a reason, a thin one FAILs.
530
+ if not os.path.isdir(cdir):
531
+ return Dim("D08", "course_corpus", True, "corpus not shipped in this tree",
532
+ skip=True, reasons=["course/ absent: optional study material, not part of the tool"])
533
+ reasons = []
534
+ for rel in ("course/README.md", "course/00_mechanics_map.md"):
535
+ if not os.path.exists(os.path.join(root, rel)):
536
+ reasons.append(f"{rel} missing")
537
+ lessons, words, pbytes = [], 0, 0
538
+ if os.path.isdir(cdir):
539
+ for sect in sorted(os.listdir(cdir)):
540
+ spath = os.path.join(cdir, sect)
541
+ if not os.path.isdir(spath) or sect == "skill_packages":
542
+ continue
543
+ for les in sorted(os.listdir(spath)):
544
+ lpath = os.path.join(spath, les)
545
+ if not os.path.isdir(lpath):
546
+ continue
547
+ ptext = read(os.path.join(lpath, "prompts.md"))
548
+ stext = read(os.path.join(lpath, "slides_ocr.md"))
549
+ if ptext is None or stext is None:
550
+ reasons.append(f"course/{sect}/{les}: prompts.md or slides_ocr.md missing")
551
+ continue
552
+ if len(ptext) < 200 or len(stext) < 200:
553
+ reasons.append(f"course/{sect}/{les}: lesson file too thin")
554
+ lessons.append(les)
555
+ pbytes += len(ptext.encode("utf-8"))
556
+ words += len(stext.split())
557
+ if len(lessons) < min_lessons:
558
+ reasons.append(f"course corpus: {len(lessons)} lessons, need >= {min_lessons}")
559
+ if words < min_words:
560
+ reasons.append(f"course corpus: {words} OCR words, need >= {min_words}")
561
+ if pbytes < min_prompt_bytes:
562
+ reasons.append(f"course corpus: {pbytes} prompt bytes, need >= {min_prompt_bytes}")
563
+ detail = f"{len(lessons)} lessons, {words} OCR words, {pbytes} prompt bytes"
564
+ return Dim("D08", "course_corpus", not reasons, detail, reasons=reasons)
565
+
566
+
567
+ def dim_roles_fanout(root):
568
+ if cold_start(root):
569
+ return Dim("D09", "roles_fanout", False, "cold start: no role artifact exists in this tree", skip=True,
570
+ reasons=["role files are written by a real fan-out against a real task"])
571
+ missing, thin = [], []
572
+ for name, (_, min_chars) in ROLE_FILES.items():
573
+ rel = f"handoff/agents/{name}.md"
574
+ text = read(os.path.join(root, rel))
575
+ if text is None:
576
+ missing.append(rel)
577
+ continue
578
+ if len(text) < min_chars:
579
+ thin.append(f"{rel}: {len(text)} chars, need >= {min_chars}")
580
+ elif "runId:" not in text:
581
+ thin.append(f"{rel}: no 'runId:' attribution to the role run")
582
+ if missing:
583
+ return Dim("D09", "roles_fanout", True, "stage 2 (ywcrew roles) not run yet",
584
+ skip=True, reasons=[f"missing {m}" for m in missing] + thin)
585
+ if thin:
586
+ return Dim("D09", "roles_fanout", False, "role artifacts present but not substantive", reasons=thin)
587
+ return Dim("D09", "roles_fanout", True, "four role artifacts present with run attribution")
588
+
589
+
590
+ def dim_issue_bridge(root):
591
+ """D10: the issue queue (stage 6) must be wired *and* self-checking.
592
+
593
+ Three things are asserted, in the order they can break:
594
+ 1. the machinery exists (bridge script, brief renderer, repository profiles);
595
+ 2. every profile declares an evidence bundle of at least two commands, and the brief rendered from
596
+ it carries those commands plus the evidence rules verbatim — this is the mechanical answer to the
597
+ live PR failure, where the rule lived in prose and the worker proved one gate out of three;
598
+ 3. the validator is not vacuous: it must accept a complete transcript and reject one that skips a
599
+ command or reports a non-zero exit code. A validator that only ever passes would make the whole
600
+ dimension decoration, so it is attacked here with the two failure modes that actually happened.
601
+ """
602
+ reasons = []
603
+ for rel in (BRIDGE_SCRIPT, BRIEF_SCRIPT, PROFILES_REL):
604
+ if not _present(root, rel):
605
+ reasons.append(f"{rel} missing")
606
+ if reasons:
607
+ return Dim("D10", "issue_bridge", False, "issue bridge incomplete", reasons=reasons)
608
+
609
+ brief_path = gate_file("worker_brief")
610
+ try:
611
+ import importlib.util
612
+ spec = importlib.util.spec_from_file_location("loop_worker_brief", brief_path)
613
+ if spec is None or spec.loader is None:
614
+ raise ImportError(f"cannot load a module spec from {brief_path}")
615
+ worker_brief = importlib.util.module_from_spec(spec)
616
+ spec.loader.exec_module(worker_brief)
617
+ except Exception as exc: # noqa: BLE001 - any import failure is a failing dimension, not a crash
618
+ return Dim("D10", "issue_bridge", False, f"{BRIEF_SCRIPT} not importable",
619
+ reasons=[f"{BRIEF_SCRIPT}: {exc.__class__.__name__}: {exc}"])
620
+
621
+ try:
622
+ profiles = worker_brief.load_profiles(root)
623
+ except (OSError, KeyError, json.JSONDecodeError) as exc:
624
+ return Dim("D10", "issue_bridge", False, f"{PROFILES_REL} unreadable",
625
+ reasons=[f"{PROFILES_REL}: {exc}"])
626
+ if not profiles:
627
+ reasons.append(f"{PROFILES_REL}: no repository profile declared")
628
+
629
+ sample = {"number": 1, "title": "sample", "body": "sample body",
630
+ "url": "https://example.invalid/issues/1", "labels": []}
631
+ checked = 0
632
+ ready_bundle = None
633
+ attacks_run = 0
634
+ for name, profile in sorted(profiles.items()):
635
+ bundle = profile.get("evidence_bundle") or []
636
+ if len(bundle) >= 2 and all(i.get("command") and i.get("source") for i in bundle) \
637
+ and ready_bundle is None:
638
+ ready_bundle = profile
639
+ if len(bundle) < 2:
640
+ reasons.append(f"{name}: evidence_bundle needs >= 2 commands (a subset is exactly what the "
641
+ "first real PR was rejected for), got {len(bundle)}")
642
+ for item in bundle:
643
+ if not item.get("command") or not item.get("source"):
644
+ reasons.append(f"{name}: evidence_bundle entry needs both 'command' and 'source': {item!r}")
645
+ rules = " ".join(profile.get("pr_rules") or []).lower()
646
+ if "one task = one pr" not in rules:
647
+ reasons.append(f"{name}: pr_rules must state the one-task-one-PR rule")
648
+ if not (profile.get("kanban") or {}).get("assignee"):
649
+ reasons.append(f"{name}: kanban.assignee missing (the queue would have no executor)")
650
+ if not profile.get("bundle_source"):
651
+ reasons.append(f"{name}: 'bundle_source' missing: the bundle needs a named origin, otherwise "
652
+ "the list is an unverifiable assertion about the repository")
653
+ brief = worker_brief.render_brief(name, profile, sample)
654
+ for item in bundle:
655
+ if item.get("command") and item["command"] not in brief:
656
+ reasons.append(f"{name}: brief does not name the mandatory command `{item['command']}`")
657
+ low = brief.lower()
658
+ for needle in BRIEF_REQUIREMENTS:
659
+ if needle.lower() not in low:
660
+ reasons.append(f"{name}: rendered brief is missing the evidence rule text {needle!r}")
661
+ if brief != worker_brief.render_brief(name, profile, sample):
662
+ reasons.append(f"{name}: brief rendering is not deterministic")
663
+ # The same rule must travel with a ywcrew dispatch, not only with the kanban task body.
664
+ spec = worker_brief.render_ywcrew_task(name, profile, sample)
665
+ constraints = (spec.get("task") or {}).get("constraints", "")
666
+ for item in bundle:
667
+ if item.get("command") and item["command"] not in constraints:
668
+ reasons.append(f"{name}: ywcrew task constraints omit the mandatory command "
669
+ f"`{item['command']}`")
670
+ for needle in ("verbatim", "exit", "NOT RUN"):
671
+ if needle.lower() not in constraints.lower():
672
+ reasons.append(f"{name}: ywcrew task constraints are missing the evidence rule text "
673
+ f"{needle!r}")
674
+ if spec.get("task", {}).get("briefing") != brief:
675
+ reasons.append(f"{name}: ywcrew task briefing differs from the brief (two sources for one rule)")
676
+ checked += 1
677
+
678
+ if checked and ready_bundle:
679
+ profile = ready_bundle
680
+ bundle = [i["command"] for i in (profile.get("evidence_bundle") or [])]
681
+ complete = "\n".join(f"$ {c}\noutput\n" + "exit=0\n" for c in bundle)
682
+ partial = f"$ {bundle[0]}\noutput\nexit=0\n"
683
+ failing = f"$ {bundle[0]}\noutput\nexit=1\n"
684
+ # Borrowed exit code: every command is listed, one trailing exit=0 is the only code in the
685
+ # transcript. The first live review used exactly this to reject the whole design.
686
+ borrowed = "\n".join(f"$ {c}\noutput\n" for c in bundle) + "exit=0\n"
687
+ # Hidden rerun: the first occurrence of a command passed, a later rerun of the same command
688
+ # failed. Reviewing only the first occurrence would report a clean bundle (second live review).
689
+ hidden_rerun = (f"$ {bundle[0]}\noutput\nexit=0\n\n$ {bundle[0]}\nboom\nexit=1\n"
690
+ + "\n".join(f"$ {c}\noutput\nexit=0\n" for c in bundle[1:]))
691
+ # Runaway repair: the same command red three times in a row. "Repair is bounded" is a rule in
692
+ # the brief; a validator that accepts this transcript makes the rule decorative.
693
+ runaway = ("\n".join(f"$ {bundle[0]}\nboom\nexit=1\n" for _ in range(3))
694
+ + "\n".join(f"$ {c}\noutput\nexit=0\n" for c in bundle[1:]))
695
+ attacks = [("complete transcript rejected", complete, True),
696
+ ("incomplete transcript accepted", partial, False),
697
+ ("failing exit code accepted", failing, False),
698
+ ("empty transcript accepted", "", False),
699
+ ("borrowed exit code accepted", borrowed, False),
700
+ ("hidden failing rerun accepted", hidden_rerun, False),
701
+ ("runaway repair accepted", runaway, False)]
702
+ for label, text, want_ok in attacks:
703
+ got_ok = not worker_brief.verify_evidence(profile, text)
704
+ attacks_run += 1
705
+ if got_ok != want_ok:
706
+ reasons.append(f"evidence validator is vacuous: {label} ({BRIEF_SCRIPT}.verify_evidence)")
707
+
708
+ detail = (f"{len(profiles)} repo profile(s), bundle rules in the brief, validator attacked with "
709
+ f"{attacks_run} falsification cases")
710
+ return Dim("D10", "issue_bridge", not reasons, detail if not reasons else "issue bridge invalid",
711
+ reasons=reasons)
712
+
713
+
714
+ def _good_observation(scenario):
715
+ """An observation that satisfies the scenario, derived from its own expectations.
716
+
717
+ Building the "good" case from the config (instead of hardcoding one) is what keeps the attack below
718
+ from going stale when scenarios change.
719
+ """
720
+ exp = scenario.get("expect") or {}
721
+ obs = {}
722
+ if "status" in exp:
723
+ obs["status"] = exp["status"]
724
+ if exp.get("json_ok_true"):
725
+ obs["body"] = '{"ok":true,"data":true}'
726
+ if "body_contains" in exp:
727
+ obs["body"] = f"prefix {exp['body_contains']} suffix"
728
+ if "exit_code" in exp:
729
+ obs["exit_code"] = exp["exit_code"]
730
+ if "max_shutdown_ms" in exp:
731
+ obs["shutdown_ms"] = max(1, exp["max_shutdown_ms"] - 1)
732
+ if scenario.get("kind") == "startup_failure":
733
+ obs["exit_code"] = 1
734
+ obs["stderr"] = f"startup failed: {exp.get('stderr_contains', '')}"
735
+ if scenario.get("kind") == "json_schema":
736
+ obs = {"valid_failures": [], "invalid_accepted": []}
737
+ return obs
738
+
739
+
740
+ def dim_product_gate(root):
741
+ """D11: the product gate (stage 7) must exist, be user-facing, and be able to fail.
742
+
743
+ The technical gate cannot answer "can a user do their job with this product", so stage 7 adds a second
744
+ verdict with its own owner. This dimension keeps that second gate honest: the scenarios must state what
745
+ a user observes and why it matters, and the evaluator must reject falsified observations — including
746
+ the ones produced by mutating each scenario's own expectations, so the attack cannot go stale.
747
+ """
748
+ reasons = []
749
+ for rel in (PRODUCT_SCRIPT, PRODUCT_SCENARIOS_REL):
750
+ if not _present(root, rel):
751
+ reasons.append(f"{rel} missing")
752
+ if reasons:
753
+ return Dim("D11", "product_gate", False, "product gate incomplete", reasons=reasons)
754
+
755
+ try:
756
+ import importlib.util
757
+ spec = importlib.util.spec_from_file_location("loop_check_product",
758
+ gate_file("check_product"))
759
+ if spec is None or spec.loader is None:
760
+ raise ImportError("cannot load a module spec for the product gate")
761
+ product = importlib.util.module_from_spec(spec)
762
+ spec.loader.exec_module(product)
763
+ config = product.load_scenarios(root)
764
+ except Exception as exc: # noqa: BLE001 - any failure is a failing dimension, not a crash
765
+ return Dim("D11", "product_gate", False, f"{PRODUCT_SCRIPT} not loadable",
766
+ reasons=[f"{PRODUCT_SCRIPT}: {exc.__class__.__name__}: {exc}"])
767
+
768
+ scenarios = config.get("scenarios") or []
769
+ mandatory = [s for s in scenarios if not s.get("optional")]
770
+ if len(mandatory) < 6:
771
+ reasons.append(f"{PRODUCT_SCENARIOS_REL}: {len(mandatory)} mandatory scenarios, need >= 6 "
772
+ "(a product gate with a handful of checks is a smoke test, not acceptance)")
773
+ ids = [s.get("id") for s in scenarios]
774
+ if len(ids) != len(set(ids)):
775
+ reasons.append(f"{PRODUCT_SCENARIOS_REL}: duplicate scenario ids in {ids}")
776
+ for sc in scenarios:
777
+ for field in ("id", "title", "kind", "expect"):
778
+ if not sc.get(field):
779
+ reasons.append(f"{PRODUCT_SCENARIOS_REL}: scenario {sc.get('id', '?')} lacks {field!r}")
780
+ why = (sc.get("why") or "").strip()
781
+ if len(why) < 25:
782
+ reasons.append(f"{PRODUCT_SCENARIOS_REL}: scenario {sc.get('id', '?')} has no user-facing "
783
+ "'why' (the consequence a user would feel), got {why!r}")
784
+ profile_name = config.get("profile")
785
+ try:
786
+ with open(os.path.join(root, PROFILES_REL), encoding="utf-8") as fh:
787
+ profiles = json.load(fh)["profiles"]
788
+ except (OSError, KeyError, json.JSONDecodeError) as exc:
789
+ profiles = {}
790
+ reasons.append(f"{PROFILES_REL}: {exc}")
791
+ if profile_name not in profiles:
792
+ reasons.append(f"{PRODUCT_SCENARIOS_REL}: profile {profile_name!r} is not declared in {PROFILES_REL}")
793
+ elif not (profiles[profile_name].get("local_path") or profiles[profile_name].get("local_path_env")):
794
+ reasons.append(f"{PROFILES_REL}: profile {profile_name!r} declares neither local_path nor "
795
+ "local_path_env, so the product gate cannot find the product it is supposed to "
796
+ "accept")
797
+
798
+ # Attack the evaluator: every scenario must reject its own falsified observations.
799
+ falsifications, accepted_good = 0, 0
800
+ for sc in scenarios:
801
+ good = _good_observation(sc)
802
+ if good and not product.evaluate(sc, good):
803
+ accepted_good += 1
804
+ exp = sc.get("expect") or {}
805
+ attacks = []
806
+ if "status" in exp:
807
+ attacks.append({**good, "status": (exp["status"] or 0) + 1})
808
+ if exp.get("json_ok_true"):
809
+ attacks.append({**good, "body": '{"ok":false}'})
810
+ if "body_contains" in exp:
811
+ attacks.append({**good, "body": "no marker here"})
812
+ if "exit_code" in exp:
813
+ attacks.append({**good, "exit_code": exp["exit_code"] + 1})
814
+ if "max_shutdown_ms" in exp:
815
+ attacks.append({**good, "shutdown_ms": exp["max_shutdown_ms"] + 500})
816
+ if exp.get("exit_nonzero"):
817
+ attacks.append({**good, "exit_code": 0})
818
+ if exp.get("stderr_contains"):
819
+ attacks.append({**good, "stderr": "something went wrong"})
820
+ if sc.get("kind") == "json_schema":
821
+ attacks.append({"valid_failures": ["fixture.json"], "invalid_accepted": []})
822
+ attacks.append({"valid_failures": [], "invalid_accepted": ["fixture.json"]})
823
+ for attack in attacks:
824
+ if not product.evaluate(sc, attack):
825
+ reasons.append(f"{PRODUCT_SCENARIOS_REL}: {sc['id']} accepts a falsified observation "
826
+ f"{ {k: v for k, v in attack.items() if good.get(k) != v} } — the check is "
827
+ "vacuous")
828
+ else:
829
+ falsifications += 1
830
+ if falsifications < 4:
831
+ reasons.append(f"the product evaluator resisted only {falsifications} falsification(s); need >= 4")
832
+ if accepted_good == 0:
833
+ reasons.append("no scenario has a satisfiable observation — the evaluator rejects everything, "
834
+ "which is as useless as accepting everything")
835
+
836
+ detail = (f"{len(scenarios)} scenario(s) ({len(mandatory)} mandatory), {falsifications} falsifications "
837
+ f"rejected, {accepted_good} satisfiable")
838
+ return Dim("D11", "product_gate", not reasons, detail if not reasons else "product gate invalid",
839
+ reasons=reasons)
840
+
841
+
842
+ def dim_anchor_witness(root):
843
+ """D12: the anchor must be signed, and a rewrite must be detectable.
844
+
845
+ The technical gate's own residual risk was that every input it reads is authored on this machine: a
846
+ fabricated anchor was detectable by a command but not impossible. Signing the anchor with a
847
+ purpose-limited key (public half committed in ``configs/allowed_signers``) closes the silent-rewrite
848
+ path; the external witness (a published digest, checked by ``check-external``) is the operator step
849
+ that makes a coordinated rewrite visible, and it is deliberately not required here — this gate stays
850
+ local and offline.
851
+ """
852
+ # A tree with no trusted public key can never verify a signed verdict, and that is true whether or not
853
+ # this tree has ever run the loop. Checked before the cold short-circuit on purpose: ``allowed_signers``
854
+ # is a tracked configuration file, not loop state, so an emptied signer list must redden here instead of
855
+ # hiding behind "history unprovable" — otherwise the regression suite cannot observe that attack in a
856
+ # cold tree and skips it.
857
+ trusted = []
858
+ signers_path = os.path.join(root, ALLOWED_SIGNERS_REL)
859
+ if os.path.exists(signers_path):
860
+ try:
861
+ with open(signers_path, encoding="utf-8") as fh:
862
+ trusted = [line for line in fh
863
+ if line.strip() and not line.lstrip().startswith("#")]
864
+ except OSError:
865
+ trusted = []
866
+ if not trusted:
867
+ return Dim("D12", "anchor_witness", False, "no trusted signer",
868
+ reasons=[f"{ALLOWED_SIGNERS_REL}: no trusted public key — with an empty signer list "
869
+ "nothing can be verified, so nothing may be accepted"])
870
+
871
+ missing_state = [rel for rel in (ANCHOR_SIG_REL, WITNESS_RECORD_REL)
872
+ if not os.path.exists(os.path.join(root, rel))]
873
+ if cold_start(root) and missing_state:
874
+ return Dim("D12", "anchor_witness", False,
875
+ "cold start: no signed verdict exists in this tree", skip=True,
876
+ reasons=[f"{rel} is produced by a real gate run, not by the export" for rel in missing_state])
877
+ reasons = []
878
+ for rel in (WITNESS_SCRIPT, ALLOWED_SIGNERS_REL, WITNESS_CONFIG_REL, ANCHOR_SIG_REL, WITNESS_RECORD_REL):
879
+ if not _present(root, rel):
880
+ reasons.append(f"{rel} missing")
881
+ if reasons:
882
+ return Dim("D12", "anchor_witness", False, "anchor witness incomplete", reasons=reasons)
883
+
884
+ try:
885
+ import importlib.util
886
+ spec = importlib.util.spec_from_file_location("loop_anchor_witness",
887
+ gate_file("anchor_witness"))
888
+ if spec is None or spec.loader is None:
889
+ raise ImportError("cannot load a module spec for the anchor witness")
890
+ witness = importlib.util.module_from_spec(spec)
891
+ spec.loader.exec_module(witness)
892
+ config = witness.load_config(root)
893
+ except Exception as exc: # noqa: BLE001
894
+ return Dim("D12", "anchor_witness", False, f"{WITNESS_SCRIPT} not loadable",
895
+ reasons=[f"{WITNESS_SCRIPT}: {exc.__class__.__name__}: {exc}"])
896
+
897
+ destination = config.get("destination") or {}
898
+ if not destination.get("repo") or not destination.get("issue"):
899
+ reasons.append(f"{WITNESS_CONFIG_REL}: no external destination declared, so the anchor has no "
900
+ "witness outside this machine")
901
+ if not config.get("marker"):
902
+ reasons.append(f"{WITNESS_CONFIG_REL}: no marker, so a published comment cannot be attributed")
903
+
904
+ ok, note = witness.verify(root, config)
905
+ if not ok:
906
+ reasons.append(f"the current anchor does not verify against {ALLOWED_SIGNERS_REL}: {note}")
907
+
908
+ record_path = os.path.join(root, WITNESS_RECORD_REL)
909
+ try:
910
+ record = json.load(open(record_path, encoding="utf-8"))
911
+ except (OSError, json.JSONDecodeError) as exc:
912
+ record = {}
913
+ reasons.append(f"{WITNESS_RECORD_REL}: {exc}")
914
+ if record:
915
+ current = json.load(open(os.path.join(root, PASS_REL), encoding="utf-8")).get("verdict_hash")
916
+ if record.get("verdict_hash") != current:
917
+ reasons.append(f"{WITNESS_RECORD_REL}: the witness signs verdict "
918
+ f"{str(record.get('verdict_hash'))} but the anchor now carries {current} — "
919
+ "the witness describes a different run (re-sign it)")
920
+
921
+ # Falsification: a rewritten anchor must stop verifying. The probe is written outside the repository,
922
+ # because the gate is read-only with respect to the tree (only its own transcripts may be written).
923
+ import tempfile
924
+ probe_dir = tempfile.mkdtemp(prefix="loop_witness_probe_")
925
+ tampered = os.path.join(probe_dir, "tampered_anchor.json")
926
+ try:
927
+ with open(os.path.join(root, PASS_REL), encoding="utf-8") as fh:
928
+ payload = json.load(fh)
929
+ payload["verdict_hash"] = "0000000000000000"
930
+ with open(tampered, "w", encoding="utf-8") as fh:
931
+ json.dump(payload, fh, indent=2)
932
+ forged_ok, _ = witness.verify(root, config, anchor_path=tampered)
933
+ if forged_ok:
934
+ reasons.append("a rewritten anchor still verifies: the signature does not bind the anchor's "
935
+ "contents")
936
+ finally:
937
+ shutil.rmtree(probe_dir, ignore_errors=True)
938
+
939
+ detail = (f"anchor signed and verified against {ALLOWED_SIGNERS_REL}; rewritten anchor rejected; "
940
+ f"external witness {destination.get('repo')}#{destination.get('issue')}")
941
+ return Dim("D12", "anchor_witness", not reasons, detail if not reasons else "anchor witness invalid",
942
+ reasons=reasons)
943
+
944
+
945
+ def _good_visual_observation(scenario):
946
+ exp = scenario.get("expect") or {}
947
+ dom = "".join(exp.get("require_text") or []) + ("x" * int(exp.get("min_bytes") or 0))
948
+ # The screenshot is a real file that exists in any install: the gate itself. D13 requires os.path.exists().
949
+ return {"dom": dom, "screenshot": os.path.realpath(__file__),
950
+ "screenshot_bytes": int(exp.get("min_bytes") or 30000) + 1}
951
+
952
+
953
+ def dim_change_health(root):
954
+ """D14: the third verdict — "did it get worse" — exists, is measured, and is not vacuous.
955
+
956
+ `check_loop` answers "is the loop disciplined", `check_product` "can a user work with this". Neither
957
+ notices a tree that stays green while quietly losing dimensions, falsification cases, or gaining
958
+ unchecked code. This dimension does not score quality: it requires that countable metrics are compared
959
+ against a recorded baseline, that a regression is refused, and — the part that keeps it honest — that a
960
+ genuinely worse pair IS refused and an improvement is NOT. A comparator that never reddens is a slogan.
961
+ """
962
+ script_rel, policy_rel, baseline_rel = (HEALTH_SCRIPT, HEALTH_POLICY_REL, HEALTH_BASELINE_REL)
963
+ missing = [rel for rel in (script_rel, policy_rel, baseline_rel)
964
+ if not _present(root, rel)]
965
+ if missing:
966
+ return Dim("D14", "change_health", False, "third verdict missing", reasons=[f"{rel} missing" for rel in missing])
967
+
968
+ try:
969
+ import importlib.util
970
+ spec = importlib.util.spec_from_file_location("loop_change_health", gate_file("change_health"))
971
+ if spec is None or spec.loader is None:
972
+ raise ImportError("cannot load a module spec for change_health")
973
+ health = importlib.util.module_from_spec(spec)
974
+ spec.loader.exec_module(health)
975
+ policy = health.load_policy(root)
976
+ head = health.collect(root)
977
+ baseline_raw = read(os.path.join(root, baseline_rel)) or ""
978
+ payload = json.loads(baseline_raw)
979
+ except Exception as exc: # noqa: BLE001
980
+ return Dim("D14", "change_health", False, f"{script_rel} not loadable",
981
+ reasons=[f"{script_rel}: {exc.__class__.__name__}: {exc}"])
982
+
983
+ reasons: list[str] = []
984
+ if payload.get("schema") != health.SCHEMA_VERSION:
985
+ reasons.append(f"{baseline_rel}: schema {payload.get('schema')!r} != {health.SCHEMA_VERSION}")
986
+ baseline = payload.get("metrics") or {}
987
+ if not baseline:
988
+ reasons.append(f"{baseline_rel}: no metrics recorded")
989
+ if not (policy.get("metrics") or {}):
990
+ reasons.append(f"{policy_rel}: no metric policies — nothing would ever be judged")
991
+
992
+ for row in health.judge(baseline, head, policy):
993
+ if row["regressed"]:
994
+ reasons.append(f"{row['metric']}: {row['base']} -> {row['head']} is worse than the recorded "
995
+ f"baseline ({row['why']})")
996
+
997
+ # Falsification: the comparator must redden on a worse pair, stay quiet on a better one, and never
998
+ # invent a judgement for a metric it cannot see.
999
+ attacks = [
1000
+ ("a lost gate dimension was accepted", {"gate_dims": 14}, {"gate_dims": 13},
1001
+ {"gate_dims": {"kind": "must_not_decrease", "why": ""}}, False),
1002
+ ("code growth past tolerance was accepted", {"python_lines": 1000}, {"python_lines": 1300},
1003
+ {"python_lines": {"kind": "max_growth_pct", "tolerance": 20, "why": ""}}, False),
1004
+ ("a new TODO past tolerance was accepted", {"todo_markers": 3}, {"todo_markers": 9},
1005
+ {"todo_markers": {"kind": "must_not_increase", "tolerance": 3, "why": ""}}, False),
1006
+ ("an improvement was called a regression", {"regression_cases": 20}, {"regression_cases": 26},
1007
+ {"regression_cases": {"kind": "must_not_decrease", "why": ""}}, True),
1008
+ ("an unchanged metric was called a regression", {"python_lines": 1000}, {"python_lines": 1000},
1009
+ {"python_lines": {"kind": "max_growth_pct", "tolerance": 20, "why": ""}}, True),
1010
+ ]
1011
+ for label, base, after, rules, want_clean in attacks:
1012
+ got_clean = not any(r["regressed"] for r in health.judge(base, after, {"metrics": rules}))
1013
+ if got_clean != want_clean:
1014
+ reasons.append(f"change-health comparator is vacuous: {label}")
1015
+ unknown = health.judge({}, head, {"metrics": {"python_lines": {"kind": "must_not_decrease"}}})
1016
+ if unknown and unknown[0].get("status") != "unknown":
1017
+ reasons.append("change-health invents a verdict for a metric the baseline never recorded")
1018
+
1019
+ detail = (f"{len(policy.get('metrics') or {})} metric policies, baseline {payload.get('recorded_at', '?')}, "
1020
+ f"{len(attacks)} falsifications rejected, {len(baseline)} recorded metrics")
1021
+ return Dim("D14", "change_health", not reasons, detail if not reasons else "third verdict invalid",
1022
+ reasons=reasons)
1023
+
1024
+
1025
+ def dim_visual_gate(root):
1026
+ """D13: the visual half of the qa role must exist and must be able to see a defect.
1027
+
1028
+ A page can be technically correct and visually wrong, and no JSON assertion catches that. This dimension
1029
+ keeps the visual gate honest: scenarios must state what a user sees and why it matters, a headless
1030
+ browser must actually be available (a visual check that silently skips is worse than none), at least one
1031
+ scenario must demand a screenshot, and the evaluator must reject falsified observations.
1032
+ """
1033
+ reasons = []
1034
+ for rel in (VISUAL_SCRIPT, VISUAL_SCENARIOS_REL, STATUS_PAGE_SCRIPT):
1035
+ if not _present(root, rel):
1036
+ reasons.append(f"{rel} missing")
1037
+ if reasons:
1038
+ return Dim("D13", "visual_gate", False, "visual gate incomplete", reasons=reasons)
1039
+
1040
+ try:
1041
+ import importlib.util
1042
+ spec = importlib.util.spec_from_file_location("loop_check_visual",
1043
+ gate_file("check_visual"))
1044
+ if spec is None or spec.loader is None:
1045
+ raise ImportError("cannot load a module spec for the visual gate")
1046
+ visual = importlib.util.module_from_spec(spec)
1047
+ spec.loader.exec_module(visual)
1048
+ config = visual.load_scenarios(root)
1049
+ except Exception as exc: # noqa: BLE001
1050
+ return Dim("D13", "visual_gate", False, f"{VISUAL_SCRIPT} not loadable",
1051
+ reasons=[f"{VISUAL_SCRIPT}: {exc.__class__.__name__}: {exc}"])
1052
+
1053
+ scenarios = config.get("scenarios") or []
1054
+ if len(scenarios) < 3:
1055
+ reasons.append(f"{VISUAL_SCENARIOS_REL}: {len(scenarios)} scenario(s), need >= 3")
1056
+ ids = [s.get("id") for s in scenarios]
1057
+ if len(ids) != len(set(ids)):
1058
+ reasons.append(f"{VISUAL_SCENARIOS_REL}: duplicate scenario ids in {ids}")
1059
+ for sc in scenarios:
1060
+ for field in ("id", "title", "page", "expect"):
1061
+ if not sc.get(field):
1062
+ reasons.append(f"{VISUAL_SCENARIOS_REL}: scenario {sc.get('id', '?')} lacks {field!r}")
1063
+ if len((sc.get("why") or "").strip()) < 25:
1064
+ reasons.append(f"{VISUAL_SCENARIOS_REL}: scenario {sc.get('id', '?')} has no user-facing 'why'")
1065
+ if not any((s.get("expect") or {}).get("screenshot_required") for s in scenarios):
1066
+ reasons.append(f"{VISUAL_SCENARIOS_REL}: no scenario demands a screenshot, so nothing here is "
1067
+ "actually visual")
1068
+ chrome = visual.chrome_binary()
1069
+ if chrome is None:
1070
+ reasons.append("no headless Chrome/Chromium on this machine: the visual gate could only ever SKIP")
1071
+
1072
+ falsifications, accepted_good = 0, 0
1073
+ for sc in scenarios:
1074
+ good = _good_visual_observation(sc)
1075
+ if not visual.evaluate(sc, good):
1076
+ accepted_good += 1
1077
+ exp = sc.get("expect") or {}
1078
+ attacks = []
1079
+ for needle in exp.get("require_text") or []:
1080
+ attacks.append({**good, "dom": good["dom"].replace(needle, "")})
1081
+ for needle in exp.get("forbid_text") or []:
1082
+ attacks.append({**good, "dom": good["dom"] + needle})
1083
+ if exp.get("screenshot_required"):
1084
+ attacks.append({**good, "screenshot": "/nonexistent/shot.png"})
1085
+ if exp.get("min_bytes"):
1086
+ attacks.append({**good, "screenshot_bytes": 10})
1087
+ if exp.get("visible_cell_truncation"):
1088
+ width = int(exp.get("cell_width", 70))
1089
+ attacks.append({**good, "dom": good["dom"] + f"<td>{'y' * width}</td>"})
1090
+ for attack in attacks:
1091
+ if not visual.evaluate(sc, attack):
1092
+ reasons.append(f"{VISUAL_SCENARIOS_REL}: {sc['id']} accepts a falsified page — the check is "
1093
+ "vacuous")
1094
+ else:
1095
+ falsifications += 1
1096
+ if falsifications < 4:
1097
+ reasons.append(f"the visual evaluator resisted only {falsifications} falsification(s); need >= 4")
1098
+ if accepted_good == 0:
1099
+ reasons.append("no visual scenario has a satisfiable observation")
1100
+
1101
+ detail = (f"{len(scenarios)} scenario(s), headless browser {os.path.basename(chrome) if chrome else 'missing'}, "
1102
+ f"{falsifications} falsifications rejected, {accepted_good} satisfiable")
1103
+ return Dim("D13", "visual_gate", not reasons, detail if not reasons else "visual gate invalid",
1104
+ reasons=reasons)
1105
+
1106
+
1107
+ # ---------------------------------------------------------------------- main
1108
+
1109
+ def main(argv=None):
1110
+ ap = argparse.ArgumentParser(description="Loop-system local gate (read-only).")
1111
+ ap.add_argument("--root", default=project_root())
1112
+ ap.add_argument("--strict", action="store_true",
1113
+ help="treat SKIP dimensions as failures (this is the project acceptance mode)")
1114
+ ap.add_argument("--report", help="write a JSON summary to this path (only write the gate performs)")
1115
+ args = ap.parse_args(argv)
1116
+
1117
+ root = os.path.realpath(args.root)
1118
+ # The gate's own source and the machine-written report echo gate output verbatim — they are the
1119
+ # checker talking about itself, so guardrail scans skip them. Everything else in the tree is scanned.
1120
+ skip_paths = {
1121
+ # The gate's own source, found inside the judged tree. __file__ is the copy that runs, which is not
1122
+ # necessarily the file inside `root` (a regression copy runs the gate from the installed package).
1123
+ os.path.realpath(os.path.join(root, GATE_SCRIPT)),
1124
+ os.path.realpath(os.path.join(root, REPORT_REL)),
1125
+ os.path.realpath(os.path.join(root, PASS_REL)),
1126
+ }
1127
+ print("LOOP GATE — loopgate/gates/check_loop.py (read-only)")
1128
+ print(f"root: {root}")
1129
+ print(f"mode: {'strict' if args.strict else 'normal'}")
1130
+ print()
1131
+
1132
+ def effective_status(dim):
1133
+ if dim.skip and not args.strict:
1134
+ return "SKIP"
1135
+ return "OK" if dim.ok else "FAIL"
1136
+
1137
+ others = [
1138
+ dim_contract_skill(root),
1139
+ dim_sources_of_truth(root),
1140
+ dim_loop_state(root),
1141
+ dim_rounds(root),
1142
+ dim_no_direct_cli(root, skip_paths),
1143
+ dim_no_secrets(root, skip_paths),
1144
+ dim_course_corpus(root),
1145
+ dim_roles_fanout(root),
1146
+ dim_issue_bridge(root),
1147
+ dim_product_gate(root),
1148
+ dim_anchor_witness(root),
1149
+ dim_visual_gate(root),
1150
+ dim_change_health(root),
1151
+ ]
1152
+ # D05 is evaluated last of the group because it must know this run's other verdicts.
1153
+ handoff_dim = dim_handoff(root, {d.ident: (d.ok, d.skip) for d in others})
1154
+ dims = others[:4] + [handoff_dim] + others[4:]
1155
+
1156
+ cold = cold_start(root)
1157
+ failures, skipped, passed, cold_skips, not_applicable = [], [], 0, [], []
1158
+ statuses = []
1159
+ for dim in dims:
1160
+ if dim.skip:
1161
+ if dim.ident in NOT_APPLICABLE_DIMS:
1162
+ # An input the project does not ship (D08 study corpus) cannot make a tree red: the
1163
+ # dimension applies to the operator's own corpus, and saying "not applicable" is the
1164
+ # truthful status. It is still printed, so the gap is visible.
1165
+ status = "N/A"
1166
+ not_applicable.append(dim)
1167
+ elif cold and dim.ident in COLD_DIMS:
1168
+ # A tree that never ran the loop cannot prove its own history. Saying so is the honest
1169
+ # verdict; printing PASS would be a claim, and inventing rounds would be a fake.
1170
+ status = "COLD"
1171
+ cold_skips.append(dim)
1172
+ elif args.strict:
1173
+ status = "FAIL"
1174
+ failures.append(dim)
1175
+ else:
1176
+ status = "SKIP"
1177
+ skipped.append(dim)
1178
+ elif dim.ok:
1179
+ status, passed = "OK", passed + 1
1180
+ else:
1181
+ status = "FAIL"
1182
+ failures.append(dim)
1183
+ statuses.append(status)
1184
+ print(f"{dim.ident} {dim.title:<26} [{status}] {dim.detail}")
1185
+ for reason in (dim.reasons if status in {"FAIL", "SKIP"} else []):
1186
+ print(f" - {reason}")
1187
+
1188
+ print()
1189
+ ok = not failures and (not args.strict or not skipped)
1190
+ marker = MARKER_STOP if not ok else (MARKER_COLD if cold_skips else MARKER_PASS)
1191
+ extra = []
1192
+ if skipped:
1193
+ extra.append(f"{len(skipped)} skip")
1194
+ if cold_skips:
1195
+ extra.append(f"{len(cold_skips)} cold")
1196
+ if not_applicable:
1197
+ extra.append(f"{len(not_applicable)} n/a ({', '.join(d.ident for d in not_applicable)})")
1198
+ print(f"dims: {passed} ok, {len(failures)} fail" + (", " + ", ".join(extra) if extra else "")
1199
+ + f" (mandatory: {len(dims) - len(OPTIONAL_DIMS)}, optional: {len(OPTIONAL_DIMS)})")
1200
+ if skipped and not args.strict:
1201
+ print("note: an optional dimension was skipped — project acceptance requires "
1202
+ "`python3 -m loopgate.gates.check_loop --strict` (see PLANS.md)")
1203
+ if cold_skips:
1204
+ print(f"cold start: {len(cold_skips)} state dimension(s) cannot be proven in this tree "
1205
+ f"({', '.join(d.ident for d in cold_skips)}). Run the loop once, then re-check.")
1206
+ print(f"acceptance: {'cold-start' if cold_skips and ok else ('strict-complete' if ok and not skipped else ('stage-1' if ok else 'not-accepted'))}")
1207
+
1208
+ if args.report:
1209
+ payload = {
1210
+ "marker": marker,
1211
+ "generated_at": __import__("datetime").datetime.now().astimezone().isoformat(timespec="seconds"),
1212
+ "root": root,
1213
+ "strict": args.strict,
1214
+ "dimensions": [{"id": d.ident, "name": d.title, "status": statuses[i],
1215
+ "ok": d.ok, "skip": d.skip, "detail": d.detail, "reasons": d.reasons}
1216
+ for i, d in enumerate(dims)],
1217
+ "failures": [d.ident for d in failures],
1218
+ }
1219
+ payload["verdict_hash"] = _verdict_hash(payload)
1220
+ try:
1221
+ with open(args.report, "w", encoding="utf-8") as fh:
1222
+ json.dump(payload, fh, ensure_ascii=False, indent=2)
1223
+ fh.write("\n")
1224
+ print(f"report: {os.path.relpath(args.report, root)} verdict_hash={payload['verdict_hash']}")
1225
+ except OSError as exc:
1226
+ # A read-only sandbox (codex --sandbox read-only, agy --sandbox) can forbid this write. The
1227
+ # transcript is a convenience, never a prerequisite: the verdict must still be printed.
1228
+ print(f"warning: could not write report ({exc.__class__.__name__}: {exc}); verdict unchanged")
1229
+
1230
+ # Binding anchor: written whenever every dimension except the ones that read the anchor is green,
1231
+ # because a dimension must not certify itself. The handoff must quote this hash.
1232
+ if not cold_skips and not [d for d in failures if d.ident not in SELF_CERTIFYING_DIMS]:
1233
+ anchor = {
1234
+ "marker": MARKER_PASS,
1235
+ "generated_at": __import__("datetime").datetime.now().astimezone().isoformat(timespec="seconds"),
1236
+ "all_other_dimensions_ok": True,
1237
+ "excluded_from_hash": sorted(SELF_CERTIFYING_DIMS),
1238
+ "gate_command": GATE_CMD,
1239
+ "dimensions": [{"id": d.ident, "name": d.title, "status": statuses[i],
1240
+ "ok": d.ok, "skip": d.skip}
1241
+ for i, d in enumerate(dims)],
1242
+ }
1243
+ anchor["verdict_hash"] = _verdict_hash(anchor)
1244
+ try:
1245
+ with open(os.path.join(root, PASS_REL), "w", encoding="utf-8") as fh:
1246
+ json.dump(anchor, fh, ensure_ascii=False, indent=2)
1247
+ fh.write("\n")
1248
+ print(f"anchor: {PASS_REL} verdict_hash={anchor['verdict_hash']}")
1249
+ except OSError as exc:
1250
+ # Same rule as the report: an unwritable anchor degrades to a warning. Never let a failed
1251
+ # write swallow the marker — a gate that dies after "9 ok" is worse than a gate without a
1252
+ # transcript, and a red-but-silent run is indistinguishable from a crash.
1253
+ print(f"warning: could not write anchor {PASS_REL} ({exc.__class__.__name__}: {exc}); "
1254
+ f"handoff binding will stay unverifiable until this is writable")
1255
+
1256
+ if ok:
1257
+ print(marker)
1258
+ return 0
1259
+ print(f"FAILURES ({len(failures)}):")
1260
+ for i, dim in enumerate(failures, 1):
1261
+ print(f" {i}. {dim.ident} {dim.title}: {dim.detail}")
1262
+ print(MARKER_STOP)
1263
+ return 1
1264
+
1265
+
1266
+ if __name__ == "__main__":
1267
+ sys.exit(main())