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,396 @@
1
+ #!/usr/bin/env python3
2
+ """Canonical worker brief (evidence-first) + the validator that keeps it honest.
3
+
4
+ Why this file exists
5
+ --------------------
6
+ The first live PR run ended in a legitimate REJECT: the worker had proven ``go build`` only, while the
7
+ target repository requires ``golangci-lint run ./...`` and ``go test -count=1 ./...`` before any
8
+ commit. The rule lived in the skill prose and in the supervisor's head, so the worker never received
9
+ it and the supervisor had to replay the evidence after the fact. Here the rule lives **inside the
10
+ brief the worker gets** (``render_brief``) and is **machine-checked** (``verify_evidence``).
11
+
12
+ Contract
13
+ --------
14
+ * ``render_brief`` is deterministic: same profile + same issue in, same bytes out (no timestamps).
15
+ * ``verify_evidence`` fails on the two failure modes of the smoke run: a mandatory command that was
16
+ never run, and a command whose exit code is not visible or is non-zero. Prints
17
+ ``WORKER_EVIDENCE_PASS_0001`` (exit 0) or ``WORKER_EVIDENCE_STOP_0001`` (exit 1, reasons listed).
18
+ * Read-only with respect to the repository: the only write is ``build --out PATH``.
19
+
20
+ Usage
21
+ -----
22
+ python3 -m loopgate.gates.worker_brief profiles
23
+ python3 -m loopgate.gates.worker_brief build --profile sample-service --issue 42 --title "..." [--body "..."|--body-file f] [--url U] [--out PATH]
24
+ python3 -m loopgate.gates.worker_brief verify --profile sample-service --evidence PATH [--json]
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from loopgate.paths import project_root, gate_file
30
+
31
+ import argparse
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ ROOT = project_root()
38
+ PROFILES_REL = "configs/repo_profiles.json"
39
+ PASS_MARKER = "WORKER_EVIDENCE_PASS_0001"
40
+ STOP_MARKER = "WORKER_EVIDENCE_STOP_0001"
41
+
42
+ EXIT_LINE_RE = re.compile(r"^\s*exit(?:\s*code)?\s*[:=]\s*(\d+)\s*$", re.I)
43
+ NOT_RUN_RE = re.compile(r"^\s*(?:NOT RUN|not run|skipped)\s*$", re.M)
44
+ PROMPT_RE = re.compile(r"^\s*(?:[$>]|➜)?\s*")
45
+
46
+
47
+ # ---------------------------------------------------------------- profiles
48
+
49
+ def load_profiles(root: str = ROOT) -> dict:
50
+ with open(os.path.join(root, PROFILES_REL), encoding="utf-8") as fh:
51
+ return json.load(fh)["profiles"]
52
+
53
+
54
+ def get_profile(root: str, name: str) -> dict:
55
+ profiles = load_profiles(root)
56
+ if name not in profiles:
57
+ raise KeyError(f"unknown profile {name!r}; known: {', '.join(sorted(profiles)) or 'none'}")
58
+ return profiles[name]
59
+
60
+
61
+ def bundle_commands(profile: dict) -> list[dict]:
62
+ return list(profile.get("evidence_bundle") or [])
63
+
64
+
65
+ def render_ywcrew_task(profile_name: str, profile: dict, issue: dict,
66
+ backend: str = "harness", mode: str = "edit") -> dict:
67
+ """The same brief as a ywcrew five-part task spec.
68
+
69
+ The evidence rule has to travel with the *dispatch* too, not only with the kanban task body: a
70
+ ywcrew run carries ``task.briefing`` + ``task.constraints``, so the bundle and its proof format are
71
+ written into those fields verbatim. Rendering it here keeps one source for the rule.
72
+ """
73
+ bundle = bundle_commands(profile)
74
+ base = profile.get("base_branch", "main")
75
+ branch = (profile.get("branch_pattern") or "<role>/issue-<n>").replace("<n>", str(issue.get("number")))
76
+ commands = "; ".join(i["command"] for i in bundle)
77
+ constraints = (
78
+ f"One task = one PR: one branch, one pull request. Commit and push the branch of the workspace "
79
+ f"you were given (if you create one yourself, name it `{branch}`); never commit or push to "
80
+ f"`{base}`. Work only inside {profile['repo']}; no other repository, no infrastructure/GitOps "
81
+ f"changes. "
82
+ f"EVIDENCE BUNDLE (mandatory, all of it, verbatim output with exit code): {commands}. "
83
+ "Paste each command with its real output and an `exit=0` line; a command you could not run is "
84
+ "reported as `NOT RUN` with the reason, never omitted and never replaced by a guess. A missing "
85
+ "command is an automatic REJECT at review even when the change is correct. The bundle is the "
86
+ "floor, not proof of completeness: re-derive it from the repository rules and run any gate they "
87
+ "require beyond this list, reporting it in the same format. "
88
+ "No secrets/keys in code, commits, PR body or the transcript. No direct CLI-agent invocations. "
89
+ "Return exactly five sections: Branch and commit / Pull request / Evidence / Files changed / "
90
+ "Not done or not verified."
91
+ )
92
+ objective = (f"Resolve {profile['repo']} issue #{issue.get('number')}: {issue.get('title')}. "
93
+ f"Acceptance criteria are in the issue body below; prove every gate the repository "
94
+ f"requires before opening the PR.")
95
+ locations = ", ".join((profile.get("sources_of_truth") or []) + [f"issue #{issue.get('number')}"])
96
+ return {
97
+ "backend": backend,
98
+ "mode": mode,
99
+ "task": {
100
+ "briefing": render_brief(profile_name, profile, issue),
101
+ "objective": objective,
102
+ "locations": locations,
103
+ "constraints": constraints,
104
+ # The fifth part was missing: a dispatcher that gets four of five fields is entitled to treat
105
+ # the answer as off-contract, and one live run already did.
106
+ "output_contract": (
107
+ "Exactly five sections, in this order: 'Branch and commit' (branch name + head SHA), "
108
+ "'Pull request' (number or URL, or why there is none), 'Evidence' (every mandatory command "
109
+ "with its real output and an `exit=N` line of its own), 'Files changed' (paths, one per "
110
+ "line), 'Not done or not verified' (anything you skipped, with the reason). The transcript "
111
+ "is the deliverable: prose without the commands is not evidence."),
112
+ },
113
+ "files": list(profile.get("sources_of_truth") or []),
114
+ }
115
+
116
+
117
+ # ---------------------------------------------------------------- brief
118
+
119
+ def render_brief(profile_name: str, profile: dict, issue: dict) -> str:
120
+ """Render the brief handed to the worker (also used as the kanban task body)."""
121
+ number = issue.get("number")
122
+ title = (issue.get("title") or "").strip()
123
+ body = (issue.get("body") or "").strip()
124
+ url = issue.get("url") or ""
125
+ labels = ", ".join(sorted(issue.get("labels") or [])) or "(none)"
126
+ repo = profile["repo"]
127
+ base = profile.get("base_branch", "main")
128
+ branch = (profile.get("branch_pattern") or "<role>/issue-<n>").replace("<n>", str(number))
129
+ bundle = bundle_commands(profile)
130
+
131
+ lines: list[str] = []
132
+ add = lines.append
133
+ add(f"# Worker brief — {repo} issue #{number}")
134
+ add("")
135
+ add("You are the implementer for exactly ONE task. The task is done when the evidence below is")
136
+ add("complete, not when the change looks right.")
137
+ add("")
138
+ add("## Goal")
139
+ add("")
140
+ add(title)
141
+ add("")
142
+ if body:
143
+ add("## Issue body (binding acceptance criteria)")
144
+ add("")
145
+ for raw in body.splitlines():
146
+ add("> " + raw if raw.strip() else ">")
147
+ add("")
148
+ add("## Sources of truth")
149
+ add("")
150
+ st = profile.get("sources_of_truth") or []
151
+ if st:
152
+ for rel in st:
153
+ add(f"- `{rel}` in {repo} — repository rules; read before editing.")
154
+ add(f"- GitHub issue #{number} ({url or 'see the task board'}) — its acceptance criteria are binding.")
155
+ add(f"- Labels: {labels}")
156
+ add("")
157
+ add("## Constraints")
158
+ add("")
159
+ add(f"- One task = one PR: one branch, one pull request. Commit and push the branch of the workspace")
160
+ add(f" you were given (a project worktree already has a dedicated branch; if you create one yourself,")
161
+ add(f" name it `{branch}`). Never commit or push to `{base}`.")
162
+ add(f"- Work only inside {repo}. No other repository, no infrastructure/GitOps changes.")
163
+ add("- Touch only the files this task needs: no drive-by refactors, renames or reformatting.")
164
+ add("- Repair is bounded: if one command is red three times in a row, stop and hand over with its")
165
+ add(" output. A fourth attempt is not allowed, and a transcript with three red runs of the same")
166
+ add(" command is rejected — a loop that cannot stop is the failure this repository exists to catch.")
167
+ add("- No secrets, keys or tokens in code, commits, the PR body or the evidence transcript.")
168
+ add("- Do the work in one pass; do not stop to request approval of a plan. This task arrives already")
169
+ add(" approved and timeboxed — a worker that answers with a plan file instead of a diff has produced")
170
+ add(" nothing that can be verified, and the measured cap treats it as an unfinished run.")
171
+ add("- No direct CLI-agent invocations anywhere in the repository (its own guardrail scans for that).")
172
+ add("- If the workspace is not a clone of " + repo + " at " + base + ": stop and report — do not clone,")
173
+ add(" and do not write outside the workspace.")
174
+ add("")
175
+ add("## Done-when")
176
+ add("")
177
+ for item in bundle:
178
+ add(f"- `{item['command']}` exits 0")
179
+ add(f"- The branch is pushed. Open the pull request against `{base}` yourself when `gh auth status`")
180
+ add(" succeeds in your environment; otherwise report branch + head SHA and the orchestrator opens it.")
181
+ add(" Either way the PR body carries the evidence bundle below, verbatim.")
182
+ add("")
183
+ add("## Evidence bundle — MANDATORY, ALL of it, verbatim output + exit code")
184
+ add("")
185
+ add("This bundle is every gate this repository requires, not a subset. A missing command is an")
186
+ add("automatic REJECT at review even when the change itself is correct — the reviewer re-runs the")
187
+ add("bundle in a separate worktree of your head commit and will find the gap.")
188
+ add("")
189
+ add("| # | command | why this repository requires it |")
190
+ add("|---|---|---|")
191
+ for i, item in enumerate(bundle, 1):
192
+ add(f"| {i} | `{item['command']}` | {item['source']} |")
193
+ add("")
194
+ add("Transcript format — paste the real terminal output, then the exit code, per command:")
195
+ add("")
196
+ add("```text")
197
+ add("$ <command>")
198
+ add("<verbatim output; do not summarize, do not truncate>")
199
+ add("exit=0")
200
+ add("```")
201
+ add("")
202
+ add("Rules for the transcript:")
203
+ add("")
204
+ add("- Every command above appears, with its exit code on a line of its own (`exit=0`) **inside that")
205
+ add(" command's own block** — before the next command line. An exit code borrowed from a neighbouring")
206
+ add(" command proves nothing and is treated as missing.")
207
+ add("- A command you could not run is reported as `NOT RUN` plus the reason — never omitted, never")
208
+ add(" replaced by a guess about what it would have printed.")
209
+ add("- A non-zero exit code is reported as it happened; then fix the cause and re-run that command.")
210
+ add("- The same transcript goes into the PR body and into your final answer. Verdicts, adjectives and")
211
+ add(" summaries do not substitute for it.")
212
+ add("")
213
+ add("## Completeness of this list")
214
+ add("")
215
+ source = profile.get("bundle_source") or (profile.get("sources_of_truth") or ["the repository rules"])[0]
216
+ add(f"The list above is the floor, not a proof of completeness: it is derived from {source}.")
217
+ add(f"Re-read that source yourself. If it requires a gate this list does not name, run it too and")
218
+ add("report it in the same format; if the repository's own rules are stricter than this list, the")
219
+ add("stricter reading wins. Say explicitly in section 5 which gates you ran beyond the list, and name")
220
+ add("any requirement you found but could not satisfy.")
221
+ add("")
222
+ add("## Review expectations")
223
+ add("")
224
+ for rule in profile.get("pr_rules") or []:
225
+ add(f"- {rule}")
226
+ add("- State in the PR body the concrete acceptance criteria you implemented against. A triage label")
227
+ add(" is the triage role's approval of the issue, not a substitute for criteria you can be held to.")
228
+ add("")
229
+ add("## Output contract — return exactly these five sections, in this order")
230
+ add("")
231
+ add("1. `## Branch and commit` — branch name and commit SHA.")
232
+ add("2. `## Pull request` — PR URL and title, or, when `gh auth status` did not succeed in your")
233
+ add(" environment, the branch + SHA you pushed and the PR body you propose.")
234
+ add("3. `## Evidence` — the transcript above, verbatim.")
235
+ add("4. `## Files changed` — output of `git diff --stat <base>...HEAD`.")
236
+ add("5. `## Not done / not verified` — anything unfinished, skipped or unverifiable, stated plainly.")
237
+ add("")
238
+ add("No preamble. No verdict about your own work. If a prerequisite above is missing, say so in")
239
+ add("section 5 and stop instead of improvising.")
240
+ add("")
241
+ return "\n".join(lines)
242
+
243
+
244
+ # ---------------------------------------------------------------- validator
245
+
246
+ def _command_lines(transcript: str, commands: list[str]) -> list[tuple[int, str, bool]]:
247
+ """``(index, text, is_command)`` per non-empty line, with the bundle commands marked.
248
+
249
+ The marker is what bounds each command's evidence: a command owns only the lines up to the next
250
+ command, so an exit code can never be borrowed from a neighbour.
251
+ """
252
+ wanted = set(commands)
253
+ out = []
254
+ for i, raw in enumerate(transcript.splitlines()):
255
+ stripped = PROMPT_RE.sub("", raw).strip()
256
+ if stripped:
257
+ out.append((i, stripped, stripped in wanted))
258
+ return out
259
+
260
+
261
+ def verify_evidence(profile: dict, transcript: str) -> list[str]:
262
+ """Return the reasons the transcript is NOT acceptable; an empty list means accepted.
263
+
264
+ A command counts as proven only when its own line is present verbatim and an exit code appears on
265
+ its own line inside that command's own block (up to the next command). Every occurrence is judged,
266
+ not just the first: a rerun that failed after a passing run is still a failed gate, and a `NOT RUN`
267
+ marker anywhere in the block disqualifies it. Anything else is 'not proven', which is exactly the gap
268
+ that made the first PR review reject the worker.
269
+ """
270
+ reasons: list[str] = []
271
+ if not transcript or not transcript.strip():
272
+ return ["evidence transcript is empty: nothing is proven"]
273
+ bundle = bundle_commands(profile)
274
+ lines = _command_lines(transcript, [i["command"] for i in bundle])
275
+ for item in bundle:
276
+ command = item["command"]
277
+ matches = [i for i, (_, text, is_cmd) in enumerate(lines) if is_cmd and text == command]
278
+ if not matches:
279
+ reasons.append(f"`{command}` was not run (no verbatim command line in the transcript)")
280
+ continue
281
+ red_runs = 0
282
+ for occurrence, idx in enumerate(matches, 1):
283
+ tail = lines[idx + 1:]
284
+ end = next((n for n, line in enumerate(tail) if line[2]), len(tail))
285
+ block = tail[:end]
286
+ where = f"`{command}`" + (f" (occurrence {occurrence} of {len(matches)})" if len(matches) > 1 else "")
287
+ codes = [m.group(1) for _, text, _ in block for m in [EXIT_LINE_RE.match(text)] if m]
288
+ if not codes:
289
+ reasons.append(f"{where} has no exit code on a line of its own in its own block "
290
+ f"(expected `exit=0` before the next command)")
291
+ continue
292
+ if codes[0] != "0":
293
+ red_runs += 1
294
+ reasons.append(f"{where} exited {codes[0]}, expected 0")
295
+ if any(NOT_RUN_RE.match(text) for _, text, _ in block):
296
+ reasons.append(f"{where} is marked NOT RUN")
297
+ # Bounded repair: the brief allows two red runs, the third is a handover. A worker that kept
298
+ # retrying is not more diligent — it is the loop that never stops, with the bill to match.
299
+ if red_runs >= 3:
300
+ reasons.append(f"`{command}` was red {red_runs} times: repair is bounded at three attempts, "
301
+ "the worker had to hand over with the output instead of retrying")
302
+ return reasons
303
+
304
+
305
+ # ---------------------------------------------------------------- CLI
306
+
307
+ def _issue_from_args(args, profile: dict | None = None) -> dict:
308
+ body = args.body or ""
309
+ if args.body_file:
310
+ with open(args.body_file, encoding="utf-8") as fh:
311
+ body = fh.read()
312
+ return {
313
+ "number": args.issue,
314
+ "title": args.title or f"issue #{args.issue}",
315
+ "body": body,
316
+ # The fallback used to be the literal string OWNER/REPO, which then travelled into the worker's
317
+ # brief as a fake link. The profile knows the repository, so ask it.
318
+ "url": args.url or f"https://github.com/{args.repo or (profile or {}).get('repo') or 'OWNER/REPO'}"
319
+ f"/issues/{args.issue}",
320
+ "labels": [l.strip() for l in (args.labels or "").split(",") if l.strip()],
321
+ }
322
+
323
+
324
+ def main(argv=None) -> int:
325
+ ap = argparse.ArgumentParser(description="Worker brief renderer and evidence-bundle validator.")
326
+ ap.add_argument("--root", default=ROOT)
327
+ sub = ap.add_subparsers(dest="cmd", required=True)
328
+
329
+ sub.add_parser("profiles", help="list known repository profiles")
330
+
331
+ b = sub.add_parser("build", help="render the brief for one issue")
332
+ b.add_argument("--profile", required=True)
333
+ b.add_argument("--repo", help="override the repository slug used in the issue URL")
334
+ b.add_argument("--issue", type=int, required=True)
335
+ b.add_argument("--title", default="")
336
+ b.add_argument("--body", default="")
337
+ b.add_argument("--body-file")
338
+ b.add_argument("--url", default="")
339
+ b.add_argument("--labels", default="")
340
+ b.add_argument("--format", choices=["markdown", "ywcrew-json"], default="markdown",
341
+ help="markdown brief, or the ywcrew five-part task spec with the same brief inside")
342
+ b.add_argument("--backend", default="harness", help="ywcrew backend for --format ywcrew-json")
343
+ b.add_argument("--mode", default="edit", choices=["edit", "read-only"],
344
+ help="ywcrew mode for --format ywcrew-json")
345
+ b.add_argument("--out", help="write the brief here instead of stdout")
346
+
347
+ v = sub.add_parser("verify", help="check an evidence transcript against the profile bundle")
348
+ v.add_argument("--profile", required=True)
349
+ v.add_argument("--evidence", required=True)
350
+ v.add_argument("--json", action="store_true")
351
+
352
+ args = ap.parse_args(argv)
353
+ if args.cmd == "profiles":
354
+ for name, prof in sorted(load_profiles(args.root).items()):
355
+ bundle = ", ".join(i["command"] for i in bundle_commands(prof))
356
+ print(f"{name}\t{prof['repo']}\t{bundle}")
357
+ return 0
358
+ try:
359
+ profile = get_profile(args.root, args.profile)
360
+ except (KeyError, OSError, json.JSONDecodeError) as exc:
361
+ print(f"profile error: {exc}", file=sys.stderr)
362
+ return 2
363
+
364
+ if args.cmd == "build":
365
+ issue = _issue_from_args(args, profile)
366
+ if args.format == "ywcrew-json":
367
+ payload = render_ywcrew_task(args.profile, profile, issue, args.backend, args.mode)
368
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
369
+ else:
370
+ text = render_brief(args.profile, profile, issue)
371
+ if args.out:
372
+ with open(args.out, "w", encoding="utf-8") as fh:
373
+ fh.write(text)
374
+ print(f"brief: {args.out} ({len(text)} chars, format={args.format})")
375
+ else:
376
+ sys.stdout.write(text)
377
+ return 0
378
+
379
+ text = open(args.evidence, encoding="utf-8").read()
380
+ reasons = verify_evidence(profile, text)
381
+ if args.json:
382
+ print(json.dumps({"profile": args.profile, "ok": not reasons, "reasons": reasons}, indent=2))
383
+ else:
384
+ for reason in reasons:
385
+ print(f"[MISSING] {reason}")
386
+ if reasons:
387
+ print(f"evidence incomplete: {len(reasons)} gap(s) — a reviewer will reject this bundle")
388
+ print(STOP_MARKER)
389
+ return 1
390
+ print("evidence bundle complete: every mandatory command ran with a visible exit code")
391
+ print(PASS_MARKER)
392
+ return 0
393
+
394
+
395
+ if __name__ == "__main__":
396
+ sys.exit(main())
loopgate/init.py ADDED
@@ -0,0 +1,128 @@
1
+ """``loopgate init`` — put the loop contract and cold-start state into a project.
2
+
3
+ What it writes, and why each piece exists:
4
+
5
+ * ``.agents/skills/loop-engineering/SKILL.md`` — the contract skill (D01), copied from the installed package.
6
+ * ``AGENTS.md`` / ``PLANS.md`` / ``README.md`` — created only if absent, from the package templates (D02).
7
+ Existing files are never overwritten: a project's own rules win.
8
+ * ``configs/`` defaults the gates read when present — created only if absent.
9
+ * ``artifacts/loop_state.json`` — a *cold* declaration (``bootstrap_state --init``), never a fabricated PASS.
10
+
11
+ Expected first verdict: STOP, not COLD. COLD requires that every failing dimension is a history dimension;
12
+ an unconfigured project also fails D10–D14 (its own facts), and those are never waved through.
13
+
14
+ What it deliberately does NOT write: round artifacts, a handoff, or an anchor. Those are evidence of a run;
15
+ writing them here would make the gate pass on a history that never happened.
16
+
17
+ The contract text in AGENTS.md is a template with placeholders. The gate only checks that a field is
18
+ non-empty, so a placeholder would pass D01/D02 while proving nothing. ``init`` therefore reports that the
19
+ contract still needs the project's own Goal / Sources / Constraints / Done-when, and does not pretend otherwise.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+
29
+ from loopgate.paths import TEMPLATES_DIR
30
+
31
+ SKILL_REL = os.path.join(".agents", "skills", "loop-engineering", "SKILL.md")
32
+ SKILL_TEMPLATE = os.path.join(TEMPLATES_DIR, "skills", "loop-engineering", "SKILL.md")
33
+
34
+ # Files the gates require at the project root. Created only when absent.
35
+ ROOT_TEMPLATES = {
36
+ "AGENTS.md": (
37
+ "# AGENTS.md\n\n"
38
+ "## Контракт петли\n\n"
39
+ "1. **Goal** — что должно стать правдой.\n"
40
+ "2. **Sources of truth** — конкретные файлы/команды, которым верим. Не выдумывать evidence.\n"
41
+ "3. **Constraints** — границы правок: что можно менять, что нельзя.\n"
42
+ "4. **Done-when** — воспроизводимая команда и маркер, который она печатает. Без маркера задача не закрыта.\n\n"
43
+ "## Стоп-условия (Stop-if)\n\n"
44
+ "Раунд останавливается и вопрос уходит оператору, если нужного файла нет, шаг требует сети или секретов,\n"
45
+ "гейт не зеленеет после трёх раундов ремонта, или evidence пришлось бы выдумать.\n\n"
46
+ "Гейт: `loopgate/gates/check_loop.py` (`python3 -m loopgate.gates.check_loop --strict`) —\n"
47
+ "`LOOP_ENGINEERING_PASS_0001` (exit 0).\n"
48
+ ),
49
+ "PLANS.md": (
50
+ "# PLANS.md\n\n"
51
+ "Гейт: `loopgate/gates/check_loop.py` (`python3 -m loopgate.gates.check_loop --strict`) →\n"
52
+ "`LOOP_ENGINEERING_PASS_0001`, exit 0.\n\n"
53
+ "## Этапы\n\n"
54
+ "1. (заполнить: что делаем, критерий приёмки, команда проверки)\n"
55
+ ),
56
+ "README.md": (
57
+ "# Project\n\n"
58
+ "Гейт: `loopgate/gates/check_loop.py` (`python3 -m loopgate.gates.check_loop --strict`).\n"
59
+ "Петля принимается, когда он печатает `LOOP_ENGINEERING_PASS_0001` (exit 0). До первого раунда\n"
60
+ "честный ответ — `LOOP_ENGINEERING_COLD_0001`.\n"
61
+ ),
62
+ }
63
+
64
+
65
+ # What a project must supply itself. These are facts about the project (its commands, its signing key, its
66
+ # product scenarios), so init cannot generate them honestly; it names them instead.
67
+ NEEDS_PROJECT = [
68
+ ("D10", "configs/repo_profiles.json — the repo's evidence bundle (the commands a worker must prove)"),
69
+ ("D11", "configs/product_scenarios.json — what a user must be able to do, and why it matters"),
70
+ ("D12", "configs/allowed_signers + configs/anchor_witness.json — the public key that signs the verdict"),
71
+ ("D13", "configs/visual_scenarios.json — what a person must see on the status page"),
72
+ ("D14", "configs/change_health.json + change_health_baseline.json — metrics and their recorded baseline"),
73
+ ]
74
+
75
+
76
+ def _write_if_absent(path: str, text: str, created: list[str], kept: list[str]) -> None:
77
+ if os.path.exists(path):
78
+ kept.append(os.path.relpath(path))
79
+ return
80
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
81
+ with open(path, "w", encoding="utf-8") as fh:
82
+ fh.write(text)
83
+ created.append(os.path.relpath(path))
84
+
85
+
86
+ def init_project(root: str, args: list[str]) -> int:
87
+ force = "--force" in args
88
+ created: list[str] = []
89
+ kept: list[str] = []
90
+
91
+ skill_dst = os.path.join(root, SKILL_REL)
92
+ if os.path.exists(skill_dst) and not force:
93
+ kept.append(SKILL_REL)
94
+ else:
95
+ os.makedirs(os.path.dirname(skill_dst), exist_ok=True)
96
+ shutil.copyfile(SKILL_TEMPLATE, skill_dst)
97
+ created.append(SKILL_REL)
98
+
99
+ for rel, text in ROOT_TEMPLATES.items():
100
+ _write_if_absent(os.path.join(root, rel), text, created, kept)
101
+
102
+ # Cold start: the tree declares it has not run the loop yet. Refuses if round/handoff artifacts exist.
103
+ proc = subprocess.run([sys.executable, "-m", "loopgate.gates.bootstrap_state", "--init", "--root", root],
104
+ capture_output=True, text=True, cwd=root)
105
+ cold_ok = proc.returncode == 0
106
+ if cold_ok:
107
+ created.append("artifacts/loop_state.json (cold)")
108
+ else:
109
+ kept.append("artifacts/loop_state.json (not changed: " + (proc.stderr or proc.stdout).strip()[:160] + ")")
110
+
111
+ print(f"loopgate init — project: {root}")
112
+ for item in created:
113
+ print(f" created {item}")
114
+ for item in kept:
115
+ print(f" kept {item}")
116
+ print()
117
+ print("Not configured by init (the gate will report these as FAIL until the project provides them):")
118
+ for dim, what in NEEDS_PROJECT:
119
+ print(f" {dim} {what}")
120
+ print()
121
+ print("Next: fill Goal / Sources / Constraints / Done-when in AGENTS.md for this project.")
122
+ print("Expect STOP until D10–D14 are configured: the gate names each gap and never fakes a PASS for it.")
123
+ print("COLD is reported only when every red dimension is a history dimension (D03–D05, D09, D12).")
124
+ return 0 if cold_ok else 1
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(init_project(os.getcwd(), sys.argv[1:]))
loopgate/paths.py ADDED
@@ -0,0 +1,43 @@
1
+ """Where a gate looks for the project it judges, and where its own templates live.
2
+
3
+ Two different trees are involved and they must not be confused:
4
+
5
+ * the *tool* — this package, installed by pip, which contains the gates and the templates;
6
+ * the *project* — the repository being judged, which owns AGENTS.md, configs/, artifacts/, handoff/.
7
+
8
+ Gates therefore resolve the project root from the working directory (or an explicit ``--root``), never
9
+ from their own ``__file__``: an installed copy lives in site-packages and has no project next to it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ PACKAGE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17
+ GATES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gates")
18
+ TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
19
+ # A project is recognised by one of these; the nearest ancestor of the working directory wins.
20
+ PROJECT_MARKERS = (".git", "AGENTS.md")
21
+
22
+
23
+ def project_root(start: str | None = None) -> str:
24
+ """Nearest ancestor of ``start`` (default: cwd) that looks like a project root; else ``start`` itself."""
25
+ here = os.path.realpath(start or os.getcwd())
26
+ cur = here
27
+ while True:
28
+ if any(os.path.exists(os.path.join(cur, m)) for m in PROJECT_MARKERS):
29
+ return cur
30
+ parent = os.path.dirname(cur)
31
+ if parent == cur:
32
+ return here
33
+ cur = parent
34
+
35
+
36
+ def gate_module(name: str) -> str:
37
+ """Dotted module path of a gate, for ``python -m`` invocation."""
38
+ return f"loopgate.gates.{name}"
39
+
40
+
41
+ def gate_file(name: str) -> str:
42
+ """Absolute path of a gate source file inside the installed package."""
43
+ return os.path.join(GATES_DIR, f"{name}.py")
loopgate/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Packaged templates: example profiles, tree-parity policy and review fixtures. Data only."""
@@ -0,0 +1,14 @@
1
+ {
2
+ "signing_key": "~/.ssh/loop_anchor_ed25519",
3
+ "identity": "loop-anchor@example.org",
4
+ "namespace": "loop-anchor",
5
+ "allowed_signers": "configs/allowed_signers",
6
+ "marker": "LOOP_ANCHOR_WITNESS",
7
+ "destination": {
8
+ "kind": "issue",
9
+ "repo": "example/loop-witness",
10
+ "issue": 1,
11
+ "note": "Put your own witness journal here: one issue in a repository you do not develop in, one comment per anchor digest. A gist also works if your token has the gist scope. The point is a timestamp this machine does not control."
12
+ },
13
+ "note": "EXAMPLE — replace with your own before signing. Generate a purpose-limited, passphrase-free key (ssh-keygen -t ed25519 -f ~/.ssh/loop_anchor_ed25519 -N ''), publish its public half in configs/allowed_signers, and pick an external repository you do not own the history of. The key proves the anchor was written by its holder; the external comment gives the digest a timestamp this machine cannot rewrite."
14
+ }
@@ -0,0 +1,75 @@
1
+ {
2
+ "note": "Offline fixture for the issue bridge. Synthetic issues used to prove the deterministic classification and the rendered brief without touching GitHub. Numbers are deliberately high (9xx) so a fixture run can never collide with a real sample-service issue in a task's idempotency key. The bridge refuses --apply for a fixture source.",
3
+ "repo": "example/sample-service",
4
+ "issues": [
5
+ {
6
+ "number": 901,
7
+ "title": "Renderer drops the last frame of a multi-shot assembly",
8
+ "url": "https://github.com/example/sample-service/issues/901",
9
+ "state": "OPEN",
10
+ "labels": [
11
+ {
12
+ "name": "triaged"
13
+ },
14
+ {
15
+ "name": "bug"
16
+ }
17
+ ],
18
+ "body": "### Steps to reproduce\n1. Assemble a 3-shot timeline with a 4.2 s last shot.\n2. Run the renderer with default settings.\n\n### Expected\nAll frames of the last shot are present.\n\n### Acceptance\n- The assembled file contains the final frame of every shot.\n- `go test -count=1 ./internal/render/...` covers the boundary case.\n\n### Actual\nThe final ~0.4 s of the last shot is missing; the timeline ends early."
19
+ },
20
+ {
21
+ "number": 902,
22
+ "title": "Add H.265 export profile",
23
+ "url": "https://github.com/example/sample-service/issues/902",
24
+ "state": "OPEN",
25
+ "labels": [
26
+ {
27
+ "name": "feature"
28
+ }
29
+ ],
30
+ "body": "### Motivation\nExporting H.265 halves the storage cost for archived renders.\n\n### Acceptance criteria\n- A `h265` profile is selectable in the export config.\n- Exports round-trip: decode of the produced file matches the source resolution and frame count.\n- Existing profiles are unchanged."
31
+ },
32
+ {
33
+ "number": 903,
34
+ "title": "it broke",
35
+ "url": "https://github.com/example/sample-service/issues/903",
36
+ "state": "OPEN",
37
+ "labels": [
38
+ {
39
+ "name": "bug"
40
+ }
41
+ ],
42
+ "body": "doesn't work"
43
+ },
44
+ {
45
+ "number": 904,
46
+ "title": "Renderer crashes on unrelated flaky test",
47
+ "url": "https://github.com/example/sample-service/issues/904",
48
+ "state": "OPEN",
49
+ "labels": [
50
+ {
51
+ "name": "invalid"
52
+ },
53
+ {
54
+ "name": "bug"
55
+ }
56
+ ],
57
+ "body": "### Steps to reproduce\nRun the suite twice; a test fails once.\n\n### Acceptance\n- Isolated failure is not a renderer defect; closed with the run log attached to prove flakiness rather than a code path."
58
+ },
59
+ {
60
+ "number": 905,
61
+ "title": "Export dialog shows the wrong duration for a 60 s clip",
62
+ "url": "https://github.com/example/sample-service/issues/905",
63
+ "state": "OPEN",
64
+ "labels": [
65
+ {
66
+ "name": "qa"
67
+ },
68
+ {
69
+ "name": "ux"
70
+ }
71
+ ],
72
+ "body": "### Steps to reproduce\n1. Open the export dialog for a 60 s clip.\n2. Read the duration shown next to the profile selector.\n\n### Expected\nThe dialog shows 60 s (00:01:00).\n\n### Acceptance\n- The dialog shows the clip's real duration for 60 s and 3 600 s clips.\n- A screenshot of both states is attached to the issue before it is closed.\n\n### Actual\nIt shows 00:00:60.\n\n### Filed by\nqa role, visual check of the export dialog (screenshot attached to the PR)."
73
+ }
74
+ ]
75
+ }