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/_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)