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,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build a publication-ready tree from this checkout.
|
|
3
|
+
|
|
4
|
+
Why this exists: the working repository is a *lab notebook*. It carries the author's absolute paths,
|
|
5
|
+
their GitHub account, a witness journal in a private repository, and a study corpus collected from a
|
|
6
|
+
paid course. None of that can be published, and hand-cleaning a clone before every release is how private
|
|
7
|
+
strings leak — so the cleaning is a command:
|
|
8
|
+
|
|
9
|
+
python3 -m loopgate.gates.export_public --out dist/public
|
|
10
|
+
python3 -m loopgate.gates.export_public --out dist/public --git-init # fresh single-commit history
|
|
11
|
+
|
|
12
|
+
What it guarantees:
|
|
13
|
+
* **Only tracked files** are considered — gitignored state (`artifacts/`, `handoff/`, an editor's backup)
|
|
14
|
+
cannot leak by accident.
|
|
15
|
+
* **Excluded paths stay out** (`course/`). The study corpus is not part of the tool and never ships; the
|
|
16
|
+
removal rules themselves live in ``configs/public_sanitize.json`` (repository-specific strings were
|
|
17
|
+
stripped once, before publication; the `forbidden` list keeps the generic home-directory paths, and a
|
|
18
|
+
survivor fails the export — credential shapes are D07's job, not this file's).
|
|
19
|
+
* **Identities are rewritten**, not merely deleted, when the configuration declares a replacement: a profile
|
|
20
|
+
without a location is a profile the product gate cannot use, so the example files it lands are valid and
|
|
21
|
+
runnable.
|
|
22
|
+
* **Fail-closed:** after building, every text file is scanned for the forbidden strings. If one survives,
|
|
23
|
+
the exporter prints ``PUBLIC_EXPORT_STOP_0001`` with ``file:line`` and exits 1 — the tree is left on disk
|
|
24
|
+
for inspection, but it is not a release.
|
|
25
|
+
|
|
26
|
+
The exporter never rewrites git history; the working repository is untouched apart from `--out`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from loopgate.paths import project_root, gate_file
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import fnmatch
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shutil
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
|
|
42
|
+
ROOT = project_root()
|
|
43
|
+
CONFIG_REL = "configs/public_sanitize.json"
|
|
44
|
+
PASS_MARKER = "PUBLIC_EXPORT_PASS_0001"
|
|
45
|
+
STOP_MARKER = "PUBLIC_EXPORT_STOP_0001"
|
|
46
|
+
|
|
47
|
+
TEXT_EXT = {".md", ".py", ".json", ".yml", ".yaml", ".toml", ".sh", ".txt", ".cfg", ".ini",
|
|
48
|
+
".html", ".css", ".swift", ".gitignore", ""}
|
|
49
|
+
# Dimensions that depend on loop state (rounds, handoff, a signed anchor) legitimately redden in a tree
|
|
50
|
+
# that has never been run; the export asserts the ones that depend on the code only.
|
|
51
|
+
CODE_DIMS = ["D01", "D02", "D06", "D07", "D10", "D11", "D13"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tracked(root: str) -> list[str]:
|
|
55
|
+
"""Every file that would be committed: tracked plus new-but-not-ignored.
|
|
56
|
+
|
|
57
|
+
Tracked-only would silently drop files that are written but not yet committed, which is exactly the
|
|
58
|
+
state a release branch is in — the first export missed LICENSE, the CI workflow and the CLI package.
|
|
59
|
+
Ignored state (`artifacts/`, `handoff/`, `dist/`) stays out, so nothing private leaks by accident.
|
|
60
|
+
"""
|
|
61
|
+
proc = subprocess.run(["git", "-C", root, "ls-files", "-z", "--cached", "--others",
|
|
62
|
+
"--exclude-standard"], capture_output=True, text=True)
|
|
63
|
+
if proc.returncode != 0:
|
|
64
|
+
raise SystemExit(f"git ls-files failed: {proc.stderr.strip()}")
|
|
65
|
+
return [entry for entry in proc.stdout.split("\0") if entry.strip()]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def excluded(rel: str, patterns: list[str]) -> bool:
|
|
69
|
+
for pattern in patterns:
|
|
70
|
+
if pattern.endswith("/") and (rel == pattern[:-1] or rel.startswith(pattern)):
|
|
71
|
+
return True
|
|
72
|
+
if rel == pattern:
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_text(rel: str) -> bool:
|
|
78
|
+
return os.path.splitext(rel)[1].lower() in TEXT_EXT
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def sanitize(text: str, substitutions: list[dict]) -> tuple[str, dict[str, int]]:
|
|
82
|
+
counts: dict[str, int] = {}
|
|
83
|
+
for sub in substitutions:
|
|
84
|
+
find, replace = sub["find"], sub["replace"]
|
|
85
|
+
n = text.count(find)
|
|
86
|
+
if n:
|
|
87
|
+
text = text.replace(find, replace)
|
|
88
|
+
counts[find] = n
|
|
89
|
+
return text, counts
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def redact(text: str, rel: str, rules: list[dict]) -> tuple[str, dict[str, int]]:
|
|
93
|
+
"""Pattern redactions, limited by scope — because the same string means two different things.
|
|
94
|
+
|
|
95
|
+
`~/.hermes/kanban.db` inside loopgate/gates/guard_card.py is the default path of an integration and must stay
|
|
96
|
+
working; the same string in a journal is a description of somebody's machine. Scope ("*.md") is what
|
|
97
|
+
keeps the two apart instead of a clever pattern.
|
|
98
|
+
"""
|
|
99
|
+
counts: dict[str, int] = {}
|
|
100
|
+
for rule in rules:
|
|
101
|
+
scope = rule.get("scope") or ["*.md"]
|
|
102
|
+
if not any(fnmatch.fnmatch(rel, pattern) for pattern in scope):
|
|
103
|
+
continue
|
|
104
|
+
text, n = re.subn(rule["pattern"], rule["replace"], text)
|
|
105
|
+
if n:
|
|
106
|
+
counts[rule["pattern"]] = counts.get(rule["pattern"], 0) + n
|
|
107
|
+
return text, counts
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build(cfg: dict, out: str, root: str) -> tuple[list[str], dict[str, int]]:
|
|
111
|
+
replace_files = cfg.get("replace_files") or {}
|
|
112
|
+
extras = cfg.get("extras") or []
|
|
113
|
+
subs = cfg.get("substitutions") or []
|
|
114
|
+
rules = cfg.get("redact") or []
|
|
115
|
+
wrote: list[str] = []
|
|
116
|
+
totals: dict[str, int] = {}
|
|
117
|
+
|
|
118
|
+
for rel in tracked(root):
|
|
119
|
+
if excluded(rel, cfg.get("exclude") or []):
|
|
120
|
+
continue
|
|
121
|
+
source = replace_files.get(rel, rel)
|
|
122
|
+
src_path = os.path.join(root, source)
|
|
123
|
+
if not os.path.exists(src_path):
|
|
124
|
+
print(f" missing source for {rel} <- {source}", file=sys.stderr)
|
|
125
|
+
continue
|
|
126
|
+
dest_rel = rel
|
|
127
|
+
dest = os.path.join(out, dest_rel)
|
|
128
|
+
os.makedirs(os.path.dirname(dest) or out, exist_ok=True)
|
|
129
|
+
if is_text(rel):
|
|
130
|
+
text = open(src_path, encoding="utf-8").read()
|
|
131
|
+
text, counts = sanitize(text, subs)
|
|
132
|
+
text, redacted = redact(text, rel, rules)
|
|
133
|
+
counts.update({f"redact:{k}": v for k, v in redacted.items()})
|
|
134
|
+
for k, v in counts.items():
|
|
135
|
+
totals[k] = totals.get(k, 0) + v
|
|
136
|
+
open(dest, "w", encoding="utf-8").write(text)
|
|
137
|
+
else:
|
|
138
|
+
shutil.copy2(src_path, dest)
|
|
139
|
+
wrote.append(dest_rel)
|
|
140
|
+
|
|
141
|
+
for extra in extras:
|
|
142
|
+
src_path = os.path.join(root, extra["from"])
|
|
143
|
+
if not os.path.exists(src_path):
|
|
144
|
+
continue
|
|
145
|
+
text = open(src_path, encoding="utf-8").read()
|
|
146
|
+
text, counts = sanitize(text, subs)
|
|
147
|
+
for k, v in counts.items():
|
|
148
|
+
totals[k] = totals.get(k, 0) + v
|
|
149
|
+
dest = os.path.join(out, extra["to"])
|
|
150
|
+
os.makedirs(os.path.dirname(dest) or out, exist_ok=True)
|
|
151
|
+
open(dest, "w", encoding="utf-8").write(text)
|
|
152
|
+
wrote.append(extra["to"])
|
|
153
|
+
return wrote, totals
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def scan(cfg: dict, out: str) -> list[str]:
|
|
157
|
+
forbidden = cfg.get("forbidden") or []
|
|
158
|
+
allow = set(cfg.get("forbidden_allow") or [])
|
|
159
|
+
offenders: list[str] = []
|
|
160
|
+
for base, _dirs, files in os.walk(out):
|
|
161
|
+
if ".git" in base.split(os.sep):
|
|
162
|
+
continue
|
|
163
|
+
for name in files:
|
|
164
|
+
path = os.path.join(base, name)
|
|
165
|
+
rel = os.path.relpath(path, out)
|
|
166
|
+
if rel in allow or not is_text(rel):
|
|
167
|
+
continue
|
|
168
|
+
try:
|
|
169
|
+
text = open(path, encoding="utf-8").read()
|
|
170
|
+
except (OSError, UnicodeDecodeError):
|
|
171
|
+
continue
|
|
172
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
173
|
+
for needle in forbidden:
|
|
174
|
+
if needle in line:
|
|
175
|
+
offenders.append(f"{rel}:{lineno}: {needle!r}")
|
|
176
|
+
return offenders
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def gate_dims(out: str) -> tuple[dict[str, str], str]:
|
|
180
|
+
# The exported tree carries the package; run its gate as a module from that tree so the copy judges itself.
|
|
181
|
+
gate = os.path.join(out, "loopgate", "gates", "check_loop.py")
|
|
182
|
+
if not os.path.exists(gate):
|
|
183
|
+
return {}, "no gate in the exported tree"
|
|
184
|
+
proc = subprocess.run([sys.executable, "-m", "loopgate.gates.check_loop"], capture_output=True, text=True, cwd=out)
|
|
185
|
+
dims: dict[str, str] = {}
|
|
186
|
+
for line in (proc.stdout + proc.stderr).splitlines():
|
|
187
|
+
parts = line.split()
|
|
188
|
+
if len(parts) >= 3 and parts[0].startswith("D") and parts[0][1:].isdigit():
|
|
189
|
+
status = parts[2].strip("[]")
|
|
190
|
+
dims[parts[0]] = status
|
|
191
|
+
return dims, (proc.stdout + proc.stderr).strip().splitlines()[-1] if proc.stdout or proc.stderr else ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def git_fresh_history(out: str) -> str:
|
|
195
|
+
run = lambda *a: subprocess.run(["git", "-C", out, *a], capture_output=True, text=True)
|
|
196
|
+
if os.path.isdir(os.path.join(out, ".git")):
|
|
197
|
+
shutil.rmtree(os.path.join(out, ".git"))
|
|
198
|
+
if run("init", "-q", "-b", "main").returncode != 0:
|
|
199
|
+
run("init", "-q")
|
|
200
|
+
run("add", "-A")
|
|
201
|
+
proc = subprocess.run(
|
|
202
|
+
["git", "-C", out, "-c", "user.name=loopgate export", "-c", "user.email=export@example.org",
|
|
203
|
+
"commit", "-q", "-m",
|
|
204
|
+
"loopgate: gates that accept agent work by proof, not by confidence\n\n"
|
|
205
|
+
"Public tree assembled by loopgate/gates/export_public.py: private material and local identities are\n"
|
|
206
|
+
"removed, example profiles are in place of the author's working ones."],
|
|
207
|
+
capture_output=True, text=True)
|
|
208
|
+
if proc.returncode != 0:
|
|
209
|
+
return f"git commit failed: {proc.stderr.strip()}"
|
|
210
|
+
return run("rev-parse", "--short", "HEAD").stdout.strip()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv=None) -> int:
|
|
214
|
+
ap = argparse.ArgumentParser(description="Assemble a publication-ready tree (fail-closed).")
|
|
215
|
+
ap.add_argument("--out", required=True, help="destination directory (removed and rebuilt)")
|
|
216
|
+
ap.add_argument("--root", default=ROOT)
|
|
217
|
+
ap.add_argument("--git-init", action="store_true", help="init a fresh single-commit history")
|
|
218
|
+
ap.add_argument("--no-gate", action="store_true", help="skip running the gate inside the export")
|
|
219
|
+
ap.add_argument("--json", action="store_true")
|
|
220
|
+
args = ap.parse_args(argv)
|
|
221
|
+
|
|
222
|
+
out = os.path.abspath(args.out)
|
|
223
|
+
if os.path.commonpath([out, os.path.abspath(args.root)]) == os.path.abspath(args.root) and \
|
|
224
|
+
out == os.path.abspath(args.root):
|
|
225
|
+
print("refusing to export over the working repository", file=sys.stderr)
|
|
226
|
+
print(STOP_MARKER)
|
|
227
|
+
return 1
|
|
228
|
+
cfg_path = os.path.join(args.root, CONFIG_REL)
|
|
229
|
+
try:
|
|
230
|
+
cfg = json.load(open(cfg_path, encoding="utf-8"))
|
|
231
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
232
|
+
print(f"cannot read {CONFIG_REL}: {exc}", file=sys.stderr)
|
|
233
|
+
print(STOP_MARKER)
|
|
234
|
+
return 1
|
|
235
|
+
|
|
236
|
+
if os.path.isdir(out):
|
|
237
|
+
shutil.rmtree(out)
|
|
238
|
+
os.makedirs(out)
|
|
239
|
+
|
|
240
|
+
print(f"exporting {args.root} -> {out}")
|
|
241
|
+
wrote, totals = build(cfg, out, args.root)
|
|
242
|
+
print(f" files written: {len(wrote)}")
|
|
243
|
+
for find, count in sorted(totals.items(), key=lambda kv: -kv[1]):
|
|
244
|
+
print(f" rewritten: {find!r} x{count}")
|
|
245
|
+
|
|
246
|
+
offenders = scan(cfg, out)
|
|
247
|
+
dims: dict[str, str] = {}
|
|
248
|
+
if not args.no_gate:
|
|
249
|
+
dims, last_line = gate_dims(out)
|
|
250
|
+
bad = [(d, dims.get(d, "MISSING")) for d in CODE_DIMS if dims.get(d) != "OK"]
|
|
251
|
+
print(f" gate in export: {last_line}")
|
|
252
|
+
print(" code dimensions: " + ", ".join(f"{d}={dims.get(d, 'MISSING')}" for d in CODE_DIMS))
|
|
253
|
+
if bad:
|
|
254
|
+
for dim, status in bad:
|
|
255
|
+
print(f" code dimension not OK in the export: {dim} [{status}]", file=sys.stderr)
|
|
256
|
+
|
|
257
|
+
# The change-health baseline is a claim about a specific tree. Shipping the working tree's numbers
|
|
258
|
+
# would describe files the export does not contain, so the published tree records its own baseline
|
|
259
|
+
# before it is committed — that is how the verdict stays honest instead of merely favourable.
|
|
260
|
+
if not args.no_gate:
|
|
261
|
+
recorded = subprocess.run([sys.executable, "-m", "loopgate.gates.change_health",
|
|
262
|
+
"--root", out, "--write-baseline"],
|
|
263
|
+
capture_output=True, text=True, cwd=out)
|
|
264
|
+
if recorded.returncode == 0:
|
|
265
|
+
print(" change-health baseline: " + recorded.stdout.strip().splitlines()[0])
|
|
266
|
+
else:
|
|
267
|
+
print(f" change-health baseline not recorded: {recorded.stderr.strip()[:200]}", file=sys.stderr)
|
|
268
|
+
|
|
269
|
+
revision = git_fresh_history(out) if args.git_init else None
|
|
270
|
+
if revision:
|
|
271
|
+
print(f" fresh history: {revision}")
|
|
272
|
+
|
|
273
|
+
ok = not offenders and (args.no_gate or not [d for d in CODE_DIMS if dims.get(d) != "OK"])
|
|
274
|
+
if offenders:
|
|
275
|
+
print(f"forbidden strings survived ({len(offenders)}):", file=sys.stderr)
|
|
276
|
+
for line in offenders[:40]:
|
|
277
|
+
print(f" {line}", file=sys.stderr)
|
|
278
|
+
|
|
279
|
+
summary = {"marker": PASS_MARKER if ok else STOP_MARKER, "out": out, "files": len(wrote),
|
|
280
|
+
"rewritten": totals, "offenders": offenders[:200], "dims": dims,
|
|
281
|
+
"revision": revision}
|
|
282
|
+
if args.json:
|
|
283
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True))
|
|
284
|
+
print(PASS_MARKER if ok else STOP_MARKER)
|
|
285
|
+
return 0 if ok else 1
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|