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
@@ -0,0 +1,669 @@
1
+ """Score an existing agent loop against the prime-directive checklist.
2
+
3
+ ``inspect_loop`` is the runnable core of the [[loop-inspector]] spoke: point it
4
+ at a loop directory — a ``.loop/`` repo-OS contract, a superpowers / ruflo
5
+ harness, any agent-loop dir — and it emits a **scored gap report** against the
6
+ two things that separate a robust loop from one that can only *claim* completion:
7
+
8
+ * the prime-directive checklist — defines verifiable success? independent
9
+ verification? approval gates on side-effects? false-completion defense
10
+ (held-out / anti-cheat)? plan-then-execute for untrusted input?
11
+ * the 7 canonical terminal states — are they all reachable, or does the loop
12
+ end in a silent "completed"?
13
+
14
+ **False-completion defense is graded on invocation evidence, not claims.** A
15
+ self-asserted ``false_completion: false`` flag, a ``verifier_gaming`` manifest
16
+ key, or the phrase "false-completion" in prose earns *nothing* — those are
17
+ assertions the loop makes about itself. The three grades are:
18
+
19
+ * **invoked** (full credit) — a ``scripts/verify-*`` gate *executes* a holdout
20
+ / anti-cheat gate script: an interpreter (``python``/``python3``/``python3.N``/
21
+ ``uv run``/``bash``/``sh``/``exec``, optionally behind a transparent wrapper
22
+ like ``env``/``time``/``nohup``) runs a gate script named in its arguments
23
+ (``python3 scripts/holdout_gate.py``), or the gate script is invoked directly
24
+ by path (``./scripts/holdout_gate.py``). A command that only *references* the
25
+ file — ``echo``/``printf`` printing it, ``grep``/``cat``/``ls``/``test``/
26
+ ``head``/``wc``/``find`` reading it, a trailing ``# comment`` naming it, or a
27
+ redirection using it as a sink — earns *nothing*. A workspace-relative gate
28
+ path must exist on disk; only unresolvable paths (``$VAR``/absolute) earn
29
+ shape-only credit. OR ``RUNLOG.md`` / ``.loop/receipts/*.jsonl`` records an
30
+ actual run (the gate ``.py`` path AND a whole-word verdict token on one line,
31
+ with a gate script present on disk — not a bare token in stuffed prose;
32
+ ordinary English like "ran"/"result"/"cleanup"/"passphrase" never counts).
33
+ * **wired** (partial credit, half the weight) — a gate script file exists
34
+ (``scripts/holdout_gate.py`` / ``anticheat_scan.py`` / ``anti_cheat.py``)
35
+ and is referenced from the contract's verify surface (SPEC / WORKFLOW /
36
+ verify-* scripts), but no run is recorded yet.
37
+ * **none** (zero) — only a self-asserted terminal flag, a prose mention, or an
38
+ unreferenced script file.
39
+
40
+ It is **read-only** over the target: the scanned dir is treated as DATA only
41
+ (plan-then-execute) — file content is matched against fixed signals, never
42
+ interpreted as instructions. It writes nothing into the target.
43
+
44
+ Run::
45
+
46
+ python3 inspect_loop.py <loop_dir>
47
+
48
+ Prints the report as JSON. Exit 0 iff the verdict is non-weak (``strong``/``ok``).
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import json
54
+ import re
55
+ import shlex
56
+ import sys
57
+ from pathlib import Path
58
+
59
+ # When run as a documented standalone script (`python3 scripts/inspect_loop.py
60
+ # <loop>`), sys.path[0] is scripts/ — not the repo root — so the sibling `loop`
61
+ # package is not importable and we would silently use the degraded fallbacks
62
+ # below (read_manifest -> None, root-only path resolution). Put the repo root on
63
+ # sys.path first so the real loop.contract / loop.paths are used when the package
64
+ # ships alongside.
65
+ _REPO_ROOT = str(Path(__file__).resolve().parent.parent)
66
+ if _REPO_ROOT not in sys.path:
67
+ sys.path.insert(0, _REPO_ROOT)
68
+
69
+ try:
70
+ from loop.contract import TERMINAL_STATES, read_manifest
71
+ from loop.paths import resolve_loop_paths
72
+ except ImportError: # pragma: no cover - direct script copy outside repo root
73
+ TERMINAL_STATES = (
74
+ "Succeeded",
75
+ "FailedUnverifiable",
76
+ "FailedBlocked",
77
+ "FailedBudget",
78
+ "FailedSafety",
79
+ "FailedSpecGap",
80
+ "AbortedByHuman",
81
+ )
82
+
83
+ def read_manifest(_path):
84
+ return None
85
+
86
+ def resolve_loop_paths(target):
87
+ class _Paths:
88
+ workspace = Path(target)
89
+ loop_dir = Path(target) / ".loop"
90
+ manifest = loop_dir / "manifest.yaml"
91
+ state = loop_dir / "state.json"
92
+ tasks = Path(target) / "TASKS.json"
93
+ runlog = Path(target) / "RUNLOG.md"
94
+ terminal = loop_dir / "terminal_state.json"
95
+ spec = Path(target) / "SPEC.md"
96
+ workflow = Path(target) / "WORKFLOW.md"
97
+ contract = Path(target) / "loop-contract.md"
98
+
99
+ return _Paths()
100
+
101
+ # Bound the read of any single target file: the corpus is substring-matched
102
+ # against fixed signals, so the head of a file is enough and an oversized file
103
+ # can never exhaust memory.
104
+ _MAX_READ_BYTES = 256 * 1024
105
+
106
+ # The prime-directive checklist. Each check: (key, label, weight, gap message).
107
+ # Weights sum to the non-terminal budget (60); the terminal-state coverage owns
108
+ # the remaining 40 so a loop with no terminal taxonomy can never score "strong".
109
+ _CHECKS = (
110
+ ("defines_success", "defines verifiable success criteria", 12,
111
+ "no defined success criteria (SPEC.md ## Success Criteria) — loop can only claim completion"),
112
+ ("independent_verification", "independent verification", 14,
113
+ "no independent verification (verify-* script / TASKS verify command) — success is self-asserted"),
114
+ ("approval_gates", "approval gates on side-effects", 10,
115
+ "no approval gates declared for side-effects (destructive / secret / production / money)"),
116
+ ("false_completion_defense", "false-completion defense", 14,
117
+ "no false-completion defense: no recorded holdout/anti-cheat invocation "
118
+ "(a self-asserted false_completion flag or prose mention earns no credit)"),
119
+ ("plan_then_execute", "plan-then-execute for untrusted input", 10,
120
+ "no plan-then-execute discipline for untrusted/web reads (prompt-injection surface)"),
121
+ )
122
+
123
+ _TERMINAL_WEIGHT = 40 # points for full 7-of-7 terminal-state coverage
124
+
125
+ # False-completion-defense evidence signals. Gate scripts are matched by name;
126
+ # their tokens (underscored, script-specific) discriminate a real invocation /
127
+ # recorded run from mere prose ("anti-cheat", "false-completion").
128
+ _GATE_TOKENS = ("holdout_gate", "anticheat_scan", "anti_cheat")
129
+ _GATE_SCRIPTS = ("holdout_gate.py", "anticheat_scan.py", "anti_cheat.py")
130
+ # Verdict vocabulary, matched on WORD BOUNDARIES: "cleanup"/"passphrase"/
131
+ # "surpassed" must never satisfy the bar the way "clean"/"pass" do.
132
+ _GATE_RUN_WORDS_RE = re.compile(
133
+ r"\b(?:verdict|pass|passed|fail|failed|flagged|clean)\b|\bexit 0\b"
134
+ )
135
+ _FALSE_COMPLETION_PARTIAL_DIVISOR = 2 # wired-but-unrun earns half the weight
136
+
137
+ # A gate script referenced as a *.py path — the invocation shape a real verify
138
+ # gate uses (`python3 scripts/holdout_gate.py`, `./scripts/anticheat_scan.py`).
139
+ # The bare token ("holdout_gate") without .py is prose, not an invocation.
140
+ _GATE_SCRIPT_RE = re.compile(r"\b(?:holdout_gate|anticheat_scan|anti_cheat)\.py\b")
141
+ # Genuine-invocation ALLOWLIST: "invoked" credit requires an executable line that
142
+ # actually *runs* a gate script. Either an interpreter leads the command and a
143
+ # gate script is named in its arguments, or the gate script is invoked directly
144
+ # by path. Everything else — a reference via echo/printf/grep/cat/ls/test/head/
145
+ # wc/find, or any other non-interpreter command — earns nothing. `uv` executes
146
+ # only through its `run` subcommand (pip install / add merely reference).
147
+ _GATE_INTERPRETERS = frozenset({"python", "python3", "bash", "sh", "exec"})
148
+ _VERSIONED_PYTHON_RE = re.compile(r"^python3\.\d+$")
149
+ # Wrapper commands that transparently run their argument command.
150
+ _TRANSPARENT_PREFIXES = frozenset({"env", "time", "nohup", "nice", "command"})
151
+ # A token that is (or opens) a shell redirection: everything after it is a file
152
+ # operand, not part of the executed command (`python3 x.py > holdout_gate.py`).
153
+ _REDIRECTION_RE = re.compile(r"^\d*(>>?|<)")
154
+ # Strip whole redirection expressions (operator + file operand) from a line
155
+ # BEFORE segment splitting: compound operators (`>&`, `>|`, `&>`) contain the
156
+ # very characters the segment splitter cuts on, so a redirect sink would
157
+ # otherwise be severed into its own segment and read as a bare-path invocation.
158
+ _REDIRECTION_STRIP_RE = re.compile(
159
+ r"(?:\d*(?:>>|>\||>&|>|<<-|<<|<&|<)|&>>?)\s*\S*"
160
+ )
161
+ # Split a shell line into command segments so a real invocation chained after an
162
+ # inert emitter (`echo x && python3 ...holdout_gate.py`) is still discovered.
163
+ _SEGMENT_SPLIT_RE = re.compile(r"&&|\|\||[;|()&]")
164
+ # A verify-* line is "inert" (no verification substance) when its leading command
165
+ # only prints or no-ops. A body of nothing but these plus comments is not proof.
166
+ _INERT_LINE_COMMANDS = frozenset({"echo", "printf", "exit", "true", "false", ":"})
167
+
168
+
169
+ def _read_text(path: Path) -> str:
170
+ try:
171
+ with path.open("rb") as fh:
172
+ raw = fh.read(_MAX_READ_BYTES)
173
+ except OSError:
174
+ return ""
175
+ return raw.decode("utf-8", errors="ignore")
176
+
177
+
178
+ def _read_json_object(path: Path) -> dict:
179
+ try:
180
+ data = json.loads(path.read_text(encoding="utf-8"))
181
+ except (OSError, json.JSONDecodeError):
182
+ return {}
183
+ return data if isinstance(data, dict) else {}
184
+
185
+
186
+ def _script_exists(workspace: Path, *names: str) -> bool:
187
+ scripts = workspace / "scripts"
188
+ return any((scripts / name).exists() for name in names)
189
+
190
+
191
+ def _task_verify_declared(tasks: dict) -> bool:
192
+ rows = tasks.get("tasks")
193
+ if not isinstance(rows, list):
194
+ return False
195
+ for row in rows:
196
+ if isinstance(row, dict) and isinstance(row.get("verify"), str) and row["verify"].strip():
197
+ return True
198
+ return False
199
+
200
+
201
+ # Scaffold placeholder convention: an unfilled slot is the literal "REPLACE"
202
+ # marker (loop/scaffold.py `_substitutions`: "REPLACE: <hint>" for filled tokens,
203
+ # a bare "REPLACE" for the extra CRITERION_2/3 slots).
204
+ _PLACEHOLDER_MSG = (
205
+ "unfilled scaffold placeholders ('REPLACE: ...') — replace them with "
206
+ "concrete, verifiable text before this loop can claim to define {what}"
207
+ )
208
+ _SECTION_HEADING_RE = re.compile(r"\s*#{1,6}\s+(?P<title>.*\S)\s*$")
209
+ _LIST_ITEM_RE = re.compile(r"\s*(?:\d+[.)]|[-*+])\s+(?P<item>.*\S)\s*$")
210
+
211
+
212
+ def _is_placeholder(text: str) -> bool:
213
+ """True iff ``text`` is an unfilled scaffold slot ('REPLACE' / 'REPLACE:…')."""
214
+ upper = text.strip().upper()
215
+ return upper == "REPLACE" or upper.startswith("REPLACE:")
216
+
217
+
218
+ def _section_body(text: str, heading: str) -> str | None:
219
+ """Return the body of the first ``## <heading>`` section (case-insensitive)."""
220
+ lines = text.splitlines()
221
+ target = heading.strip().lower()
222
+ start = None
223
+ for i, line in enumerate(lines):
224
+ match = _SECTION_HEADING_RE.match(line)
225
+ if match and match.group("title").strip().lower().rstrip(":") == target:
226
+ start = i + 1
227
+ break
228
+ if start is None:
229
+ return None
230
+ body: list[str] = []
231
+ for line in lines[start:]:
232
+ if _SECTION_HEADING_RE.match(line):
233
+ break
234
+ body.append(line)
235
+ return "\n".join(body)
236
+
237
+
238
+ def _success_criteria_all_placeholder(spec_text: str) -> bool:
239
+ """True iff the SPEC's Success-criteria list has items and all are unfilled."""
240
+ body = _section_body(spec_text, "success criteria")
241
+ if body is None:
242
+ return False
243
+ items = [m.group("item") for line in body.splitlines() if (m := _LIST_ITEM_RE.match(line))]
244
+ return bool(items) and all(_is_placeholder(item) for item in items)
245
+
246
+
247
+ def _task_titles_all_placeholder(tasks: dict) -> bool:
248
+ """True iff every declared task carries an unfilled 'REPLACE' title."""
249
+ rows = tasks.get("tasks")
250
+ if not isinstance(rows, list) or not rows:
251
+ return False
252
+ titles = [row.get("title") for row in rows if isinstance(row, dict)]
253
+ titles = [t for t in titles if isinstance(t, str) and t.strip()]
254
+ return bool(titles) and all(_is_placeholder(title) for title in titles)
255
+
256
+
257
+ def _terminal_states_covered_from_contract(loop: Path) -> int:
258
+ """Count terminal taxonomy coverage from contract-owned files only."""
259
+
260
+ paths = resolve_loop_paths(loop)
261
+ manifest = read_manifest(paths.manifest) or {}
262
+ states = manifest.get("terminal_states") if isinstance(manifest, dict) else None
263
+ if isinstance(states, list):
264
+ return sum(1 for state in TERMINAL_STATES if state in states)
265
+
266
+ contract_text = "\n".join(
267
+ _read_text(path).lower()
268
+ for path in (paths.workflow, paths.manifest, paths.contract)
269
+ if path.exists()
270
+ )
271
+ return sum(1 for state in TERMINAL_STATES if state.lower() in contract_text)
272
+
273
+
274
+ def _verify_scripts(workspace: Path) -> list[Path]:
275
+ scripts = workspace / "scripts"
276
+ if not scripts.is_dir():
277
+ return []
278
+ return sorted(p for p in scripts.glob("verify-*") if p.is_file())
279
+
280
+
281
+ def _leading_command(segment: str) -> str:
282
+ """The command word of a shell segment (past subshell/group openers)."""
283
+ stripped = segment.lstrip("({ \t")
284
+ parts = stripped.split(None, 1)
285
+ return parts[0] if parts else ""
286
+
287
+
288
+ def _shell_tokens(segment: str) -> list[str]:
289
+ """Tokenize a shell segment (subshell openers stripped, quotes removed).
290
+
291
+ Unquoted ``#`` starts a comment — everything after it is prose, not command
292
+ content, so a gate named only in a trailing comment never tokenizes.
293
+ """
294
+ seg = segment.lstrip("({ \t")
295
+ try:
296
+ return shlex.split(seg, posix=True, comments=True)
297
+ except ValueError:
298
+ tokens: list[str] = []
299
+ for tok in seg.split():
300
+ if tok.startswith("#"):
301
+ break
302
+ tokens.append(tok)
303
+ return tokens
304
+
305
+
306
+ def _basename(token: str) -> str:
307
+ """The trailing path component of a shell token (posix `/` separator)."""
308
+ return token.rsplit("/", 1)[-1]
309
+
310
+
311
+ def _is_gate_interpreter(token: str) -> bool:
312
+ return token in _GATE_INTERPRETERS or bool(_VERSIONED_PYTHON_RE.match(token))
313
+
314
+
315
+ def _statically_resolvable(token: str) -> bool:
316
+ """A gate path we can check on disk: workspace-relative, no expansion."""
317
+ return not (token.startswith(("/", "~")) or "$" in token or "`" in token)
318
+
319
+
320
+ def _gate_on_disk(workspace: Path, token: str) -> bool:
321
+ rel = token[2:] if token.startswith("./") else token
322
+ if (workspace / rel).is_file():
323
+ return True
324
+ return "/" not in rel and (workspace / "scripts" / rel).is_file()
325
+
326
+
327
+ def _gate_arg_credits(workspace: Path, token: str) -> bool:
328
+ """A token earns gate credit: names a gate script that plausibly exists.
329
+
330
+ A workspace-relative path must exist on disk — an invocation SHAPE of a
331
+ non-existent gate is stolen valor ("invoked" full credit must not have a
332
+ weaker precondition than "wired" half credit, which checks existence).
333
+ Unresolvable paths ($VAR / absolute / backticks) keep shape-only credit:
334
+ the flagship's gate lives outside the example workspace behind ``$REPO``.
335
+ """
336
+ if _basename(token) not in _GATE_SCRIPTS:
337
+ return False
338
+ if not _statically_resolvable(token):
339
+ return True
340
+ return _gate_on_disk(workspace, token)
341
+
342
+
343
+ def _segment_runs_gate(segment: str, workspace: Path) -> bool:
344
+ """True iff this shell segment genuinely *executes* a gate script.
345
+
346
+ Allowlisted shapes only: an interpreter (python/python3/python3.N/uv run/
347
+ bash/sh/exec, optionally behind env/time/nohup/nice/command) whose arguments
348
+ name a gate script, or the gate script invoked directly by path
349
+ (`./scripts/holdout_gate.py`). A leading grep/cat/ls/test/head/wc/find — or
350
+ echo/printf — that merely references the file is not an execution; neither
351
+ is a gate path that appears only as a redirection sink.
352
+ """
353
+ tokens = _shell_tokens(segment)
354
+ for i, tok in enumerate(tokens):
355
+ if _REDIRECTION_RE.match(tok):
356
+ tokens = tokens[:i]
357
+ break
358
+ while tokens and tokens[0] in _TRANSPARENT_PREFIXES:
359
+ tokens = tokens[1:]
360
+ if not tokens:
361
+ return False
362
+ lead, args = tokens[0], tokens[1:]
363
+ if _basename(lead) in _GATE_SCRIPTS:
364
+ return _gate_arg_credits(workspace, lead)
365
+ if lead == "uv":
366
+ if not args or args[0] != "run":
367
+ return False
368
+ args = args[1:]
369
+ elif not _is_gate_interpreter(lead):
370
+ return False
371
+ return any(_gate_arg_credits(workspace, arg) for arg in args)
372
+
373
+
374
+ def _gate_invoked_in_verify(workspace: Path) -> bool:
375
+ """A verify-* script genuinely *executes* a holdout/anti-cheat gate.
376
+
377
+ Credit requires the gate script (`holdout_gate.py` / `anticheat_scan.py` /
378
+ `anti_cheat.py`) to be *run* — an interpreter invocation or a direct
379
+ by-path call. A command that only prints, reads, lists, or searches the file
380
+ (echo/printf/grep/cat/ls/test/head/wc/find) earns nothing.
381
+ """
382
+ for script in _verify_scripts(workspace):
383
+ for line in _read_text(script).splitlines():
384
+ stripped = line.strip()
385
+ if not stripped or stripped.startswith("#"):
386
+ continue
387
+ # Redirection expressions go first: `>&`/`>|` contain segment-split
388
+ # characters, so a sink severed into its own segment would read as
389
+ # a bare-path invocation of a file the command never executes.
390
+ stripped = _REDIRECTION_STRIP_RE.sub(" ", stripped)
391
+ for segment in _SEGMENT_SPLIT_RE.split(stripped):
392
+ segment = segment.strip()
393
+ if not segment or not _GATE_SCRIPT_RE.search(segment):
394
+ continue
395
+ if _segment_runs_gate(segment, workspace):
396
+ return True
397
+ return False
398
+
399
+
400
+ def _verify_script_has_substance(workspace: Path) -> bool:
401
+ """A verify-* script exists whose body has ≥1 non-inert executable line.
402
+
403
+ A body of nothing but comments and printing/no-op commands (echo/printf/exit/
404
+ true/false/`:`) is not verification — a file merely *named* ``verify-fast``
405
+ earns no independent-verification credit. The shipped scaffold's verify-fast
406
+ keeps credit: its contract-file existence ``for``/``if`` loop is substantive.
407
+ """
408
+ for script in _verify_scripts(workspace):
409
+ for line in _read_text(script).splitlines():
410
+ stripped = line.strip()
411
+ if not stripped or stripped.startswith("#"):
412
+ continue
413
+ lead = _leading_command(stripped)
414
+ if lead and lead not in _INERT_LINE_COMMANDS:
415
+ return True
416
+ return False
417
+
418
+
419
+ def _records_gate_run(low: str, require_script_path: bool = False) -> bool:
420
+ """A gate token AND an independent verdict word share one lowered line.
421
+
422
+ The verdict word is checked against the residue *after* the gate tokens are
423
+ removed, so a token that itself contains one cannot self-satisfy — a bare
424
+ token earns nothing. The word list is verdict vocabulary only: ordinary
425
+ English ("ran", "result", "the deadline passed") is narration, not a record.
426
+ With ``require_script_path`` (the RUNLOG bar) the line must name the actual
427
+ gate ``.py`` path, not just the bare token.
428
+ """
429
+ if require_script_path:
430
+ if not _GATE_SCRIPT_RE.search(low):
431
+ return False
432
+ elif not any(token in low for token in _GATE_TOKENS):
433
+ return False
434
+ residue = low
435
+ for token in _GATE_TOKENS:
436
+ residue = residue.replace(token, " ")
437
+ return bool(_GATE_RUN_WORDS_RE.search(residue))
438
+
439
+
440
+ def _receipt_records_gate(line: str) -> bool:
441
+ """A receipt line is a real gate record: parseable JSON with gate+run fields."""
442
+ line = line.strip()
443
+ if not line:
444
+ return False
445
+ try:
446
+ obj = json.loads(line)
447
+ except json.JSONDecodeError:
448
+ return False
449
+ return _records_gate_run(json.dumps(obj).lower())
450
+
451
+
452
+ def _gate_run_recorded(paths) -> bool:
453
+ """RUNLOG.md / .loop/receipts/*.jsonl record an actual gate run.
454
+
455
+ A record of a run implies a gate that can run: with no gate script anywhere
456
+ on disk, record-shaped prose is a claim about a tool that does not exist.
457
+ """
458
+ if not _script_exists(paths.workspace, *_GATE_SCRIPTS):
459
+ return False
460
+ for line in _read_text(paths.runlog).splitlines():
461
+ if _records_gate_run(line.lower(), require_script_path=True):
462
+ return True
463
+ receipts = paths.loop_dir / "receipts"
464
+ if receipts.is_dir():
465
+ for receipt in sorted(receipts.glob("*.jsonl")):
466
+ for line in _read_text(receipt).splitlines():
467
+ if _receipt_records_gate(line):
468
+ return True
469
+ return False
470
+
471
+
472
+ def _gate_script_referenced(paths) -> bool:
473
+ """A gate script file exists and is named from the contract's verify surface."""
474
+ if not _script_exists(paths.workspace, *_GATE_SCRIPTS):
475
+ return False
476
+ surface = _read_text(paths.spec).lower() + "\n" + _read_text(paths.workflow).lower()
477
+ for script in _verify_scripts(paths.workspace):
478
+ surface += "\n" + _read_text(script).lower()
479
+ return any(token in surface for token in _GATE_TOKENS)
480
+
481
+
482
+ def _false_completion_credit(paths) -> str:
483
+ """Graded false-completion-defense credit (see module docstring).
484
+
485
+ Returns "invoked" (full), "wired" (partial), or "none" (zero).
486
+ """
487
+ if _gate_invoked_in_verify(paths.workspace) or _gate_run_recorded(paths):
488
+ return "invoked"
489
+ if _gate_script_referenced(paths):
490
+ return "wired"
491
+ return "none"
492
+
493
+
494
+ def _evaluate_contract_checks(loop: Path) -> dict[str, object]:
495
+ """Evaluate the checklist against typed/owned contract artifacts.
496
+
497
+ Positive credit comes from SPEC/WORKFLOW/TASKS/scripts/.loop, not broad
498
+ README prose, so keyword stuffing cannot satisfy the loop contract.
499
+ """
500
+
501
+ paths = resolve_loop_paths(loop)
502
+ # SPEC/WORKFLOW resolve dual-location (.loop/ ∪ root) via resolve_loop_paths;
503
+ # a committed single-file loop-contract.md is folded in as a contract-owned
504
+ # source for the same signals.
505
+ contract = _read_text(paths.contract).lower()
506
+ spec = _read_text(paths.spec).lower() + "\n" + contract
507
+ workflow = _read_text(paths.workflow).lower() + "\n" + contract
508
+ tasks = _read_json_object(paths.tasks)
509
+ manifest = read_manifest(paths.manifest) or {}
510
+
511
+ policies = manifest.get("policies") if isinstance(manifest, dict) else None
512
+ manifest_declares_plan = isinstance(policies, dict) and "plan_then_execute" in policies
513
+
514
+ # A fresh scaffold's Success-criteria list and task titles are the literal
515
+ # "REPLACE:" placeholders. A structurally-present-but-unfilled criteria list
516
+ # earns no defines_success credit — a shell is not a defined success.
517
+ spec_raw = _read_text(paths.spec) + "\n" + _read_text(paths.contract)
518
+ criteria_all_placeholder = _success_criteria_all_placeholder(spec_raw)
519
+ task_titles_placeholder = _task_titles_all_placeholder(tasks)
520
+
521
+ has_criteria_heading = "success criteria" in spec or "success_criteria" in spec
522
+ has_spec_criteria = has_criteria_heading and not criteria_all_placeholder
523
+ # A verify-* script earns credit only if it actually verifies — a body of
524
+ # nothing but echo/printf/no-ops (a file merely *named* verify-fast) does not.
525
+ has_verify = (
526
+ _task_verify_declared(tasks)
527
+ or _verify_script_has_substance(paths.workspace)
528
+ or "scripts/verify" in spec
529
+ )
530
+ has_approval = (
531
+ "approval gate" in workflow
532
+ or "approval gates" in workflow
533
+ or "approval_policy" in str(manifest).lower()
534
+ or "approval_gates" in str(manifest).lower()
535
+ )
536
+ if manifest_declares_plan:
537
+ has_plan_then_execute = policies.get("plan_then_execute") is True
538
+ else:
539
+ has_plan_then_execute = "plan-then-execute" in workflow or "plan_then_execute: true" in workflow
540
+
541
+ return {
542
+ "defines_success": has_spec_criteria,
543
+ "independent_verification": has_verify,
544
+ "approval_gates": has_approval,
545
+ "false_completion_defense": _false_completion_credit(paths),
546
+ "plan_then_execute": has_plan_then_execute,
547
+ "_success_criteria_placeholder": has_criteria_heading and criteria_all_placeholder,
548
+ "_task_titles_placeholder": task_titles_placeholder,
549
+ }
550
+
551
+
552
+ def _grade_false_completion(grade, weight, label, none_gap, present, gaps) -> int:
553
+ if grade == "invoked":
554
+ present.append(f"{label} (invoked)")
555
+ return weight
556
+ if grade == "wired":
557
+ present.append(f"{label} (wired, no recorded run)")
558
+ gaps.append(
559
+ "false-completion gate wired but never run — no recorded "
560
+ "holdout/anti-cheat invocation yet (RUNLOG.md / .loop/receipts)"
561
+ )
562
+ return round(weight / _FALSE_COMPLETION_PARTIAL_DIVISOR)
563
+ gaps.append(none_gap)
564
+ return 0
565
+
566
+
567
+ def _verdict(score: int) -> str:
568
+ if score >= 80:
569
+ return "strong"
570
+ if score >= 50:
571
+ return "ok"
572
+ return "weak"
573
+
574
+
575
+ def inspect_loop(loop_dir: str) -> dict:
576
+ """Read a loop directory and return a scored gap report.
577
+
578
+ Read-only over ``loop_dir``. Returns::
579
+
580
+ {
581
+ "target": <dir>,
582
+ "score": 0-100,
583
+ "terminal_states_covered": 0-7,
584
+ "present": [<satisfied checks>],
585
+ "gaps": [<actionable gap messages>],
586
+ "verdict": "strong" | "ok" | "weak",
587
+ }
588
+ """
589
+ loop = Path(loop_dir)
590
+
591
+ results = _evaluate_contract_checks(loop)
592
+ covered = _terminal_states_covered_from_contract(loop)
593
+
594
+ present: list[str] = []
595
+ gaps: list[str] = []
596
+ score = 0
597
+ for key, label, weight, gap_msg in _CHECKS:
598
+ value = results[key]
599
+ if key == "false_completion_defense":
600
+ score += _grade_false_completion(value, weight, label, gap_msg, present, gaps)
601
+ continue
602
+ if key == "defines_success" and not value and results.get("_success_criteria_placeholder"):
603
+ gaps.append("success criteria are " + _PLACEHOLDER_MSG.format(what="success"))
604
+ continue
605
+ if value:
606
+ score += weight
607
+ present.append(label)
608
+ else:
609
+ gaps.append(gap_msg)
610
+
611
+ if results.get("_task_titles_placeholder"):
612
+ gaps.append("TASKS.json titles are " + _PLACEHOLDER_MSG.format(what="its tasks"))
613
+
614
+ terminal_points = round(_TERMINAL_WEIGHT * covered / len(TERMINAL_STATES))
615
+ score += terminal_points
616
+ if covered == len(TERMINAL_STATES):
617
+ present.append(f"all {len(TERMINAL_STATES)} terminal states reachable")
618
+ else:
619
+ paths = resolve_loop_paths(loop)
620
+ manifest = read_manifest(paths.manifest) or {}
621
+ states = manifest.get("terminal_states") if isinstance(manifest, dict) else None
622
+ if isinstance(states, list):
623
+ missing = [s for s in TERMINAL_STATES if s not in states]
624
+ else:
625
+ contract_text = "\n".join(
626
+ _read_text(path).lower()
627
+ for path in (paths.workflow, paths.manifest, paths.contract)
628
+ if path.exists()
629
+ )
630
+ missing = [s for s in TERMINAL_STATES if s.lower() not in contract_text]
631
+ gaps.append(
632
+ f"{covered}/{len(TERMINAL_STATES)} terminal states present — "
633
+ f"missing {', '.join(missing)} (loop can end in a silent 'completed')"
634
+ )
635
+
636
+ score = max(0, min(100, score))
637
+
638
+ # False-completion defense is the anti-gaming keystone: a loop with NO real
639
+ # holdout/anti-cheat gate (grade "none") must never reach "strong" or clear a
640
+ # fail-under-80 CI gate on keyword stuffing alone. Cap it below the threshold.
641
+ # "wired"/"invoked" grades — a gate that at least exists — are uncapped.
642
+ if results["false_completion_defense"] == "none" and score >= 80:
643
+ score = 79
644
+ gaps.append(
645
+ "no false-completion defense — score capped below 'strong'; "
646
+ "wire a holdout/anti-cheat gate"
647
+ )
648
+
649
+ return {
650
+ "target": str(loop),
651
+ "score": score,
652
+ "terminal_states_covered": covered,
653
+ "present": present,
654
+ "gaps": gaps,
655
+ "verdict": _verdict(score),
656
+ }
657
+
658
+
659
+ def main(argv: list[str]) -> int:
660
+ if not argv:
661
+ print("usage: inspect_loop.py <loop_dir>", file=sys.stderr)
662
+ return 2
663
+ report = inspect_loop(argv[0])
664
+ print(json.dumps(report, indent=2))
665
+ return 0 if report["verdict"] != "weak" else 1
666
+
667
+
668
+ if __name__ == "__main__":
669
+ sys.exit(main(sys.argv[1:]))