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