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,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())
|