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.
- loop/__init__.py +17 -0
- loop/__main__.py +176 -0
- loop/_bundle/schemas/manifest.schema.json +41 -0
- loop/_bundle/schemas/receipt.schema.json +21 -0
- loop/_bundle/schemas/repair-record.schema.json +42 -0
- loop/_bundle/schemas/rollout-record.schema.json +27 -0
- loop/_bundle/schemas/state.schema.json +25 -0
- loop/_bundle/schemas/tasks.schema.json +30 -0
- loop/_bundle/schemas/terminal.schema.json +19 -0
- loop/_bundle/templates/AGENTS.md.tmpl +67 -0
- loop/_bundle/templates/EVALS-rubric.md.tmpl +114 -0
- loop/_bundle/templates/RUNLOG.md.tmpl +42 -0
- loop/_bundle/templates/SPEC.md.tmpl +61 -0
- loop/_bundle/templates/TASKS.json.tmpl +23 -0
- loop/_bundle/templates/WORKFLOW.md.tmpl +84 -0
- loop/_bundle/templates/extract-trace-metrics.sh +27 -0
- loop/_bundle/templates/judge-rubric.sh +28 -0
- loop/_bundle/templates/manifest.yaml.tmpl +47 -0
- loop/_bundle/templates/state.json.tmpl +38 -0
- loop/_bundle/templates/terminal_state.json.tmpl +13 -0
- loop/_bundle/templates/verify-fast.sh +27 -0
- loop/_bundle/templates/verify-full.sh +34 -0
- loop/_bundle/templates/verify-safety.sh +35 -0
- loop/_bundle/tools/anticheat_scan.py +490 -0
- loop/_bundle/tools/holdout_gate.py +121 -0
- loop/_bundle/tools/inspect_loop.py +669 -0
- loop/_bundle/tools/metrics.py +725 -0
- loop/_resources.py +43 -0
- loop/contract.py +577 -0
- loop/emit.py +258 -0
- loop/paths.py +77 -0
- loop/scaffold.py +131 -0
- loop_engineer-0.7.0.dist-info/METADATA +470 -0
- loop_engineer-0.7.0.dist-info/RECORD +37 -0
- loop_engineer-0.7.0.dist-info/WHEEL +4 -0
- loop_engineer-0.7.0.dist-info/entry_points.txt +3 -0
- loop_engineer-0.7.0.dist-info/licenses/LICENSE +21 -0
loop/_resources.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Bundle-first resource resolution (S0).
|
|
2
|
+
|
|
3
|
+
A built wheel carries schemas/, templates/, and the CLI-needed tool scripts as
|
|
4
|
+
package data under loop/_bundle/ (see [tool.hatch.build.targets.wheel.force-include]
|
|
5
|
+
in pyproject.toml). An editable install / repo checkout has no _bundle, so each
|
|
6
|
+
resolver falls back to the historical repo-relative layout. Wheels install as
|
|
7
|
+
real directories, so Traversable -> Path is safe here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_REPO_FALLBACKS = {"schemas": "schemas", "templates": "templates", "tools": "scripts"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _bundle_root() -> Path | None:
|
|
19
|
+
try:
|
|
20
|
+
return Path(str(resources.files("loop") / "_bundle"))
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _data_dir(kind: str) -> Path:
|
|
26
|
+
root = _bundle_root()
|
|
27
|
+
if root is not None:
|
|
28
|
+
bundled = root / kind
|
|
29
|
+
if bundled.is_dir():
|
|
30
|
+
return bundled
|
|
31
|
+
return Path(__file__).resolve().parent.parent / _REPO_FALLBACKS[kind]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def schemas_dir() -> Path:
|
|
35
|
+
return _data_dir("schemas")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def templates_dir() -> Path:
|
|
39
|
+
return _data_dir("templates")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tools_dir() -> Path:
|
|
43
|
+
return _data_dir("tools")
|
loop/contract.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .paths import LoopPaths, resolve_loop_paths
|
|
8
|
+
|
|
9
|
+
TERMINAL_STATES = (
|
|
10
|
+
"Succeeded",
|
|
11
|
+
"FailedUnverifiable",
|
|
12
|
+
"FailedBlocked",
|
|
13
|
+
"FailedBudget",
|
|
14
|
+
"FailedSafety",
|
|
15
|
+
"FailedSpecGap",
|
|
16
|
+
"AbortedByHuman",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
SCHEMA_IDS = (
|
|
20
|
+
"loop-engineer/manifest@1",
|
|
21
|
+
"loop-engineer/state@1",
|
|
22
|
+
"loop-engineer/tasks@1",
|
|
23
|
+
"loop-engineer/terminal@1",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContractIssue(dict):
|
|
28
|
+
def __init__(self, code: str, message: str, path: Path | None = None):
|
|
29
|
+
super().__init__(code=code, message=message)
|
|
30
|
+
if path is not None:
|
|
31
|
+
self["path"] = str(path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_json(path: Path, issues: list[dict]) -> dict[str, Any] | None:
|
|
35
|
+
if not path.exists():
|
|
36
|
+
issues.append(ContractIssue("missing_file", f"missing {path.name}", path))
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
40
|
+
except json.JSONDecodeError as exc:
|
|
41
|
+
issues.append(ContractIssue("invalid_json", f"{path.name}: {exc}", path))
|
|
42
|
+
return None
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
issues.append(ContractIssue("invalid_json", f"{path.name}: expected object", path))
|
|
45
|
+
return None
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_scalar(value: str) -> Any:
|
|
50
|
+
value = value.strip()
|
|
51
|
+
if value == "true":
|
|
52
|
+
return True
|
|
53
|
+
if value == "false":
|
|
54
|
+
return False
|
|
55
|
+
if value == "null":
|
|
56
|
+
return None
|
|
57
|
+
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
|
58
|
+
return value[1:-1]
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _strip_comment(raw: str) -> str:
|
|
63
|
+
"""Drop a trailing ``#`` comment, but only when the ``#`` is unquoted.
|
|
64
|
+
|
|
65
|
+
A ``#`` inside a single- or double-quoted scalar is data (``"reach #1"``),
|
|
66
|
+
not a comment; and — like YAML — a ``#`` only opens a comment at line start
|
|
67
|
+
or after whitespace, so an unquoted ``reach#1`` is left intact.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
in_single = in_double = False
|
|
71
|
+
for i, ch in enumerate(raw):
|
|
72
|
+
if ch == "'" and not in_double:
|
|
73
|
+
in_single = not in_single
|
|
74
|
+
elif ch == '"' and not in_single:
|
|
75
|
+
in_double = not in_double
|
|
76
|
+
elif ch == "#" and not in_single and not in_double and (i == 0 or raw[i - 1] in " \t"):
|
|
77
|
+
return raw[:i]
|
|
78
|
+
return raw
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fallback_yaml(text: str) -> dict[str, Any]:
|
|
82
|
+
"""Small YAML subset parser for the manifest shapes this project emits.
|
|
83
|
+
|
|
84
|
+
It supports top-level scalars, one-level mappings, and one-level lists. When
|
|
85
|
+
PyYAML is available, `read_manifest` uses it instead.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
root: dict[str, Any] = {}
|
|
89
|
+
current_key: str | None = None
|
|
90
|
+
for raw in text.splitlines():
|
|
91
|
+
line = _strip_comment(raw).rstrip()
|
|
92
|
+
if not line:
|
|
93
|
+
continue
|
|
94
|
+
if not line.startswith(" ") and ":" in line:
|
|
95
|
+
key, value = line.split(":", 1)
|
|
96
|
+
key = key.strip()
|
|
97
|
+
value = value.strip()
|
|
98
|
+
if value:
|
|
99
|
+
root[key] = _parse_scalar(value)
|
|
100
|
+
current_key = None
|
|
101
|
+
else:
|
|
102
|
+
root[key] = {}
|
|
103
|
+
current_key = key
|
|
104
|
+
continue
|
|
105
|
+
if current_key is None:
|
|
106
|
+
continue
|
|
107
|
+
stripped = line.strip()
|
|
108
|
+
if stripped.startswith("- "):
|
|
109
|
+
if not isinstance(root[current_key], list):
|
|
110
|
+
root[current_key] = []
|
|
111
|
+
root[current_key].append(_parse_scalar(stripped[2:]))
|
|
112
|
+
elif ":" in stripped:
|
|
113
|
+
if not isinstance(root[current_key], dict):
|
|
114
|
+
root[current_key] = {}
|
|
115
|
+
key, value = stripped.split(":", 1)
|
|
116
|
+
root[current_key][key.strip()] = _parse_scalar(value)
|
|
117
|
+
return root
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_manifest(path: Path) -> dict[str, Any] | None:
|
|
121
|
+
if not path.exists():
|
|
122
|
+
return None
|
|
123
|
+
text = path.read_text(encoding="utf-8")
|
|
124
|
+
try:
|
|
125
|
+
import yaml # type: ignore
|
|
126
|
+
except Exception:
|
|
127
|
+
data = _fallback_yaml(text)
|
|
128
|
+
else:
|
|
129
|
+
# The manifest may come from an untrusted/foreign loop dir; a malformed
|
|
130
|
+
# document must fail safe to {} (parseable-as-empty), mirroring the
|
|
131
|
+
# json.JSONDecodeError guard in _read_json — never propagate a traceback.
|
|
132
|
+
try:
|
|
133
|
+
loaded = yaml.safe_load(text)
|
|
134
|
+
except yaml.YAMLError:
|
|
135
|
+
loaded = None
|
|
136
|
+
data = loaded if isinstance(loaded, dict) else {}
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _require_schema(data: dict[str, Any] | None, expected: str, path: Path, issues: list[dict]) -> None:
|
|
141
|
+
if data is None:
|
|
142
|
+
return
|
|
143
|
+
if data.get("schema") != expected:
|
|
144
|
+
issues.append(
|
|
145
|
+
ContractIssue(
|
|
146
|
+
"schema_mismatch",
|
|
147
|
+
f"{path.name}: expected schema {expected!r}, got {data.get('schema')!r}",
|
|
148
|
+
path,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _validate_state(data: dict[str, Any] | None, path: Path, issues: list[dict]) -> None:
|
|
154
|
+
_require_schema(data, "loop-engineer/state@1", path, issues)
|
|
155
|
+
if data is None:
|
|
156
|
+
return
|
|
157
|
+
for key in ("iteration_id", "state", "plan_version", "budget_remaining"):
|
|
158
|
+
if key not in data:
|
|
159
|
+
issues.append(ContractIssue("missing_state_field", f"state missing {key}", path))
|
|
160
|
+
terminal = data.get("terminal_state")
|
|
161
|
+
if terminal is not None and terminal not in TERMINAL_STATES:
|
|
162
|
+
issues.append(ContractIssue("invalid_terminal_state", f"invalid terminal_state {terminal!r}", path))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check_tasks_semantics(data: dict[str, Any] | None, path: Path, issues: list[dict]) -> None:
|
|
166
|
+
"""Cross-task rules JSON Schema cannot express: id uniqueness and the
|
|
167
|
+
evidence-before-done invariant. Runs in both validation modes."""
|
|
168
|
+
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
return
|
|
171
|
+
tasks = data.get("tasks")
|
|
172
|
+
if not isinstance(tasks, list):
|
|
173
|
+
return
|
|
174
|
+
seen: set[str] = set()
|
|
175
|
+
for task in tasks:
|
|
176
|
+
if not isinstance(task, dict):
|
|
177
|
+
continue
|
|
178
|
+
task_id = task.get("id")
|
|
179
|
+
if isinstance(task_id, str) and task_id:
|
|
180
|
+
if task_id in seen:
|
|
181
|
+
issues.append(ContractIssue("duplicate_task", f"duplicate task id {task_id!r}", path))
|
|
182
|
+
seen.add(task_id)
|
|
183
|
+
if task.get("status") == "done" and not task.get("evidence"):
|
|
184
|
+
issues.append(ContractIssue("done_without_evidence", f"task {task_id!r} done without evidence", path))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _validate_tasks(data: dict[str, Any] | None, path: Path, issues: list[dict]) -> None:
|
|
188
|
+
_require_schema(data, "loop-engineer/tasks@1", path, issues)
|
|
189
|
+
if data is None:
|
|
190
|
+
return
|
|
191
|
+
tasks = data.get("tasks")
|
|
192
|
+
if not isinstance(tasks, list):
|
|
193
|
+
issues.append(ContractIssue("invalid_tasks", "tasks must be a list", path))
|
|
194
|
+
return
|
|
195
|
+
for task in tasks:
|
|
196
|
+
if not isinstance(task, dict):
|
|
197
|
+
issues.append(ContractIssue("invalid_task", "task must be an object", path))
|
|
198
|
+
continue
|
|
199
|
+
task_id = task.get("id")
|
|
200
|
+
if not isinstance(task_id, str) or not task_id:
|
|
201
|
+
issues.append(ContractIssue("invalid_task", "task id must be a non-empty string", path))
|
|
202
|
+
_check_tasks_semantics(data, path, issues)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _check_terminal_contradiction(data: dict[str, Any] | None, path: Path, issues: list[dict]) -> None:
|
|
206
|
+
"""G1: a Succeeded terminal must not contradict its own evidence.
|
|
207
|
+
|
|
208
|
+
Runs in both validation modes because JSON Schema cannot express the
|
|
209
|
+
cross-field rule that a success claim requires false_completion=false, at
|
|
210
|
+
least one met criterion, AND a non-empty evidence list — mirroring the
|
|
211
|
+
write-time refusal in loop/emit.py (an evidence-free Succeeded is a claim
|
|
212
|
+
with nothing behind it).
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
if not isinstance(data, dict) or data.get("state") != "Succeeded":
|
|
216
|
+
return
|
|
217
|
+
if data.get("false_completion") is True:
|
|
218
|
+
issues.append(
|
|
219
|
+
ContractIssue("contradictory_terminal", "Succeeded terminal declares false_completion=true", path)
|
|
220
|
+
)
|
|
221
|
+
criteria = data.get("criteria_met")
|
|
222
|
+
if not isinstance(criteria, dict) or not any(v is True for v in criteria.values()):
|
|
223
|
+
issues.append(
|
|
224
|
+
ContractIssue("contradictory_terminal", "Succeeded terminal has no met (true) entry in criteria_met", path)
|
|
225
|
+
)
|
|
226
|
+
evidence = data.get("evidence")
|
|
227
|
+
if not isinstance(evidence, list) or not evidence:
|
|
228
|
+
issues.append(
|
|
229
|
+
ContractIssue("contradictory_terminal", "Succeeded terminal has empty evidence[] (G1)", path)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _validate_terminal(data: dict[str, Any] | None, path: Path, issues: list[dict]) -> None:
|
|
234
|
+
_require_schema(data, "loop-engineer/terminal@1", path, issues)
|
|
235
|
+
if data is None:
|
|
236
|
+
return
|
|
237
|
+
if data.get("state") not in TERMINAL_STATES:
|
|
238
|
+
issues.append(ContractIssue("invalid_terminal_state", f"invalid state {data.get('state')!r}", path))
|
|
239
|
+
if not isinstance(data.get("criteria_met"), dict):
|
|
240
|
+
issues.append(ContractIssue("invalid_terminal", "criteria_met must be an object", path))
|
|
241
|
+
if not isinstance(data.get("evidence"), list):
|
|
242
|
+
issues.append(ContractIssue("invalid_terminal", "evidence must be a list", path))
|
|
243
|
+
if not isinstance(data.get("false_completion"), bool):
|
|
244
|
+
issues.append(ContractIssue("invalid_terminal", "false_completion must be bool", path))
|
|
245
|
+
_check_terminal_contradiction(data, path, issues)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _validate_manifest(data: dict[str, Any] | None, path: Path, issues: list[dict]) -> None:
|
|
249
|
+
if data is None:
|
|
250
|
+
issues.append(ContractIssue("missing_file", "missing manifest.yaml", path))
|
|
251
|
+
return
|
|
252
|
+
if data.get("schema") != "loop-engineer/manifest@1":
|
|
253
|
+
issues.append(
|
|
254
|
+
ContractIssue(
|
|
255
|
+
"schema_mismatch",
|
|
256
|
+
f"manifest.yaml: expected schema 'loop-engineer/manifest@1', got {data.get('schema')!r}",
|
|
257
|
+
path,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
policies = data.get("policies")
|
|
261
|
+
if not isinstance(policies, dict):
|
|
262
|
+
issues.append(ContractIssue("invalid_manifest", "policies must be an object", path))
|
|
263
|
+
elif not isinstance(policies.get("plan_then_execute"), bool):
|
|
264
|
+
issues.append(ContractIssue("invalid_manifest", "policies.plan_then_execute must be bool", path))
|
|
265
|
+
states = data.get("terminal_states")
|
|
266
|
+
if states is not None and list(states) != list(TERMINAL_STATES):
|
|
267
|
+
issues.append(ContractIssue("invalid_terminal_states", "terminal_states must match canonical 7", path))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _verify_script_paths(paths: LoopPaths) -> list[Path]:
|
|
271
|
+
scripts = paths.workspace / "scripts"
|
|
272
|
+
return [scripts / "verify-fast", scripts / "verify-fast.sh", scripts / "verify-full", scripts / "verify-full.sh"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _task_verify_values(tasks: dict[str, Any] | None) -> list[str]:
|
|
276
|
+
"""Non-empty ``verify`` strings declared by the tasks, in order."""
|
|
277
|
+
if not isinstance(tasks, dict):
|
|
278
|
+
return []
|
|
279
|
+
task_list = tasks.get("tasks")
|
|
280
|
+
if not isinstance(task_list, list):
|
|
281
|
+
return []
|
|
282
|
+
values: list[str] = []
|
|
283
|
+
for task in task_list:
|
|
284
|
+
if isinstance(task, dict):
|
|
285
|
+
verify = task.get("verify")
|
|
286
|
+
if isinstance(verify, str) and verify.strip():
|
|
287
|
+
values.append(verify.strip())
|
|
288
|
+
return values
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _check_verify_surface(paths: LoopPaths, tasks: dict[str, Any] | None, issues: list[dict]) -> None:
|
|
292
|
+
"""Every loop needs a verification surface, and a declared one must resolve.
|
|
293
|
+
|
|
294
|
+
(a) If NO verify-* script exists AND no task declares a verify command, the
|
|
295
|
+
contract can never gate anything — ``missing_verify_surface``.
|
|
296
|
+
(b) A task ``verify`` whose first whitespace token is path-shaped (contains
|
|
297
|
+
"/") must resolve relative to the workspace — a dangling script path is
|
|
298
|
+
``unresolved_task_verify``. A plain command (``pytest -q``) is not a path
|
|
299
|
+
and is not existence-checked.
|
|
300
|
+
|
|
301
|
+
Runs in both validation modes.
|
|
302
|
+
"""
|
|
303
|
+
any_script = any(p.is_file() for p in _verify_script_paths(paths))
|
|
304
|
+
verify_values = _task_verify_values(tasks)
|
|
305
|
+
if not any_script and not verify_values:
|
|
306
|
+
issues.append(
|
|
307
|
+
ContractIssue(
|
|
308
|
+
"missing_verify_surface",
|
|
309
|
+
"no verify-* script exists and no task declares a verify command",
|
|
310
|
+
paths.tasks,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
for value in verify_values:
|
|
314
|
+
first = value.split()[0]
|
|
315
|
+
if "/" not in first:
|
|
316
|
+
continue
|
|
317
|
+
if not (paths.workspace / first).exists():
|
|
318
|
+
issues.append(
|
|
319
|
+
ContractIssue(
|
|
320
|
+
"unresolved_task_verify",
|
|
321
|
+
f"task verify path {first!r} does not resolve under the workspace",
|
|
322
|
+
paths.tasks,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _check_stub_verify_scripts(paths: LoopPaths, issues: list[dict]) -> None:
|
|
328
|
+
"""Flag verify-* scripts that still carry the scaffold's stub markers.
|
|
329
|
+
|
|
330
|
+
The ``stub:`` / ``replace with real command`` markers are an OPT-IN
|
|
331
|
+
convention: a loop that wants doctor to refuse an un-filled gate leaves them
|
|
332
|
+
in until the real command lands. The templates ship WITHOUT them so a fresh
|
|
333
|
+
scaffold is doctor-clean — the presence of a marker is a deliberate signal,
|
|
334
|
+
never the default state.
|
|
335
|
+
"""
|
|
336
|
+
for script in _verify_script_paths(paths):
|
|
337
|
+
if not script.exists() or not script.is_file():
|
|
338
|
+
continue
|
|
339
|
+
text = script.read_text(encoding="utf-8", errors="ignore").lower()
|
|
340
|
+
if "stub:" in text or "replace with real command" in text:
|
|
341
|
+
issues.append(ContractIssue("stub_verify_script", f"{script.name} still contains stub markers", script))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
_SCHEMA_FILES = {
|
|
345
|
+
"manifest": "manifest.schema.json",
|
|
346
|
+
"state": "state.schema.json",
|
|
347
|
+
"tasks": "tasks.schema.json",
|
|
348
|
+
"terminal": "terminal.schema.json",
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _schemas_dir() -> Path:
|
|
353
|
+
# Bundle-first (wheel package data), repo-relative editable-install fallback.
|
|
354
|
+
from ._resources import schemas_dir
|
|
355
|
+
|
|
356
|
+
return schemas_dir()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _load_schema(name: str) -> dict[str, Any]:
|
|
360
|
+
return json.loads((_schemas_dir() / _SCHEMA_FILES[name]).read_text(encoding="utf-8"))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _validation_mode() -> str:
|
|
364
|
+
try:
|
|
365
|
+
import jsonschema # type: ignore # noqa: F401
|
|
366
|
+
except Exception:
|
|
367
|
+
return "structural-fallback"
|
|
368
|
+
return "jsonschema"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _jsonschema_validate(data: dict[str, Any], name: str, path: Path, issues: list[dict]) -> None:
|
|
372
|
+
import jsonschema # type: ignore
|
|
373
|
+
|
|
374
|
+
validator = jsonschema.Draft202012Validator(_load_schema(name))
|
|
375
|
+
for err in validator.iter_errors(data):
|
|
376
|
+
location = "/".join(str(p) for p in err.absolute_path) or "<root>"
|
|
377
|
+
issues.append(ContractIssue("schema_violation", f"{path.name}: {location}: {err.message}", path))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# The FCR/RP evidentiary trail (M5): repair records and receipt/rollout ledgers.
|
|
381
|
+
# Validated OPTIONALLY — only when the files exist — so an in-flight loop that has
|
|
382
|
+
# not emitted them yet still passes, while a loop that ships malformed metric
|
|
383
|
+
# inputs can no longer pass validation with them unchecked.
|
|
384
|
+
_RECORD_SCHEMA_FILES = {
|
|
385
|
+
"repair": "repair-record.schema.json",
|
|
386
|
+
"rollout": "rollout-record.schema.json",
|
|
387
|
+
"receipt": "receipt.schema.json",
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# The $id each record schema publishes, reported under schemas_checked when the
|
|
391
|
+
# corresponding record files were present and validated (deterministic order).
|
|
392
|
+
_RECORD_SCHEMA_IDS = (
|
|
393
|
+
("repair", "loop-engineer/repair@1"),
|
|
394
|
+
("rollout", "loop-engineer/rollout@1"),
|
|
395
|
+
("receipt", "loop-engineer/receipt@1"),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _load_schema_file(filename: str) -> dict[str, Any]:
|
|
400
|
+
return json.loads((_schemas_dir() / filename).read_text(encoding="utf-8"))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _structural_record_check(data: dict[str, Any], schema: dict[str, Any], path: Path, issues: list[dict]) -> None:
|
|
404
|
+
props = schema.get("properties", {})
|
|
405
|
+
for key in schema.get("required", []):
|
|
406
|
+
if key not in data:
|
|
407
|
+
issues.append(ContractIssue("invalid_record", f"{path.name}: missing {key}", path))
|
|
408
|
+
for key, sub in props.items():
|
|
409
|
+
if key in data and isinstance(sub, dict) and "const" in sub and data[key] != sub["const"]:
|
|
410
|
+
issues.append(ContractIssue("schema_mismatch", f"{path.name}: {key} != {sub['const']!r}", path))
|
|
411
|
+
for vk in ("verification_before", "verification_after"):
|
|
412
|
+
sub = props.get(vk)
|
|
413
|
+
if isinstance(sub, dict) and "score" in sub.get("required", []) and vk in data:
|
|
414
|
+
value = data[vk]
|
|
415
|
+
score = value.get("score") if isinstance(value, dict) else None
|
|
416
|
+
if isinstance(score, bool) or not isinstance(score, (int, float)):
|
|
417
|
+
issues.append(ContractIssue("invalid_record", f"{path.name}: {vk}.score must be numeric", path))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _validate_record(data: dict[str, Any], schema_key: str, path: Path, mode: str, issues: list[dict]) -> None:
|
|
421
|
+
filename = _RECORD_SCHEMA_FILES[schema_key]
|
|
422
|
+
if mode == "jsonschema":
|
|
423
|
+
import jsonschema # type: ignore
|
|
424
|
+
|
|
425
|
+
validator = jsonschema.Draft202012Validator(_load_schema_file(filename))
|
|
426
|
+
for err in validator.iter_errors(data):
|
|
427
|
+
location = "/".join(str(p) for p in err.absolute_path) or "<root>"
|
|
428
|
+
issues.append(ContractIssue("schema_violation", f"{path.name}: {location}: {err.message}", path))
|
|
429
|
+
else:
|
|
430
|
+
_structural_record_check(data, _load_schema_file(filename), path, issues)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# The canonical rollout / candidate ledger file (scripts/rollout_ledger.py,
|
|
434
|
+
# schemas/rollout-record.schema.json). doctor validates THIS as a rollout ledger;
|
|
435
|
+
# any other .loop/*.jsonl is foreign and skipped rather than force-validated
|
|
436
|
+
# against the rollout schema.
|
|
437
|
+
ROLLOUT_LEDGER_NAMES = ("rollout.jsonl",)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _validate_jsonl(path: Path, schema_key: str, mode: str, issues: list[dict]) -> None:
|
|
441
|
+
try:
|
|
442
|
+
text = path.read_text(encoding="utf-8")
|
|
443
|
+
except UnicodeDecodeError as exc:
|
|
444
|
+
# A ledger that is not valid UTF-8 fails closed — decoding it lossily
|
|
445
|
+
# (errors="ignore") would let a record with corrupt bytes validate clean.
|
|
446
|
+
issues.append(ContractIssue("invalid_encoding", f"{path.name}: not valid UTF-8: {exc}", path))
|
|
447
|
+
return
|
|
448
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
449
|
+
line = line.strip()
|
|
450
|
+
if not line:
|
|
451
|
+
continue
|
|
452
|
+
try:
|
|
453
|
+
data = json.loads(line)
|
|
454
|
+
except json.JSONDecodeError as exc:
|
|
455
|
+
issues.append(ContractIssue("invalid_json", f"{path.name}:{lineno}: {exc}", path))
|
|
456
|
+
continue
|
|
457
|
+
if not isinstance(data, dict):
|
|
458
|
+
issues.append(ContractIssue("invalid_record", f"{path.name}:{lineno}: expected object", path))
|
|
459
|
+
continue
|
|
460
|
+
_validate_record(data, schema_key, path, mode, issues)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _validate_optional_records(paths: LoopPaths, mode: str, issues: list[dict]) -> set[str]:
|
|
464
|
+
"""Validate record files that are present; return the set of record schema
|
|
465
|
+
keys actually checked (``repair``/``rollout``/``receipt``) so ``doctor`` can
|
|
466
|
+
report them under ``schemas_checked`` instead of under-counting its coverage."""
|
|
467
|
+
checked: set[str] = set()
|
|
468
|
+
repair_dir = paths.loop_dir / "repair"
|
|
469
|
+
if repair_dir.is_dir():
|
|
470
|
+
for record_path in sorted(repair_dir.glob("*.json")):
|
|
471
|
+
data = _read_json(record_path, issues)
|
|
472
|
+
if data is not None:
|
|
473
|
+
_validate_record(data, "repair", record_path, mode, issues)
|
|
474
|
+
checked.add("repair")
|
|
475
|
+
for name in ROLLOUT_LEDGER_NAMES:
|
|
476
|
+
ledger_path = paths.loop_dir / name
|
|
477
|
+
if ledger_path.is_file():
|
|
478
|
+
_validate_jsonl(ledger_path, "rollout", mode, issues)
|
|
479
|
+
checked.add("rollout")
|
|
480
|
+
receipts_dir = paths.loop_dir / "receipts"
|
|
481
|
+
if receipts_dir.is_dir():
|
|
482
|
+
for receipt_path in sorted(receipts_dir.glob("*.jsonl")):
|
|
483
|
+
_validate_jsonl(receipt_path, "receipt", mode, issues)
|
|
484
|
+
checked.add("receipt")
|
|
485
|
+
return checked
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _derive_lifecycle(state: Any, terminal: Any, terminal_exists: bool) -> str:
|
|
489
|
+
"""Report which lifecycle band a loop is in — additive reporting only, never
|
|
490
|
+
an issue source. Total and pure: never raises.
|
|
491
|
+
|
|
492
|
+
Per the ratified rule:
|
|
493
|
+
1. state parsed with a non-null terminal_state, OR terminal_state.json
|
|
494
|
+
present → ``terminated:<X>`` where X is the terminal file's ``state``
|
|
495
|
+
(dict + string) if available, else state.json's ``terminal_state`` if a
|
|
496
|
+
string, else ``unknown``.
|
|
497
|
+
2. state parsed and iteration_id is 0 / "0" → ``planned``.
|
|
498
|
+
3. state parsed → ``running``.
|
|
499
|
+
4. else → ``unknown``.
|
|
500
|
+
"""
|
|
501
|
+
state_is_dict = isinstance(state, dict)
|
|
502
|
+
terminal_state_val = state.get("terminal_state") if state_is_dict else None
|
|
503
|
+
if (state_is_dict and terminal_state_val is not None) or terminal_exists:
|
|
504
|
+
if isinstance(terminal, dict) and isinstance(terminal.get("state"), str):
|
|
505
|
+
resolved = terminal["state"]
|
|
506
|
+
elif isinstance(terminal_state_val, str):
|
|
507
|
+
resolved = terminal_state_val
|
|
508
|
+
else:
|
|
509
|
+
resolved = "unknown"
|
|
510
|
+
return f"terminated:{resolved}"
|
|
511
|
+
if state_is_dict:
|
|
512
|
+
iteration_id = state.get("iteration_id")
|
|
513
|
+
if iteration_id == 0 or iteration_id == "0":
|
|
514
|
+
return "planned"
|
|
515
|
+
return "running"
|
|
516
|
+
return "unknown"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def validate_contract(target: str | Path) -> dict[str, Any]:
|
|
520
|
+
paths = resolve_loop_paths(target)
|
|
521
|
+
issues: list[dict] = []
|
|
522
|
+
manifest = read_manifest(paths.manifest)
|
|
523
|
+
state = _read_json(paths.state, issues)
|
|
524
|
+
tasks = _read_json(paths.tasks, issues)
|
|
525
|
+
if not paths.terminal.exists() and isinstance(state, dict) and state.get("terminal_state") is None:
|
|
526
|
+
# In-flight loop: terminal_state.json is written once, at loop end. Its
|
|
527
|
+
# absence is valid while state.json still declares terminal_state: null.
|
|
528
|
+
terminal = None
|
|
529
|
+
else:
|
|
530
|
+
terminal = _read_json(paths.terminal, issues)
|
|
531
|
+
|
|
532
|
+
mode = _validation_mode()
|
|
533
|
+
if mode == "jsonschema":
|
|
534
|
+
if manifest is None:
|
|
535
|
+
issues.append(ContractIssue("missing_file", "missing manifest.yaml", paths.manifest))
|
|
536
|
+
else:
|
|
537
|
+
_jsonschema_validate(manifest, "manifest", paths.manifest, issues)
|
|
538
|
+
for data, name, path in (
|
|
539
|
+
(state, "state", paths.state),
|
|
540
|
+
(tasks, "tasks", paths.tasks),
|
|
541
|
+
(terminal, "terminal", paths.terminal),
|
|
542
|
+
):
|
|
543
|
+
if data is not None:
|
|
544
|
+
_jsonschema_validate(data, name, path, issues)
|
|
545
|
+
# Cross-field rules JSON Schema cannot express, run in both modes.
|
|
546
|
+
_check_tasks_semantics(tasks, paths.tasks, issues)
|
|
547
|
+
_check_terminal_contradiction(terminal, paths.terminal, issues)
|
|
548
|
+
else:
|
|
549
|
+
_validate_manifest(manifest, paths.manifest, issues)
|
|
550
|
+
_validate_state(state, paths.state, issues)
|
|
551
|
+
_validate_tasks(tasks, paths.tasks, issues)
|
|
552
|
+
_validate_terminal(terminal, paths.terminal, issues)
|
|
553
|
+
|
|
554
|
+
if not paths.runlog.exists():
|
|
555
|
+
issues.append(ContractIssue("missing_file", "missing RUNLOG.md", paths.runlog))
|
|
556
|
+
_check_stub_verify_scripts(paths, issues)
|
|
557
|
+
_check_verify_surface(paths, tasks, issues)
|
|
558
|
+
records_checked = _validate_optional_records(paths, mode, issues)
|
|
559
|
+
|
|
560
|
+
schemas_checked = list(SCHEMA_IDS) + [
|
|
561
|
+
schema_id for key, schema_id in _RECORD_SCHEMA_IDS if key in records_checked
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
lifecycle = _derive_lifecycle(state, terminal, paths.terminal.exists())
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
"ok": not issues,
|
|
568
|
+
"paths": paths.to_json(),
|
|
569
|
+
"validation_mode": mode,
|
|
570
|
+
"schemas_checked": schemas_checked,
|
|
571
|
+
"lifecycle": lifecycle,
|
|
572
|
+
"issues": issues,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def doctor_report(target: str | Path) -> dict[str, Any]:
|
|
577
|
+
return validate_contract(target)
|