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
|
@@ -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:]))
|