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,249 @@
1
+ #!/usr/bin/env python3
2
+ """Anchor witness — the gate's verdict leaves the machine, so a silent rewrite is detectable.
3
+
4
+ The residual risk this closes (``risk=4`` in ``artifacts/loop_state.json``)
5
+ -----------------------------------------------------------------------
6
+ Every input the technical gate reads is authored on this machine, so a fabricated anchor is *detectable by
7
+ a command* but not *impossible*. Two things change that:
8
+
9
+ 1. **Signature.** The binding anchor (``artifacts/loop_gate_pass.json``) is signed with a dedicated,
10
+ purpose-limited SSH key (``~/.ssh/loop_anchor_ed25519``; its public half is committed in
11
+ ``configs/allowed_signers``). Any later edit of the anchor without that key fails verification, so the
12
+ anchor cannot be quietly re-minted to describe a run that did not happen.
13
+ 2. **External timestamp.** ``publish`` writes the anchor digest + signature as a comment on a GitHub issue,
14
+ where the timestamp is the platform's, not ours. A coordinated rewrite now has to contradict a record we
15
+ do not control.
16
+
17
+ Honest boundary: the signing key lives on the same machine, so the signature alone proves "written by the
18
+ holder of this key", not "written by an independent party". The external publication is what makes a
19
+ *coordinated* rewrite visible. The gate checks the signature locally (no network); ``check-external`` is the
20
+ operator-run step that verifies the published copy.
21
+
22
+ Usage
23
+ -----
24
+ python3 -m loopgate.gates.anchor_witness sign # sign the current anchor
25
+ python3 -m loopgate.gates.anchor_witness verify # local: signature over the current anchor
26
+ python3 -m loopgate.gates.anchor_witness publish # post digest+signature to the witness log
27
+ python3 -m loopgate.gates.anchor_witness check-external # read the log back and compare digests
28
+ python3 -m loopgate.gates.anchor_witness status
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from loopgate.paths import project_root, gate_file
34
+
35
+ import argparse
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import subprocess
40
+ import sys
41
+
42
+ ROOT = project_root()
43
+ ANCHOR_REL = "artifacts/loop_gate_pass.json"
44
+ VERDICT_REL = "artifacts/loop_gate_verdict.txt"
45
+ # ssh-keygen writes <message>.sig next to the message, so the signature lives beside the signed payload.
46
+ SIG_REL = "artifacts/loop_gate_verdict.txt.sig"
47
+ WITNESS_REL = "artifacts/anchor_witness.json"
48
+ CONFIG_REL = "configs/anchor_witness.json"
49
+ PASS_MARKER = "ANCHOR_WITNESS_PASS_0001"
50
+ STOP_MARKER = "ANCHOR_WITNESS_STOP_0001"
51
+
52
+
53
+ def load_config(root: str = ROOT) -> dict:
54
+ with open(os.path.join(root, CONFIG_REL), encoding="utf-8") as fh:
55
+ return json.load(fh)
56
+
57
+
58
+ def digest_of(path: str) -> str:
59
+ with open(path, "rb") as fh:
60
+ return hashlib.sha256(fh.read()).hexdigest()
61
+
62
+
63
+ def verdict_payload(anchor_path: str) -> bytes:
64
+ """What gets signed: the verdict, not the file.
65
+
66
+ The anchor carries a fresh ``generated_at`` on every run, so signing the file would invalidate the
67
+ witness after each gate run. The verdict (marker + verdict hash) is stable while the verdict set is
68
+ stable, which is exactly what a witness should pin.
69
+ """
70
+ with open(anchor_path, encoding="utf-8") as fh:
71
+ payload = json.load(fh)
72
+ return f"marker={payload.get('marker')}\nverdict_hash={payload.get('verdict_hash')}\n".encode()
73
+
74
+
75
+ def sign(root: str, config: dict) -> tuple[bool, str]:
76
+ anchor = os.path.join(root, ANCHOR_REL)
77
+ sig = os.path.join(root, SIG_REL)
78
+ message = os.path.join(root, VERDICT_REL)
79
+ key = os.path.expanduser(config["signing_key"])
80
+ with open(message, "wb") as fh:
81
+ fh.write(verdict_payload(anchor))
82
+ # ssh-keygen -Y sign silently keeps an existing signature file (exit 0, old signature left in place),
83
+ # so a stale signature would verify the *previous* verdict while looking freshly signed. Remove it first.
84
+ if os.path.exists(sig):
85
+ os.remove(sig)
86
+ proc = subprocess.run(["ssh-keygen", "-Y", "sign", "-f", key, "-n", config["namespace"], message],
87
+ capture_output=True, text=True)
88
+ if proc.returncode != 0:
89
+ return False, f"ssh-keygen sign failed: {proc.stderr.strip()[:200]}"
90
+ stale = os.path.join(root, "artifacts", "loop_gate_pass.json.sig")
91
+ if os.path.exists(stale): # an earlier file-based signature would shadow the verdict one
92
+ os.remove(stale)
93
+ record = {"anchor": ANCHOR_REL, "signed_message": VERDICT_REL,
94
+ "verdict_hash": json.load(open(anchor, encoding="utf-8")).get("verdict_hash"),
95
+ "payload_sha256": digest_of(message), "namespace": config["namespace"],
96
+ "identity": config["identity"], "signature_file": SIG_REL,
97
+ "signer_fingerprint": signer_fingerprint(key)}
98
+ with open(os.path.join(root, WITNESS_REL), "w", encoding="utf-8") as fh:
99
+ json.dump(record, fh, ensure_ascii=False, indent=2)
100
+ fh.write("\n")
101
+ return True, (f"signed the verdict of {ANCHOR_REL} -> {SIG_REL} "
102
+ f"(verdict_hash {record['verdict_hash']}, payload sha256 {record['payload_sha256'][:16]}…)")
103
+
104
+
105
+ def signer_fingerprint(key: str) -> str:
106
+ proc = subprocess.run(["ssh-keygen", "-l", "-f", key + ".pub"], capture_output=True, text=True)
107
+ parts = proc.stdout.split()
108
+ return parts[1] if len(parts) > 1 else "unknown"
109
+
110
+
111
+ def verify(root: str, config: dict, anchor_path: str | None = None, sig_path: str | None = None) -> tuple[bool, str]:
112
+ """Verify the signature over the *verdict* reconstructed from the anchor on disk.
113
+
114
+ Passing a doctored anchor (``anchor_path``) changes the reconstructed message, so a rewrite cannot ride
115
+ on an old signature.
116
+ """
117
+ anchor = anchor_path or os.path.join(root, ANCHOR_REL)
118
+ sig = sig_path or os.path.join(root, SIG_REL)
119
+ signers = os.path.join(root, config["allowed_signers"])
120
+ for path in (anchor, sig, signers):
121
+ if not os.path.exists(path):
122
+ return False, f"{os.path.relpath(path, root)} missing (run `sign` first)"
123
+ proc = subprocess.run(["ssh-keygen", "-Y", "verify", "-f", signers, "-I", config["identity"],
124
+ "-n", config["namespace"], "-s", sig],
125
+ input=verdict_payload(anchor), capture_output=True)
126
+ if proc.returncode != 0:
127
+ note = (proc.stdout or proc.stderr or b"").decode("utf-8", "replace").strip()
128
+ return False, note[:200] or "signature does not match this verdict"
129
+ return True, (proc.stdout or b"").decode("utf-8", "replace").strip() + \
130
+ f" (verdict_hash {json.load(open(anchor, encoding='utf-8')).get('verdict_hash')})"
131
+
132
+
133
+ def gh(args: list[str], token: str | None = None) -> tuple[int, str]:
134
+ env = dict(os.environ)
135
+ if token:
136
+ env["GH_TOKEN"] = token
137
+ proc = subprocess.run(["gh"] + args, capture_output=True, text=True, env=env)
138
+ return proc.returncode, (proc.stdout or proc.stderr).strip()
139
+
140
+
141
+ def github_token() -> str | None:
142
+ """The token lives in the operator's env or in ~/.dsh/.env; never echoed."""
143
+ if os.environ.get("GH_TOKEN"):
144
+ return os.environ["GH_TOKEN"]
145
+ env_file = os.path.expanduser("~/.dsh/.env")
146
+ if os.path.exists(env_file):
147
+ for line in open(env_file, encoding="utf-8"):
148
+ if line.startswith(("GITHUB_TOKEN=", "GH_TOKEN=")):
149
+ return line.split("=", 1)[1].strip().strip('"')
150
+ return None
151
+
152
+
153
+ def publish(root: str, config: dict, verbose: bool = False) -> tuple[bool, str]:
154
+ dest = config["destination"]
155
+ repo, issue = dest["repo"], str(dest["issue"])
156
+ witness = json.load(open(os.path.join(root, WITNESS_REL), encoding="utf-8"))
157
+ # The entry carries the exact bytes that were signed, not a path into this checkout: a reader of the
158
+ # journal has no tree to resolve, and a digest nobody can recompute is an assertion, not a check. The
159
+ # payload on disk is re-hashed first, so publishing cannot describe a signature that is no longer the
160
+ # one that was made.
161
+ with open(os.path.join(root, VERDICT_REL), "rb") as fh:
162
+ payload = fh.read()
163
+ if hashlib.sha256(payload).hexdigest() != witness["payload_sha256"]:
164
+ return False, (f"{VERDICT_REL} no longer hashes to the signed digest "
165
+ f"({witness['payload_sha256'][:16]}…) — re-sign before publishing")
166
+ body = (f"<!-- {config['marker']} -->\n"
167
+ f"**{config['marker']}**\n\n"
168
+ f"- verdict_hash: `{witness['verdict_hash']}`\n"
169
+ f"- signed message, verbatim:\n\n"
170
+ f"```text\n{payload.decode('utf-8').strip()}\n```\n\n"
171
+ f"- payload sha256: `{witness['payload_sha256']}`\n"
172
+ f"- signer: `{witness['signer_fingerprint']}` ({witness['identity']}, namespace "
173
+ f"`{witness['namespace']}`)\n"
174
+ f"- signature: `{witness['signature_file']}` (verify against `{config['allowed_signers']}`)\n")
175
+ token = github_token()
176
+ code, existing = gh(["api", f"repos/{repo}/issues/{issue}/comments", "--paginate",
177
+ "--jq", ".[].body"], token)
178
+ if code != 0:
179
+ return False, f"cannot read the witness log: {existing[:160]}"
180
+ if witness["verdict_hash"] in existing:
181
+ return True, f"already published to {repo}#{issue} (verdict already witnessed, nothing to do)"
182
+ code, out = gh(["issue", "comment", issue, "-R", repo, "--body", body], token)
183
+ if code != 0:
184
+ return False, f"cannot publish: {out[:160]}"
185
+ return True, f"published to {repo}#{issue}: {out.strip()}"
186
+
187
+
188
+ def check_external(root: str, config: dict) -> tuple[bool, str]:
189
+ dest = config["destination"]
190
+ witness = json.load(open(os.path.join(root, WITNESS_REL), encoding="utf-8"))
191
+ code, out = gh(["api", f"repos/{dest['repo']}/issues/{dest['issue']}/comments", "--paginate",
192
+ "--jq", ".[].body"], github_token())
193
+ if code != 0:
194
+ return False, f"cannot read the witness log: {out[:160]}"
195
+ if witness["verdict_hash"] not in out:
196
+ return False, (f"the current verdict {witness['verdict_hash']} is not published in "
197
+ f"{dest['repo']}#{dest['issue']} — run `publish`")
198
+ current = json.load(open(os.path.join(root, ANCHOR_REL), encoding="utf-8")).get("verdict_hash")
199
+ if current != witness["verdict_hash"]:
200
+ return False, (f"the anchor now carries verdict {current} but the published witness describes "
201
+ f"{witness['verdict_hash']} — re-sign and re-publish")
202
+ return True, f"witnessed: {dest['repo']}#{dest['issue']} carries verdict {witness['verdict_hash']}"
203
+
204
+
205
+ def main(argv=None) -> int:
206
+ ap = argparse.ArgumentParser(description="Anchor witness: sign the gate anchor, publish its digest.")
207
+ ap.add_argument("--root", default=ROOT)
208
+ ap.add_argument("--verify-anchor", help="verify this anchor file instead of the current one (attack mode)")
209
+ ap.add_argument("--verify-signature", help="verify this signature file instead of the current one")
210
+ sub = ap.add_subparsers(dest="cmd", required=True)
211
+ sub.add_parser("sign")
212
+ sub.add_parser("verify")
213
+ sub.add_parser("publish")
214
+ sub.add_parser("check-external")
215
+ sub.add_parser("status")
216
+ args = ap.parse_args(argv)
217
+
218
+ try:
219
+ config = load_config(args.root)
220
+ except (OSError, json.JSONDecodeError) as exc:
221
+ print(f"config error: {exc}", file=sys.stderr)
222
+ print(STOP_MARKER)
223
+ return 2
224
+
225
+ if args.cmd == "sign":
226
+ ok, note = sign(args.root, config)
227
+ elif args.cmd == "verify":
228
+ ok, note = verify(args.root, config, args.verify_anchor, args.verify_signature)
229
+ elif args.cmd == "publish":
230
+ ok, note = publish(args.root, config)
231
+ elif args.cmd == "check-external":
232
+ ok, note = check_external(args.root, config)
233
+ else:
234
+ anchor = os.path.join(args.root, ANCHOR_REL)
235
+ witness_path = os.path.join(args.root, WITNESS_REL)
236
+ note = (f"anchor {'present' if os.path.exists(anchor) else 'missing'}, "
237
+ f"signature {'present' if os.path.exists(os.path.join(args.root, SIG_REL)) else 'missing'}, "
238
+ f"witness {'present' if os.path.exists(witness_path) else 'missing'}; "
239
+ f"destination {config['destination']['repo']}#{config['destination']['issue']}")
240
+ ok = True
241
+ if os.path.exists(witness_path):
242
+ note += f"; signed verdict {json.load(open(witness_path))['verdict_hash']}"
243
+ print(note)
244
+ print(PASS_MARKER if ok else STOP_MARKER)
245
+ return 0 if ok else 1
246
+
247
+
248
+ if __name__ == "__main__":
249
+ sys.exit(main())
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Assert that named dimensions are OK in a technical-gate run.
3
+
4
+ Why this exists: the technical gate's own verdict is `LOOP_ENGINEERING_PASS_0001` only when every
5
+ mandatory dimension is green — and several dimensions read *state* (`artifacts/`, `handoff/`, the signed
6
+ anchor) that a fresh clone does not have by design. CI therefore cannot honestly demand the PASS marker
7
+ from a clone; it can honestly demand that the dimensions which depend only on the code are green.
8
+
9
+ This script makes that distinction executable instead of a sentence in a README:
10
+
11
+ python3 -m loopgate.gates.assert_dims --require D01 D02 D06 D07 D08 D10 D11 D13
12
+ python3 -m loopgate.gates.assert_dims --require-all # only where the loop state exists
13
+
14
+ Exit 0 prints ``CI_DIMS_PASS_0001``; a missing or red dimension prints ``CI_DIMS_STOP_0001`` naming it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from loopgate.paths import project_root, gate_file
20
+
21
+ import argparse
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import sys
26
+
27
+ ROOT = project_root()
28
+ PASS_MARKER = "CI_DIMS_PASS_0001"
29
+ STOP_MARKER = "CI_DIMS_STOP_0001"
30
+ LINE_RE = re.compile(r"^(D\d\d)\s+\S+\s+\[(OK|FAIL|SKIP)\]")
31
+
32
+
33
+ def gate_output(root: str, extra: list[str]) -> tuple[int, str]:
34
+ # Run the packaged gate as a module from the judged root: a file path would put loopgate/gates/ first on
35
+ # sys.path and the package import (loopgate.paths) would fail.
36
+ proc = subprocess.run([sys.executable, "-m", "loopgate.gates.check_loop", *extra],
37
+ capture_output=True, text=True, cwd=root)
38
+ return proc.returncode, proc.stdout + proc.stderr
39
+
40
+
41
+ def parse(output: str) -> dict[str, str]:
42
+ seen: dict[str, str] = {}
43
+ for line in output.splitlines():
44
+ m = LINE_RE.match(line.strip())
45
+ if m:
46
+ seen[m.group(1)] = m.group(2)
47
+ return seen
48
+
49
+
50
+ def main(argv=None) -> int:
51
+ ap = argparse.ArgumentParser(description="Assert that named gate dimensions are OK.")
52
+ ap.add_argument("--root", default=ROOT)
53
+ ap.add_argument("--require", nargs="*", default=[], metavar="DIM")
54
+ ap.add_argument("--require-all", action="store_true",
55
+ help="require every dimension the gate prints (only where loop state exists)")
56
+ ap.add_argument("--quiet", action="store_true")
57
+ args = ap.parse_args(argv)
58
+
59
+ code, output = gate_output(args.root, ["--strict"])
60
+ dims = parse(output)
61
+ if not dims:
62
+ print("the gate printed no dimensions at all — the gate itself is broken", file=sys.stderr)
63
+ print(output, file=sys.stderr)
64
+ print(STOP_MARKER)
65
+ return 1
66
+
67
+ required = sorted(dims) if args.require_all else list(args.require)
68
+ if not required:
69
+ print("nothing required: pass --require D01 ... or --require-all", file=sys.stderr)
70
+ print(STOP_MARKER)
71
+ return 2
72
+
73
+ bad = [(d, dims.get(d, "MISSING")) for d in required if dims.get(d) != "OK"]
74
+ if not args.quiet:
75
+ print(f"gate exit={code}; dimensions seen: {len(dims)}; required: {len(required)}")
76
+ for d in required:
77
+ print(f" {d} [{dims.get(d, 'MISSING')}]")
78
+ if bad:
79
+ for d, status in bad:
80
+ print(f"required dimension not OK: {d} [{status}]", file=sys.stderr)
81
+ print(STOP_MARKER)
82
+ return 1
83
+ print(PASS_MARKER)
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main())
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Give a fresh clone the one thing it can honestly have: a declaration that the loop has not run here yet.
3
+
4
+ `artifacts/` and `handoff/` are gitignored state, so a clone starts without them and the strict gate is red
5
+ on D03/D04/D05/D12 — not because the code is broken, but because there is no history to prove. Copying a
6
+ history out of this machine would be a fake; a fake is the one thing this project cannot ship.
7
+
8
+ So the tree says so out loud:
9
+
10
+ python3 -m loopgate.gates.bootstrap_state --init # writes artifacts/loop_state.json, state = cold
11
+ python3 -m loopgate.gates.check_loop --strict # -> LOOP_ENGINEERING_COLD_0001, exit 0
12
+ ...run the loop once (a round artifact + a state update)...
13
+ python3 -m loopgate.gates.check_loop --strict # -> LOOP_ENGINEERING_PASS_0001, exit 0
14
+
15
+ The cold marker is not a pass: it says the code dimensions are green and four state dimensions could not
16
+ be measured. The gate refuses the declaration as soon as round or handoff artifacts appear, so it cannot
17
+ be used to hide a broken loop behind an empty directory.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from loopgate.paths import project_root, gate_file
23
+
24
+ import argparse
25
+ import datetime
26
+ import json
27
+ import os
28
+ import sys
29
+
30
+ ROOT = project_root()
31
+ STATE_REL = "artifacts/loop_state.json"
32
+ ROUNDS_REL = "artifacts/rounds"
33
+ HANDOFF_REL = "handoff"
34
+ COLD_MARKER = "LOOP_STATE_COLD_0001"
35
+ STOP_MARKER = "LOOP_STATE_STOP_0001"
36
+
37
+
38
+ def cold_state() -> dict:
39
+ now = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
40
+ return {
41
+ "state": "cold",
42
+ "round": 0,
43
+ "readiness": 0,
44
+ "evidence": 0,
45
+ "risk": 100,
46
+ "blockers": [
47
+ "cold start: no round artifact exists in this tree, so the loop's history is unproven",
48
+ "no handoff has been written; roles have not run against a real gate verdict",
49
+ "no signed anchor exists: a signature is produced by a run, not by a clone",
50
+ ],
51
+ "phase": "build",
52
+ "updated_at": now,
53
+ "marker": None,
54
+ "history": [],
55
+ "note": ("Written by loopgate/gates/bootstrap_state.py. This is a declaration, not evidence: it exists so "
56
+ "the gate can name what a fresh clone cannot prove instead of pretending either way."),
57
+ }
58
+
59
+
60
+ def main(argv=None) -> int:
61
+ ap = argparse.ArgumentParser(description="Initialize an honest cold-start state for a fresh tree.")
62
+ ap.add_argument("--init", action="store_true", help="write artifacts/loop_state.json with state=cold")
63
+ ap.add_argument("--force", action="store_true", help="overwrite an existing cold state file (never a real one)")
64
+ ap.add_argument("--root", default=ROOT)
65
+ args = ap.parse_args(argv)
66
+
67
+ root = os.path.abspath(args.root)
68
+ state_path = os.path.join(root, STATE_REL)
69
+ rounds_dir = os.path.join(root, ROUNDS_REL)
70
+ handoff_dir = os.path.join(root, HANDOFF_REL)
71
+
72
+ rounds = [n for n in (os.listdir(rounds_dir) if os.path.isdir(rounds_dir) else []) if n.startswith("round_")]
73
+ handoff = []
74
+ if os.path.isdir(handoff_dir):
75
+ for _dirpath, _dirnames, filenames in os.walk(handoff_dir):
76
+ handoff.extend(n for n in filenames if n.endswith(".md"))
77
+
78
+ if rounds or handoff:
79
+ print(f"refusing to declare a cold start: {len(rounds)} round artifact(s) and {len(handoff)} "
80
+ f"handoff file(s) prove this tree has run the loop")
81
+ print(STOP_MARKER)
82
+ return 1
83
+
84
+ if os.path.exists(state_path) and not args.force:
85
+ try:
86
+ with open(state_path, encoding="utf-8") as fh:
87
+ existing = json.load(fh)
88
+ except (OSError, json.JSONDecodeError):
89
+ existing = {}
90
+ if existing.get("state") != "cold":
91
+ print(f"refusing to overwrite {STATE_REL}: it holds a real state (round "
92
+ f"{existing.get('round')!r}, marker {existing.get('marker')!r})")
93
+ print(STOP_MARKER)
94
+ return 1
95
+ print(f"{STATE_REL} is already a cold state; nothing to do")
96
+
97
+ if not args.init:
98
+ ap.error("--init is required (this script never writes without being asked)")
99
+
100
+ os.makedirs(os.path.dirname(state_path), exist_ok=True)
101
+ with open(state_path, "w", encoding="utf-8") as fh:
102
+ json.dump(cold_state(), fh, ensure_ascii=False, indent=2)
103
+ fh.write("\n")
104
+ print(f"wrote {STATE_REL}: state=cold, round=0 — the gate will name the unprovable dimensions")
105
+ print("next: python3 -m loopgate.gates.check_loop --strict")
106
+ print(COLD_MARKER)
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())