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