loop-engineer 0.7.0__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 (37) hide show
  1. loop/__init__.py +17 -0
  2. loop/__main__.py +176 -0
  3. loop/_bundle/schemas/manifest.schema.json +41 -0
  4. loop/_bundle/schemas/receipt.schema.json +21 -0
  5. loop/_bundle/schemas/repair-record.schema.json +42 -0
  6. loop/_bundle/schemas/rollout-record.schema.json +27 -0
  7. loop/_bundle/schemas/state.schema.json +25 -0
  8. loop/_bundle/schemas/tasks.schema.json +30 -0
  9. loop/_bundle/schemas/terminal.schema.json +19 -0
  10. loop/_bundle/templates/AGENTS.md.tmpl +67 -0
  11. loop/_bundle/templates/EVALS-rubric.md.tmpl +114 -0
  12. loop/_bundle/templates/RUNLOG.md.tmpl +42 -0
  13. loop/_bundle/templates/SPEC.md.tmpl +61 -0
  14. loop/_bundle/templates/TASKS.json.tmpl +23 -0
  15. loop/_bundle/templates/WORKFLOW.md.tmpl +84 -0
  16. loop/_bundle/templates/extract-trace-metrics.sh +27 -0
  17. loop/_bundle/templates/judge-rubric.sh +28 -0
  18. loop/_bundle/templates/manifest.yaml.tmpl +47 -0
  19. loop/_bundle/templates/state.json.tmpl +38 -0
  20. loop/_bundle/templates/terminal_state.json.tmpl +13 -0
  21. loop/_bundle/templates/verify-fast.sh +27 -0
  22. loop/_bundle/templates/verify-full.sh +34 -0
  23. loop/_bundle/templates/verify-safety.sh +35 -0
  24. loop/_bundle/tools/anticheat_scan.py +490 -0
  25. loop/_bundle/tools/holdout_gate.py +121 -0
  26. loop/_bundle/tools/inspect_loop.py +669 -0
  27. loop/_bundle/tools/metrics.py +725 -0
  28. loop/_resources.py +43 -0
  29. loop/contract.py +577 -0
  30. loop/emit.py +258 -0
  31. loop/paths.py +77 -0
  32. loop/scaffold.py +131 -0
  33. loop_engineer-0.7.0.dist-info/METADATA +470 -0
  34. loop_engineer-0.7.0.dist-info/RECORD +37 -0
  35. loop_engineer-0.7.0.dist-info/WHEEL +4 -0
  36. loop_engineer-0.7.0.dist-info/entry_points.txt +3 -0
  37. loop_engineer-0.7.0.dist-info/licenses/LICENSE +21 -0
loop/emit.py ADDED
@@ -0,0 +1,258 @@
1
+ """Writer API for foreign runtimes (B1). A writer, never a runtime: it renders
2
+ contract artifacts and refuses dishonest ones — no orchestration, no execution.
3
+
4
+ The G1 cross-check (a Succeeded terminal needs evidence and a met criterion)
5
+ is enforced HERE, at write time, before doctor ever sees the file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Sequence
16
+
17
+ from .contract import (
18
+ TERMINAL_STATES,
19
+ _validate_record,
20
+ _validate_terminal,
21
+ _validation_mode,
22
+ )
23
+ from .paths import resolve_loop_paths
24
+ from .scaffold import scaffold
25
+
26
+ _ITERATION_OUTCOMES = (
27
+ "task_passed",
28
+ "task_failed",
29
+ "repair_triggered",
30
+ "approval_requested",
31
+ "replanned",
32
+ "terminal",
33
+ )
34
+ _RECEIPT_ROLES = ("read", "reason", "write", "orchestrate")
35
+ _RECEIPT_OUTCOMES = ("ok", "fail", "escalated")
36
+
37
+
38
+ class EmitError(ValueError):
39
+ """A write was refused: it would produce a dishonest or schema-invalid artifact."""
40
+
41
+
42
+ def open_contract(target: str | Path) -> dict[str, Any]:
43
+ """Render a fresh, doctor-clean contract. Delegates to the scaffold renderer."""
44
+ return scaffold(target)
45
+
46
+
47
+ def _require_contract(target: str | Path):
48
+ paths = resolve_loop_paths(target)
49
+ if not paths.state.is_file():
50
+ raise EmitError(
51
+ f"no loop contract at {paths.workspace} (missing .loop/state.json) — "
52
+ f"call emit.open_contract() first"
53
+ )
54
+ return paths
55
+
56
+
57
+ def _read_state(paths) -> dict[str, Any]:
58
+ try:
59
+ data = json.loads(paths.state.read_text(encoding="utf-8"))
60
+ except (OSError, json.JSONDecodeError) as exc:
61
+ raise EmitError(f"unreadable state.json: {exc}") from exc
62
+ if not isinstance(data, dict):
63
+ raise EmitError("state.json must hold a JSON object")
64
+ return data
65
+
66
+
67
+ def _atomic_write_text(path: Path, text: str) -> None:
68
+ """Whole-file write via a temp file in the SAME directory then os.replace, so a
69
+ crash mid-write can never leave truncated JSON. The temp file is removed on any
70
+ failure, leaving no litter."""
71
+ fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
72
+ try:
73
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
74
+ fh.write(text)
75
+ os.replace(tmp_name, path)
76
+ except BaseException:
77
+ try:
78
+ os.unlink(tmp_name)
79
+ except FileNotFoundError:
80
+ pass
81
+ raise
82
+
83
+
84
+ def _write_state(paths, state: dict[str, Any]) -> None:
85
+ _atomic_write_text(paths.state, json.dumps(state, indent=2) + "\n")
86
+
87
+
88
+ def append_iteration(
89
+ target: str | Path,
90
+ *,
91
+ iteration_id: int,
92
+ outcome: str,
93
+ task_id: str = "",
94
+ actions: Sequence[str] = (),
95
+ verify_cmd: str = "",
96
+ verify_outcome: str = "",
97
+ notes: str = "",
98
+ ) -> Path:
99
+ """Append one iteration block to RUNLOG.md (the shape scripts/metrics.py
100
+ parses: `## Iteration <id>` header + a backticked outcome token) and advance
101
+ .loop/state.json's iteration_id/active_task."""
102
+ if outcome not in _ITERATION_OUTCOMES:
103
+ raise EmitError(f"unknown iteration outcome {outcome!r}; expected one of {_ITERATION_OUTCOMES}")
104
+ paths = _require_contract(target)
105
+
106
+ lines = [
107
+ "",
108
+ f"## Iteration {iteration_id} — {datetime.now(timezone.utc).date().isoformat()}",
109
+ "",
110
+ ]
111
+ if task_id:
112
+ lines.append(f"**Active task:** `{task_id}`")
113
+ lines.append("")
114
+ if actions:
115
+ lines.append("### Actions taken")
116
+ lines.append("")
117
+ lines.extend(f"- {a}" for a in actions)
118
+ lines.append("")
119
+ if verify_cmd or verify_outcome:
120
+ lines.append("### Verification result")
121
+ lines.append("")
122
+ lines.append(f"- **Gate:** `{verify_cmd}` — {verify_outcome}")
123
+ lines.append("")
124
+ lines.append("### Outcome")
125
+ lines.append("")
126
+ lines.append(f"`{outcome}`")
127
+ lines.append("")
128
+ if notes:
129
+ lines.append("### Notes")
130
+ lines.append("")
131
+ lines.append(notes)
132
+ lines.append("")
133
+
134
+ runlog = paths.runlog
135
+ if not runlog.exists():
136
+ runlog = paths.workspace / "RUNLOG.md"
137
+ runlog.write_text(f"# RUNLOG.md — {paths.workspace.name}\n", encoding="utf-8")
138
+ with runlog.open("a", encoding="utf-8") as fh:
139
+ fh.write("\n".join(lines))
140
+
141
+ state = _read_state(paths)
142
+ state["iteration_id"] = str(iteration_id)
143
+ if task_id:
144
+ state["active_task"] = task_id
145
+ _write_state(paths, state)
146
+ return runlog
147
+
148
+
149
+ def append_receipt(
150
+ target: str | Path,
151
+ *,
152
+ iteration_id: int,
153
+ role: str,
154
+ model: str,
155
+ outcome: str,
156
+ dispatch_id: str | None = None,
157
+ tokens: int | None = None,
158
+ cost_usd: float | None = None,
159
+ ts: str | None = None,
160
+ ) -> Path:
161
+ """Append one loop-engineer/receipt@1 line to .loop/receipts/receipts.jsonl."""
162
+ if role not in _RECEIPT_ROLES:
163
+ raise EmitError(f"unknown receipt role {role!r}; expected one of {_RECEIPT_ROLES}")
164
+ if outcome not in _RECEIPT_OUTCOMES:
165
+ raise EmitError(f"unknown receipt outcome {outcome!r}; expected one of {_RECEIPT_OUTCOMES}")
166
+ if not isinstance(iteration_id, int) or isinstance(iteration_id, bool) or iteration_id < 0:
167
+ raise EmitError("iteration_id must be a non-negative integer")
168
+ paths = _require_contract(target)
169
+
170
+ record: dict[str, Any] = {
171
+ "schema": "loop-engineer/receipt@1",
172
+ "iteration_id": iteration_id,
173
+ "dispatch_id": dispatch_id,
174
+ "role": role,
175
+ "model": model,
176
+ "outcome": outcome,
177
+ "tokens": tokens,
178
+ "cost_usd": cost_usd,
179
+ "ts": ts,
180
+ }
181
+ receipts = paths.loop_dir / "receipts" / "receipts.jsonl"
182
+ issues: list[dict] = []
183
+ _validate_record(record, "receipt", receipts, _validation_mode(), issues)
184
+ if issues:
185
+ raise EmitError(f"receipt failed schema validation: {issues}")
186
+ receipts.parent.mkdir(parents=True, exist_ok=True)
187
+ with receipts.open("a", encoding="utf-8") as fh:
188
+ fh.write(json.dumps(record, sort_keys=True) + "\n")
189
+ return receipts
190
+
191
+
192
+ def terminate(
193
+ target: str | Path,
194
+ *,
195
+ state: str,
196
+ criteria_met: dict[str, bool],
197
+ evidence: list[str],
198
+ reason: str = "",
199
+ iteration_id: int | None = None,
200
+ false_completion: bool = False,
201
+ lessons_ref: str | None = None,
202
+ force: bool = False,
203
+ ) -> Path:
204
+ """Write .loop/terminal_state.json (and stamp state.json.terminal_state).
205
+
206
+ Refuses an evidence-free Succeeded — the G1 cross-check at write time:
207
+ Succeeded requires non-empty evidence, at least one met criterion, and
208
+ false_completion=False.
209
+
210
+ The terminal record is written once: a second terminate on an existing
211
+ terminal file is refused unless force=True (the deliberate-overwrite escape
212
+ hatch).
213
+ """
214
+ if state not in TERMINAL_STATES:
215
+ raise EmitError(f"unknown terminal state {state!r}; expected one of {TERMINAL_STATES}")
216
+ if state == "Succeeded":
217
+ if false_completion:
218
+ raise EmitError("refusing Succeeded with false_completion=True (G1 contradiction)")
219
+ if not evidence:
220
+ raise EmitError("refusing evidence-free Succeeded: evidence[] is empty (G1)")
221
+ if not any(v is True for v in criteria_met.values()):
222
+ raise EmitError("refusing Succeeded with no met (true) entry in criteria_met (G1)")
223
+ if not all(isinstance(v, bool) for v in criteria_met.values()):
224
+ raise EmitError("criteria_met values must be booleans")
225
+ paths = _require_contract(target)
226
+ terminal_path = paths.loop_dir / "terminal_state.json"
227
+ if terminal_path.is_file() and not force:
228
+ raise EmitError(
229
+ f"terminal already written at {terminal_path} — the terminal record is "
230
+ f"written once; pass force=True to deliberately overwrite it"
231
+ )
232
+ current = _read_state(paths)
233
+
234
+ terminal: dict[str, Any] = {
235
+ "schema": "loop-engineer/terminal@1",
236
+ "project": paths.workspace.name,
237
+ "state": state,
238
+ "criteria_met": dict(criteria_met),
239
+ "evidence": list(evidence),
240
+ "false_completion": false_completion,
241
+ "terminated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
242
+ }
243
+ if reason:
244
+ terminal["reason"] = reason
245
+ if iteration_id is not None:
246
+ terminal["iteration_id"] = iteration_id
247
+ if lessons_ref is not None:
248
+ terminal["lessons_ref"] = lessons_ref
249
+
250
+ issues: list[dict] = []
251
+ _validate_terminal(terminal, terminal_path, issues)
252
+ if issues:
253
+ raise EmitError(f"terminal failed validation before write: {issues}")
254
+
255
+ _atomic_write_text(terminal_path, json.dumps(terminal, indent=2) + "\n")
256
+ current["terminal_state"] = state
257
+ _write_state(paths, current)
258
+ return terminal_path
loop/paths.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class LoopPaths:
9
+ """Resolved filesystem paths for one loop contract instance."""
10
+
11
+ workspace: Path
12
+ loop_dir: Path
13
+ manifest: Path
14
+ state: Path
15
+ tasks: Path
16
+ runlog: Path
17
+ terminal: Path
18
+ spec: Path
19
+ workflow: Path
20
+ contract: Path
21
+
22
+ def to_json(self) -> dict[str, str]:
23
+ return {k: str(v) for k, v in asdict(self).items()}
24
+
25
+
26
+ def _workspace_from(target: Path) -> Path:
27
+ target = target.resolve()
28
+ if target.is_file():
29
+ # A FILE target (.loop/state.json, TASKS.json, RUNLOG.md, …) names a
30
+ # contract artifact, not the workspace. Resolve from its parent so the
31
+ # owning workspace is found instead of treating the file as a directory
32
+ # and building garbage paths underneath it.
33
+ target = target.parent
34
+ if target.name == ".loop":
35
+ return target.parent
36
+ if (target / ".loop").is_dir():
37
+ return target
38
+ if (target / "state.json").is_file() and target.parent.name != ".loop":
39
+ return target.parent
40
+ return target
41
+
42
+
43
+ def _first_existing(*paths: Path) -> Path:
44
+ for p in paths:
45
+ if p.exists():
46
+ return p
47
+ return paths[0]
48
+
49
+
50
+ def resolve_loop_paths(target: str | Path) -> LoopPaths:
51
+ """Resolve repo-OS contract paths from either workspace root or `.loop/`.
52
+
53
+ Canonical layout keeps RUNLOG.md at workspace root and machine state under
54
+ `.loop/`. The resolver accepts the historical `.loop/RUNLOG.md` fallback so
55
+ old test fixtures and partially migrated loops still produce actionable
56
+ reports instead of tracebacks.
57
+ """
58
+
59
+ workspace = _workspace_from(Path(target))
60
+ loop_dir = workspace / ".loop"
61
+ return LoopPaths(
62
+ workspace=workspace,
63
+ loop_dir=loop_dir,
64
+ manifest=_first_existing(loop_dir / "manifest.yaml", workspace / "manifest.yaml"),
65
+ state=loop_dir / "state.json",
66
+ tasks=_first_existing(workspace / "TASKS.json", loop_dir / "TASKS.json"),
67
+ runlog=_first_existing(workspace / "RUNLOG.md", loop_dir / "RUNLOG.md"),
68
+ terminal=_first_existing(loop_dir / "terminal_state.json", workspace / "terminal_state.json"),
69
+ # Resolve the prose contract files the same dual way as manifest/state so a
70
+ # loop whose SPEC/WORKFLOW live under .loop/ (the loop-engineer repo's own
71
+ # shape) is scored on substance, not missed for not being at the root.
72
+ spec=_first_existing(workspace / "SPEC.md", loop_dir / "SPEC.md"),
73
+ workflow=_first_existing(workspace / "WORKFLOW.md", loop_dir / "WORKFLOW.md"),
74
+ # A committed single-file contract (the Quiet Command shape) is a
75
+ # contract-owned artifact too.
76
+ contract=_first_existing(workspace / "loop-contract.md", loop_dir / "loop-contract.md"),
77
+ )
loop/scaffold.py ADDED
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ._resources import templates_dir
9
+
10
+
11
+ def _templates_dir() -> Path:
12
+ return templates_dir()
13
+
14
+
15
+ _PLACEHOLDER_RE = re.compile(r"\{\{[A-Z0-9_]+\}\}")
16
+
17
+ # Files copied verbatim (executable), keyed by template name -> destination.
18
+ _VERIFY_SCRIPTS = {
19
+ "verify-fast.sh": "scripts/verify-fast",
20
+ "verify-full.sh": "scripts/verify-full",
21
+ }
22
+
23
+ # Template -> destination for the filled contract files. terminal_state.json is
24
+ # intentionally absent: it is written once, at loop end, by loop-run.
25
+ _FILLED_FILES = {
26
+ "AGENTS.md.tmpl": "AGENTS.md",
27
+ "SPEC.md.tmpl": "SPEC.md",
28
+ "WORKFLOW.md.tmpl": "WORKFLOW.md",
29
+ "TASKS.json.tmpl": "TASKS.json",
30
+ "RUNLOG.md.tmpl": "RUNLOG.md",
31
+ "manifest.yaml.tmpl": ".loop/manifest.yaml",
32
+ "state.json.tmpl": ".loop/state.json",
33
+ }
34
+
35
+ # Existing-contract markers: any of these means the target already holds a loop
36
+ # contract and must not be silently overwritten.
37
+ _CONTRACT_MARKERS = ("SPEC.md", "WORKFLOW.md", "TASKS.json", ".loop/state.json")
38
+
39
+
40
+ def _substitutions(project: str) -> dict[str, str]:
41
+ """Honest, valid-by-construction defaults for every {{PLACEHOLDER}}.
42
+
43
+ Numeric/boolean/null slots (unquoted in the templates) map to bare JSON
44
+ tokens; everything else maps to a string. A fresh scaffold is a valid
45
+ in-flight contract, not a fake completed one.
46
+ """
47
+
48
+ return {
49
+ "PROJECT_NAME": project,
50
+ "LOOP_NAME": project,
51
+ "ITERATION_ID": "0",
52
+ "PLAN_VERSION": "1",
53
+ "ACTIVE_TASK_ID": "",
54
+ "STATE": "intake",
55
+ "BEST_SCORE": "null",
56
+ "FAILURE_MODE": "",
57
+ "PENDING_APPROVAL": "null",
58
+ "TIME_REMAINING": "",
59
+ "COST_REMAINING": "",
60
+ "CHECKPOINT_PATH": "",
61
+ "GOAL_DESCRIPTION": "REPLACE: one-line goal",
62
+ "CRITERION_1": "REPLACE: first success criterion",
63
+ "CONSTRAINT_1": "REPLACE: first constraint",
64
+ "WORKSPACE_PATH": ".",
65
+ "ALLOWED_TOOL_1": "Read",
66
+ "ALLOWED_TOOLS": "",
67
+ "RISK_PROFILE": "low",
68
+ "TIME_BUDGET": "",
69
+ "COST_BUDGET": "",
70
+ "APPROVAL_POLICY": "on_side_effects",
71
+ "REPAIR_ATTEMPTS": "0",
72
+ "REPAIR_CAP": "2",
73
+ "LAST_VERIFY_CMD": "",
74
+ "LAST_VERIFY_OUTCOME": "",
75
+ "LAST_SCORE": "null",
76
+ "EVIDENCE_PATH": "",
77
+ "SHORT_TERM_SUMMARY": "",
78
+ "LESSONS_PATH": "",
79
+ "PERMISSION_1": "read-only",
80
+ "APPROVAL_GATE_1": "on_side_effects",
81
+ "PLAN_THEN_EXECUTE": "true",
82
+ "TASK_ID": "T1",
83
+ "TASK_TITLE": "REPLACE: first task",
84
+ "TASK_STATUS": "pending",
85
+ "TASK_CRITERION_REF": "1",
86
+ "TASK_VERIFY": "scripts/verify-fast",
87
+ "CREATED_AT": "",
88
+ "UPDATED_AT": "",
89
+ }
90
+
91
+
92
+ def _fill(text: str, mapping: dict[str, str]) -> str:
93
+ def repl(match: re.Match[str]) -> str:
94
+ token = match.group(0)[2:-2]
95
+ return mapping.get(token, "REPLACE")
96
+
97
+ return _PLACEHOLDER_RE.sub(repl, text)
98
+
99
+
100
+ def _has_existing_contract(target: Path) -> bool:
101
+ return any((target / marker).exists() for marker in _CONTRACT_MARKERS)
102
+
103
+
104
+ def scaffold(target: str | Path) -> dict[str, Any]:
105
+ """Write a fresh, doctor-clean repo-OS contract into ``target``.
106
+
107
+ Refuses to overwrite an existing contract dir (a live loop owns its state).
108
+ """
109
+
110
+ target = Path(target)
111
+ if target.exists() and _has_existing_contract(target):
112
+ raise FileExistsError(f"contract already exists at {target}")
113
+
114
+ mapping = _substitutions(target.name)
115
+ written: list[str] = []
116
+
117
+ for template_name, rel in _FILLED_FILES.items():
118
+ dest = target / rel
119
+ dest.parent.mkdir(parents=True, exist_ok=True)
120
+ text = (_templates_dir() / template_name).read_text(encoding="utf-8")
121
+ dest.write_text(_fill(text, mapping), encoding="utf-8")
122
+ written.append(rel)
123
+
124
+ for template_name, rel in _VERIFY_SCRIPTS.items():
125
+ dest = target / rel
126
+ dest.parent.mkdir(parents=True, exist_ok=True)
127
+ shutil.copyfile(_templates_dir() / template_name, dest)
128
+ dest.chmod(0o755)
129
+ written.append(rel)
130
+
131
+ return {"ok": True, "target": str(target), "written": written}