devlyn-cli 2.9.2 → 2.10.0
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.
- package/AGENTS.md +3 -3
- package/CLAUDE.md +4 -4
- package/README.md +6 -6
- package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
- package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
- package/bin/devlyn.js +68 -7
- package/config/skills/_shared/adapters/README.md +2 -2
- package/config/skills/_shared/adapters/grok.md +77 -0
- package/config/skills/_shared/archive_run.py +35 -1
- package/config/skills/_shared/collect-codex-findings.py +15 -10
- package/config/skills/_shared/engine-doctor.sh +6 -3
- package/config/skills/_shared/engine-preflight.md +12 -5
- package/config/skills/_shared/resolve-bootstrap.py +630 -0
- package/config/skills/_shared/resolve-stop-hook.py +325 -0
- package/config/skills/_shared/state-phase-write.py +2284 -41
- package/config/skills/_shared/terminal-claim-check.py +537 -0
- package/config/skills/_shared/verify-merge-findings.py +363 -16
- package/config/skills/devlyn:engines/SKILL.md +1 -1
- package/config/skills/devlyn:queue/SKILL.md +12 -0
- package/config/skills/devlyn:resolve/SKILL.md +55 -73
- package/config/skills/devlyn:resolve/references/free-form-mode.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +65 -96
- package/config/skills/devlyn:resolve/references/state-schema.md +29 -22
- package/optional-skills/devlyn:reap/SKILL.md +1 -1
- package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
- package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
- package/package.json +1 -1
- package/scripts/lint-skills.sh +131 -67
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic PHASE-0 bootstrap for /devlyn:resolve."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import secrets
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
sys.dont_write_bytecode = True
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
VALUE_FLAGS = {
|
|
21
|
+
"--max-rounds", "--engine", "--spec", "--verify-only", "--goal-file", "--bypass",
|
|
22
|
+
}
|
|
23
|
+
BOOL_FLAGS = {"--pair-verify", "--no-pair", "--risk-probes", "--no-risk-probes", "--perf"}
|
|
24
|
+
SINGLE_VALUE_FLAGS = VALUE_FLAGS - {"--bypass"}
|
|
25
|
+
VALID_BYPASSES = {"build-gate", "cleanup"}
|
|
26
|
+
PHASE_NAMES = (
|
|
27
|
+
"plan", "probe_derive", "implement", "surface_close",
|
|
28
|
+
"build_gate", "cleanup", "verify", "final_report",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BootstrapBlocked(Exception):
|
|
33
|
+
def __init__(self, reason: str, detail: str):
|
|
34
|
+
super().__init__(detail)
|
|
35
|
+
self.reason = reason
|
|
36
|
+
self.detail = detail
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def block(reason: str, detail: str) -> None:
|
|
40
|
+
raise BootstrapBlocked(reason, detail)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reject_json_constant(token: str) -> None:
|
|
44
|
+
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def strict_json(text: str):
|
|
48
|
+
return json.loads(text, parse_constant=reject_json_constant)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def json_bytes(value: object) -> bytes:
|
|
52
|
+
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def atomic_write(path: pathlib.Path, raw: bytes) -> None:
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
fd, name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".tmp.")
|
|
58
|
+
try:
|
|
59
|
+
with open(fd, "wb") as handle:
|
|
60
|
+
handle.write(raw)
|
|
61
|
+
pathlib.Path(name).replace(path)
|
|
62
|
+
except BaseException:
|
|
63
|
+
pathlib.Path(name).unlink(missing_ok=True)
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def atomic_write_batch(
|
|
68
|
+
outputs: dict[pathlib.Path, bytes | None],
|
|
69
|
+
writer=atomic_write,
|
|
70
|
+
) -> None:
|
|
71
|
+
originals = {
|
|
72
|
+
path: (path.exists(), path.read_bytes() if path.exists() else None)
|
|
73
|
+
for path in outputs
|
|
74
|
+
}
|
|
75
|
+
parent_existed = {path.parent: path.parent.exists() for path in outputs}
|
|
76
|
+
try:
|
|
77
|
+
for path, raw in outputs.items():
|
|
78
|
+
if raw is None:
|
|
79
|
+
path.unlink(missing_ok=True)
|
|
80
|
+
else:
|
|
81
|
+
writer(path, raw)
|
|
82
|
+
except BaseException:
|
|
83
|
+
for path, (existed, raw) in reversed(originals.items()):
|
|
84
|
+
if existed:
|
|
85
|
+
assert raw is not None
|
|
86
|
+
atomic_write(path, raw)
|
|
87
|
+
else:
|
|
88
|
+
path.unlink(missing_ok=True)
|
|
89
|
+
for parent, existed in parent_existed.items():
|
|
90
|
+
if not existed and parent.exists():
|
|
91
|
+
parent.rmdir()
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sha256(raw: bytes) -> str:
|
|
96
|
+
return hashlib.sha256(raw).hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_flags(argv: list[str]) -> dict:
|
|
100
|
+
values: dict[str, str | list[str]] = {"--bypass": []}
|
|
101
|
+
switches: set[str] = set()
|
|
102
|
+
positional: list[str] = []
|
|
103
|
+
seen_single: set[str] = set()
|
|
104
|
+
i = 0
|
|
105
|
+
positional_only = False
|
|
106
|
+
while i < len(argv):
|
|
107
|
+
token = argv[i]
|
|
108
|
+
if positional_only:
|
|
109
|
+
positional.append(token)
|
|
110
|
+
i += 1
|
|
111
|
+
continue
|
|
112
|
+
if token == "--":
|
|
113
|
+
positional_only = True
|
|
114
|
+
i += 1
|
|
115
|
+
continue
|
|
116
|
+
if token in BOOL_FLAGS:
|
|
117
|
+
switches.add(token)
|
|
118
|
+
i += 1
|
|
119
|
+
continue
|
|
120
|
+
if token in VALUE_FLAGS:
|
|
121
|
+
if token in SINGLE_VALUE_FLAGS and token in seen_single:
|
|
122
|
+
block("BLOCKED:invalid-flags", f"{token} may be passed only once")
|
|
123
|
+
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
|
124
|
+
block("BLOCKED:invalid-flags", f"{token} requires a value")
|
|
125
|
+
value = argv[i + 1]
|
|
126
|
+
if token == "--bypass":
|
|
127
|
+
assert isinstance(values[token], list)
|
|
128
|
+
values[token].append(value)
|
|
129
|
+
else:
|
|
130
|
+
values[token] = value
|
|
131
|
+
seen_single.add(token)
|
|
132
|
+
i += 2
|
|
133
|
+
continue
|
|
134
|
+
if token.startswith("-"):
|
|
135
|
+
block("BLOCKED:invalid-flags", f"unknown flag: {token}")
|
|
136
|
+
positional.append(token)
|
|
137
|
+
i += 1
|
|
138
|
+
|
|
139
|
+
if "--pair-verify" in switches and "--no-pair" in switches:
|
|
140
|
+
block("BLOCKED:invalid-flags", "--pair-verify and --no-pair are mutually exclusive")
|
|
141
|
+
if "--goal-file" in values and any(flag in values for flag in ("--spec", "--verify-only")):
|
|
142
|
+
block("BLOCKED:invalid-flags", "--goal-file is mutually exclusive with --spec/--verify-only")
|
|
143
|
+
if "--goal-file" in values and positional:
|
|
144
|
+
block("BLOCKED:invalid-flags", "--goal-file is mutually exclusive with an inline goal")
|
|
145
|
+
if "--verify-only" in values and "--spec" not in values:
|
|
146
|
+
block("BLOCKED:invalid-flags", "--verify-only requires --spec")
|
|
147
|
+
if "--spec" in values and positional:
|
|
148
|
+
block("BLOCKED:invalid-flags", "--spec is mutually exclusive with an inline goal")
|
|
149
|
+
|
|
150
|
+
max_rounds_raw = values.get("--max-rounds", "4")
|
|
151
|
+
try:
|
|
152
|
+
max_rounds = int(str(max_rounds_raw))
|
|
153
|
+
except ValueError:
|
|
154
|
+
block("BLOCKED:invalid-flags", "--max-rounds must be a positive integer")
|
|
155
|
+
if max_rounds < 1:
|
|
156
|
+
block("BLOCKED:invalid-flags", "--max-rounds must be a positive integer")
|
|
157
|
+
|
|
158
|
+
bypasses: list[str] = []
|
|
159
|
+
for group in values["--bypass"]:
|
|
160
|
+
for phase in group.split(","):
|
|
161
|
+
if phase not in VALID_BYPASSES:
|
|
162
|
+
block("BLOCKED:invalid-flags", f"invalid --bypass phase: {phase or '<empty>'}")
|
|
163
|
+
if phase not in bypasses:
|
|
164
|
+
bypasses.append(phase)
|
|
165
|
+
|
|
166
|
+
mode = "verify-only" if "--verify-only" in values else "spec" if "--spec" in values else "free-form"
|
|
167
|
+
return {
|
|
168
|
+
"mode": mode,
|
|
169
|
+
"max_rounds": max_rounds,
|
|
170
|
+
"engine": values.get("--engine"),
|
|
171
|
+
"spec": values.get("--spec"),
|
|
172
|
+
"verify_ref": values.get("--verify-only"),
|
|
173
|
+
"goal_file": values.get("--goal-file"),
|
|
174
|
+
"inline_goal": " ".join(positional),
|
|
175
|
+
"pair_verify": "--pair-verify" in switches,
|
|
176
|
+
"bypasses": bypasses,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def validate_shared_dir(shared_dir: pathlib.Path) -> None:
|
|
181
|
+
required = shared_dir / "spec-verify-check.py"
|
|
182
|
+
if not required.is_file():
|
|
183
|
+
block("BLOCKED:shared-dir-unresolved", str(required))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def safe_goal_file(cwd: pathlib.Path, raw_path: str) -> bytes:
|
|
187
|
+
supplied = pathlib.Path(raw_path)
|
|
188
|
+
if supplied.is_absolute() or ".." in supplied.parts:
|
|
189
|
+
block("BLOCKED:goal-file-invalid-path", raw_path)
|
|
190
|
+
target = cwd / supplied
|
|
191
|
+
try:
|
|
192
|
+
resolved = target.resolve(strict=True)
|
|
193
|
+
resolved.relative_to(cwd.resolve())
|
|
194
|
+
except (OSError, ValueError):
|
|
195
|
+
if target.exists():
|
|
196
|
+
block("BLOCKED:goal-file-invalid-path", raw_path)
|
|
197
|
+
block("BLOCKED:goal-file-unreadable", raw_path)
|
|
198
|
+
try:
|
|
199
|
+
metadata = resolved.stat()
|
|
200
|
+
except OSError:
|
|
201
|
+
block("BLOCKED:goal-file-unreadable", raw_path)
|
|
202
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o444 == 0:
|
|
203
|
+
block("BLOCKED:goal-file-unreadable", raw_path)
|
|
204
|
+
try:
|
|
205
|
+
raw = resolved.read_bytes()
|
|
206
|
+
text = raw.decode("utf-8")
|
|
207
|
+
except (OSError, UnicodeError):
|
|
208
|
+
block("BLOCKED:goal-file-unreadable", raw_path)
|
|
209
|
+
if not text.strip():
|
|
210
|
+
block("BLOCKED:goal-file-empty", raw_path)
|
|
211
|
+
return raw
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run_checked(command: list[str], cwd: pathlib.Path) -> None:
|
|
215
|
+
proc = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
|
|
216
|
+
if proc.returncode != 0:
|
|
217
|
+
detail = (proc.stderr or proc.stdout).strip() or "command failed"
|
|
218
|
+
block("BLOCKED:invalid-flags", detail)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def load_spec_helper(shared_dir: pathlib.Path):
|
|
222
|
+
path = shared_dir / "spec-verify-check.py"
|
|
223
|
+
spec = importlib.util.spec_from_file_location("devlyn_spec_verify_check", path)
|
|
224
|
+
if spec is None or spec.loader is None:
|
|
225
|
+
block("BLOCKED:shared-dir-unresolved", str(path))
|
|
226
|
+
module = importlib.util.module_from_spec(spec)
|
|
227
|
+
spec.loader.exec_module(module)
|
|
228
|
+
return module
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def init_spec_source(
|
|
232
|
+
cwd: pathlib.Path, staging_dir: pathlib.Path, shared_dir: pathlib.Path, raw_path: str,
|
|
233
|
+
) -> tuple[dict, bytes | None]:
|
|
234
|
+
path = pathlib.Path(raw_path)
|
|
235
|
+
path = path if path.is_absolute() else cwd / path
|
|
236
|
+
if not path.is_file():
|
|
237
|
+
block("BLOCKED:invalid-flags", f"spec not found: {raw_path}")
|
|
238
|
+
try:
|
|
239
|
+
raw = path.read_bytes()
|
|
240
|
+
raw.decode("utf-8")
|
|
241
|
+
except (OSError, UnicodeError) as exc:
|
|
242
|
+
block("BLOCKED:invalid-flags", f"spec unreadable: {raw_path}: {exc}")
|
|
243
|
+
helper = shared_dir / "spec-verify-check.py"
|
|
244
|
+
expected = path.with_name("spec.expected.json")
|
|
245
|
+
if expected.is_file():
|
|
246
|
+
run_checked([sys.executable, str(helper), "--check-expected", str(expected)], cwd)
|
|
247
|
+
else:
|
|
248
|
+
run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
|
|
249
|
+
module = load_spec_helper(shared_dir)
|
|
250
|
+
if expected.is_file():
|
|
251
|
+
found, _staged, error, _expected_path, _data = module.stage_from_expected(path, staging_dir)
|
|
252
|
+
if not found or error:
|
|
253
|
+
block("BLOCKED:invalid-flags", error or f"expected contract not found: {expected}")
|
|
254
|
+
else:
|
|
255
|
+
_staged, error = module.stage_from_source(path, staging_dir)
|
|
256
|
+
if error:
|
|
257
|
+
block("BLOCKED:invalid-flags", error)
|
|
258
|
+
staged_path = staging_dir / "spec-verify.json"
|
|
259
|
+
return ({
|
|
260
|
+
"type": "spec",
|
|
261
|
+
"spec_path": raw_path,
|
|
262
|
+
"spec_sha256": sha256(raw),
|
|
263
|
+
"criteria_path": None,
|
|
264
|
+
"criteria_sha256": None,
|
|
265
|
+
}, staged_path.read_bytes() if staged_path.is_file() else None)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def git_text(cwd: pathlib.Path, *args: str, allow_empty: bool = False) -> str:
|
|
269
|
+
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
|
270
|
+
text = proc.stdout.strip()
|
|
271
|
+
if proc.returncode != 0 or (not allow_empty and not text):
|
|
272
|
+
detail = (proc.stderr or proc.stdout).strip() or "git command failed"
|
|
273
|
+
block("BLOCKED:invalid-flags", detail)
|
|
274
|
+
return text
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def capture_external_diff(cwd: pathlib.Path, ref: str) -> bytes:
|
|
278
|
+
supplied = pathlib.Path(ref)
|
|
279
|
+
source = supplied if supplied.is_absolute() else cwd / supplied
|
|
280
|
+
if source.is_file():
|
|
281
|
+
raw = source.read_bytes()
|
|
282
|
+
else:
|
|
283
|
+
proc = subprocess.run(["git", "diff", "--binary", ref], cwd=cwd, capture_output=True)
|
|
284
|
+
if proc.returncode != 0:
|
|
285
|
+
block("BLOCKED:invalid-flags", os.fsdecode(proc.stderr or proc.stdout).strip())
|
|
286
|
+
raw = proc.stdout
|
|
287
|
+
return raw
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def bootstrap(
|
|
291
|
+
argv: list[str],
|
|
292
|
+
cwd: pathlib.Path,
|
|
293
|
+
shared_dir: pathlib.Path,
|
|
294
|
+
*,
|
|
295
|
+
default_engine: str = "claude",
|
|
296
|
+
writer=atomic_write,
|
|
297
|
+
) -> dict:
|
|
298
|
+
cwd = cwd.resolve()
|
|
299
|
+
validate_shared_dir(shared_dir)
|
|
300
|
+
parsed = parse_flags(argv)
|
|
301
|
+
devlyn = cwd / ".devlyn"
|
|
302
|
+
outputs: dict[pathlib.Path, bytes | None] = {}
|
|
303
|
+
if parsed["mode"] == "free-form":
|
|
304
|
+
raw_goal = (
|
|
305
|
+
safe_goal_file(cwd, parsed["goal_file"])
|
|
306
|
+
if parsed["goal_file"] is not None
|
|
307
|
+
else parsed["inline_goal"].encode("utf-8")
|
|
308
|
+
)
|
|
309
|
+
outputs[devlyn / "goal.raw.txt"] = raw_goal
|
|
310
|
+
source = {
|
|
311
|
+
"type": "generated",
|
|
312
|
+
"spec_path": None,
|
|
313
|
+
"spec_sha256": None,
|
|
314
|
+
"goal_path": ".devlyn/goal.raw.txt",
|
|
315
|
+
"goal_sha256": sha256(raw_goal),
|
|
316
|
+
"criteria_path": ".devlyn/criteria.generated.md",
|
|
317
|
+
"criteria_sha256": None,
|
|
318
|
+
}
|
|
319
|
+
else:
|
|
320
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
321
|
+
source, staged_spec = init_spec_source(
|
|
322
|
+
cwd, pathlib.Path(tmp), shared_dir, parsed["spec"],
|
|
323
|
+
)
|
|
324
|
+
outputs[devlyn / "spec-verify.json"] = staged_spec
|
|
325
|
+
if parsed["mode"] == "verify-only":
|
|
326
|
+
outputs[devlyn / "external-diff.patch"] = capture_external_diff(cwd, parsed["verify_ref"])
|
|
327
|
+
|
|
328
|
+
engine = parsed["engine"] or default_engine
|
|
329
|
+
engine_source = "flag" if parsed["engine"] is not None else "default"
|
|
330
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
331
|
+
started_at = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
332
|
+
run_id = now.strftime("rs-%Y%m%dT%H%M%SZ-") + secrets.token_hex(6)
|
|
333
|
+
state = {
|
|
334
|
+
"version": "3.0",
|
|
335
|
+
"run_id": run_id,
|
|
336
|
+
"started_at": started_at,
|
|
337
|
+
"session_id": os.environ.get("CLAUDE_CODE_SESSION_ID"),
|
|
338
|
+
"engine": engine,
|
|
339
|
+
"engine_source": engine_source,
|
|
340
|
+
"mode": parsed["mode"],
|
|
341
|
+
"pair_verify": parsed["pair_verify"],
|
|
342
|
+
"complexity": None,
|
|
343
|
+
"risk_profile": {
|
|
344
|
+
"high_risk": False,
|
|
345
|
+
"reasons": [],
|
|
346
|
+
"risk_probes_enabled": False,
|
|
347
|
+
"risk_probes_explicit": False,
|
|
348
|
+
"pair_default_enabled": True,
|
|
349
|
+
},
|
|
350
|
+
"risk_probes_digest": None,
|
|
351
|
+
"base_ref": {
|
|
352
|
+
"branch": git_text(cwd, "symbolic-ref", "--short", "-q", "HEAD", allow_empty=True) or "HEAD",
|
|
353
|
+
"sha": git_text(cwd, "rev-parse", "HEAD"),
|
|
354
|
+
},
|
|
355
|
+
"rounds": {"max_rounds": parsed["max_rounds"], "global": 0},
|
|
356
|
+
"bypasses": parsed["bypasses"],
|
|
357
|
+
"implement_passed_sha": None,
|
|
358
|
+
"source": source,
|
|
359
|
+
"criteria": [],
|
|
360
|
+
"phases": {name: None for name in PHASE_NAMES},
|
|
361
|
+
"verify": {"coverage_failed": False, "pair_trigger": None},
|
|
362
|
+
}
|
|
363
|
+
state_raw = json_bytes(state)
|
|
364
|
+
outputs[devlyn / "pipeline.state.json"] = state_raw
|
|
365
|
+
atomic_write_batch(outputs, writer)
|
|
366
|
+
return {
|
|
367
|
+
"ok": True,
|
|
368
|
+
"run_id": run_id,
|
|
369
|
+
"mode": parsed["mode"],
|
|
370
|
+
"source": source,
|
|
371
|
+
"state_path": ".devlyn/pipeline.state.json",
|
|
372
|
+
"state_sha256": sha256(state_raw),
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def self_test() -> int:
|
|
377
|
+
script_shared = pathlib.Path(__file__).resolve().parent
|
|
378
|
+
|
|
379
|
+
def init_repo(path: pathlib.Path) -> None:
|
|
380
|
+
path.mkdir()
|
|
381
|
+
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
|
|
382
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
|
|
383
|
+
subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True)
|
|
384
|
+
(path / "app.py").write_text("print('base')\n")
|
|
385
|
+
subprocess.run(["git", "add", "app.py"], cwd=path, check=True)
|
|
386
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=path, check=True)
|
|
387
|
+
|
|
388
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
389
|
+
root = pathlib.Path(tmp)
|
|
390
|
+
work = root / "repo"
|
|
391
|
+
init_repo(work)
|
|
392
|
+
session_key = "CLAUDE_CODE_SESSION_ID"
|
|
393
|
+
session_present = session_key in os.environ
|
|
394
|
+
prior_session = os.environ.pop(session_key, None)
|
|
395
|
+
result = bootstrap(["fix", "app.py", "failing", "test"], work, script_shared)
|
|
396
|
+
state_path = work / ".devlyn" / "pipeline.state.json"
|
|
397
|
+
state = strict_json(state_path.read_text())
|
|
398
|
+
expected = {
|
|
399
|
+
"version": "3.0",
|
|
400
|
+
"run_id": state["run_id"],
|
|
401
|
+
"started_at": state["started_at"],
|
|
402
|
+
"session_id": None,
|
|
403
|
+
"engine": "claude",
|
|
404
|
+
"engine_source": "default",
|
|
405
|
+
"mode": "free-form",
|
|
406
|
+
"pair_verify": False,
|
|
407
|
+
"complexity": None,
|
|
408
|
+
"risk_profile": {
|
|
409
|
+
"high_risk": False,
|
|
410
|
+
"reasons": [],
|
|
411
|
+
"risk_probes_enabled": False,
|
|
412
|
+
"risk_probes_explicit": False,
|
|
413
|
+
"pair_default_enabled": True,
|
|
414
|
+
},
|
|
415
|
+
"risk_probes_digest": None,
|
|
416
|
+
"base_ref": {
|
|
417
|
+
"branch": git_text(work, "symbolic-ref", "--short", "-q", "HEAD"),
|
|
418
|
+
"sha": git_text(work, "rev-parse", "HEAD"),
|
|
419
|
+
},
|
|
420
|
+
"rounds": {"max_rounds": 4, "global": 0},
|
|
421
|
+
"bypasses": [],
|
|
422
|
+
"implement_passed_sha": None,
|
|
423
|
+
"source": {
|
|
424
|
+
"type": "generated",
|
|
425
|
+
"spec_path": None,
|
|
426
|
+
"spec_sha256": None,
|
|
427
|
+
"goal_path": ".devlyn/goal.raw.txt",
|
|
428
|
+
"goal_sha256": sha256(b"fix app.py failing test"),
|
|
429
|
+
"criteria_path": ".devlyn/criteria.generated.md",
|
|
430
|
+
"criteria_sha256": None,
|
|
431
|
+
},
|
|
432
|
+
"criteria": [],
|
|
433
|
+
"phases": {name: None for name in PHASE_NAMES},
|
|
434
|
+
"verify": {"coverage_failed": False, "pair_trigger": None},
|
|
435
|
+
}
|
|
436
|
+
schema = (script_shared.parent / "devlyn:resolve" / "references" / "state-schema.md").read_text()
|
|
437
|
+
assert '"version": "3.0"' in schema and all(f'"{name}"' in schema for name in PHASE_NAMES)
|
|
438
|
+
assert state_path.read_bytes() == json_bytes(expected)
|
|
439
|
+
assert result["state_sha256"] == sha256(json_bytes(expected))
|
|
440
|
+
assert (work / ".devlyn" / "goal.raw.txt").read_bytes() == b"fix app.py failing test"
|
|
441
|
+
assert not (work / ".devlyn" / "untracked.baseline").exists()
|
|
442
|
+
assert set(result) == {"ok", "run_id", "mode", "source", "state_path", "state_sha256"}
|
|
443
|
+
print("PASS bootstrap self-test state-init byte contract: schema v3.0 exact, slim result")
|
|
444
|
+
|
|
445
|
+
os.environ[session_key] = "session-self-test"
|
|
446
|
+
bootstrap(["--engine", "raw-engine", "--pair-verify", "fix", "app.py"], work, script_shared)
|
|
447
|
+
stamped = strict_json(state_path.read_text())
|
|
448
|
+
assert stamped["session_id"] == "session-self-test"
|
|
449
|
+
assert stamped["engine"] == "raw-engine" and stamped["engine_source"] == "flag"
|
|
450
|
+
assert stamped["pair_verify"] is True
|
|
451
|
+
if session_present:
|
|
452
|
+
assert prior_session is not None
|
|
453
|
+
os.environ[session_key] = prior_session
|
|
454
|
+
else:
|
|
455
|
+
os.environ.pop(session_key)
|
|
456
|
+
print("PASS bootstrap self-test session stamp: null-safe and env-present; engine flag passthrough")
|
|
457
|
+
|
|
458
|
+
invalid_flag_cases = [
|
|
459
|
+
["--pair-verify", "--no-pair", "fix", "app.py"],
|
|
460
|
+
["--goal-file", "goal.txt", "--spec", "spec.md"],
|
|
461
|
+
["--goal-file", "goal.txt", "--verify-only", "patch", "--spec", "spec.md"],
|
|
462
|
+
["--goal-file", "goal.txt", "fix", "app.py"],
|
|
463
|
+
["--goal-file"],
|
|
464
|
+
["--goal-file", "a", "--goal-file", "b"],
|
|
465
|
+
["--verify-only", "patch"],
|
|
466
|
+
["--spec", "spec.md", "fix", "app.py"],
|
|
467
|
+
["--spec", "a", "--spec", "b"],
|
|
468
|
+
["--verify-only", "a", "--spec", "s", "--verify-only", "b"],
|
|
469
|
+
["--engine"],
|
|
470
|
+
["--engine", "claude", "--engine", "codex"],
|
|
471
|
+
["--max-rounds", "0", "fix", "app.py"],
|
|
472
|
+
["--max-rounds", "x", "fix", "app.py"],
|
|
473
|
+
["--max-rounds"],
|
|
474
|
+
["--bypass", "plan", "fix", "app.py"],
|
|
475
|
+
["--bypass"],
|
|
476
|
+
["--unknown", "fix", "app.py"],
|
|
477
|
+
]
|
|
478
|
+
for case in invalid_flag_cases:
|
|
479
|
+
try:
|
|
480
|
+
parse_flags(case)
|
|
481
|
+
except BootstrapBlocked as exc:
|
|
482
|
+
assert exc.reason == "BLOCKED:invalid-flags", (case, exc.reason)
|
|
483
|
+
else:
|
|
484
|
+
raise AssertionError(f"invalid flag combination accepted: {case}")
|
|
485
|
+
print(f"PASS bootstrap self-test BLOCKED:invalid-flags: {len(invalid_flag_cases)} combinations")
|
|
486
|
+
|
|
487
|
+
blocked_work = root / "blocked-repo"
|
|
488
|
+
init_repo(blocked_work)
|
|
489
|
+
cli_blocked = subprocess.run(
|
|
490
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--pair-verify", "--no-pair"],
|
|
491
|
+
cwd=blocked_work, capture_output=True, text=True,
|
|
492
|
+
)
|
|
493
|
+
assert cli_blocked.returncode == 1 and cli_blocked.stderr == ""
|
|
494
|
+
assert strict_json(cli_blocked.stdout)["blocked"] == "BLOCKED:invalid-flags"
|
|
495
|
+
assert not (blocked_work / ".devlyn").exists()
|
|
496
|
+
print("PASS bootstrap self-test failure receipt: machine-only JSON, zero partial state")
|
|
497
|
+
|
|
498
|
+
goal_file = blocked_work / "goal.txt"
|
|
499
|
+
goal_file.write_text(" \n")
|
|
500
|
+
try:
|
|
501
|
+
bootstrap(["--goal-file", "goal.txt"], blocked_work, script_shared)
|
|
502
|
+
except BootstrapBlocked as exc:
|
|
503
|
+
assert exc.reason == "BLOCKED:goal-file-empty"
|
|
504
|
+
else:
|
|
505
|
+
raise AssertionError("empty goal file accepted")
|
|
506
|
+
try:
|
|
507
|
+
bootstrap(["--goal-file", "missing.txt"], blocked_work, script_shared)
|
|
508
|
+
except BootstrapBlocked as exc:
|
|
509
|
+
assert exc.reason == "BLOCKED:goal-file-unreadable"
|
|
510
|
+
else:
|
|
511
|
+
raise AssertionError("missing goal file accepted")
|
|
512
|
+
try:
|
|
513
|
+
bootstrap(["--goal-file", "../outside.txt"], blocked_work, script_shared)
|
|
514
|
+
except BootstrapBlocked as exc:
|
|
515
|
+
assert exc.reason == "BLOCKED:goal-file-invalid-path"
|
|
516
|
+
else:
|
|
517
|
+
raise AssertionError("escaping goal path accepted")
|
|
518
|
+
assert not (blocked_work / ".devlyn").exists()
|
|
519
|
+
print("PASS bootstrap self-test goal-file BLOCKED paths: empty/unreadable/invalid-path, zero partial state")
|
|
520
|
+
|
|
521
|
+
goal_raw = "fix app.py\r\npreserve café bytes\n".encode()
|
|
522
|
+
(work / "goal.txt").write_bytes(goal_raw)
|
|
523
|
+
goal_result = bootstrap(["--goal-file", "goal.txt"], work, script_shared)
|
|
524
|
+
assert goal_result["source"]["goal_sha256"] == sha256(goal_raw)
|
|
525
|
+
assert (work / ".devlyn" / "goal.raw.txt").read_bytes() == goal_raw
|
|
526
|
+
print("PASS bootstrap self-test goal source: exact bytes and sha256")
|
|
527
|
+
|
|
528
|
+
atomic_work = root / "atomic-repo"
|
|
529
|
+
init_repo(atomic_work)
|
|
530
|
+
write_count = 0
|
|
531
|
+
|
|
532
|
+
def fail_second_write(path: pathlib.Path, raw: bytes) -> None:
|
|
533
|
+
nonlocal write_count
|
|
534
|
+
write_count += 1
|
|
535
|
+
if write_count == 2:
|
|
536
|
+
raise OSError("forced second-write failure")
|
|
537
|
+
atomic_write(path, raw)
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
bootstrap(["fix", "app.py"], atomic_work, script_shared, writer=fail_second_write)
|
|
541
|
+
except OSError as exc:
|
|
542
|
+
assert str(exc) == "forced second-write failure"
|
|
543
|
+
else:
|
|
544
|
+
raise AssertionError("forced atomic batch failure did not fail")
|
|
545
|
+
assert not (atomic_work / ".devlyn").exists()
|
|
546
|
+
print("PASS bootstrap self-test atomic batch rollback: zero partial filesystem state")
|
|
547
|
+
|
|
548
|
+
previous_state = state_path.read_bytes()
|
|
549
|
+
try:
|
|
550
|
+
bootstrap(["fix", "app.py"], work, root / "missing-shared")
|
|
551
|
+
except BootstrapBlocked as exc:
|
|
552
|
+
assert exc.reason == "BLOCKED:shared-dir-unresolved"
|
|
553
|
+
else:
|
|
554
|
+
raise AssertionError("missing shared directory accepted")
|
|
555
|
+
assert state_path.read_bytes() == previous_state
|
|
556
|
+
print("PASS bootstrap self-test BLOCKED:shared-dir-unresolved: existing state byte-stable")
|
|
557
|
+
|
|
558
|
+
spec_dir = work / "docs" / "specs" / "sample"
|
|
559
|
+
spec_dir.mkdir(parents=True)
|
|
560
|
+
spec_path = spec_dir / "spec.md"
|
|
561
|
+
spec_path.write_text(
|
|
562
|
+
"---\ncomplexity: medium\n---\n# Spec\n\n<!-- devlyn:verification -->\n"
|
|
563
|
+
"## Verification\n\n```json\n{\"verification_commands\":[{\"cmd\":\"printf ok\",\"stdout_contains\":[\"ok\"]}]}\n```\n"
|
|
564
|
+
)
|
|
565
|
+
spec_raw = spec_path.read_bytes()
|
|
566
|
+
spec_result = bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
567
|
+
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
568
|
+
assert staged["verification_commands"][0]["cmd"] == "printf ok"
|
|
569
|
+
assert spec_result["source"]["spec_sha256"] == sha256(spec_raw)
|
|
570
|
+
(spec_dir / "spec.expected.json").write_text(json.dumps({
|
|
571
|
+
"verification_commands": [{"cmd": "printf expected", "stdout_contains": ["expected"]}],
|
|
572
|
+
}) + "\n")
|
|
573
|
+
bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
574
|
+
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
575
|
+
assert staged["verification_commands"][0]["cmd"] == "printf expected"
|
|
576
|
+
patch_raw = b"diff --git a/app.py b/app.py\nexact external bytes\x00\n"
|
|
577
|
+
(work / "external.patch").write_bytes(patch_raw)
|
|
578
|
+
verify_result = bootstrap([
|
|
579
|
+
"--verify-only", "external.patch", "--spec", str(spec_path.relative_to(work)),
|
|
580
|
+
], work, script_shared)
|
|
581
|
+
assert verify_result["mode"] == "verify-only"
|
|
582
|
+
assert (work / ".devlyn" / "external-diff.patch").read_bytes() == patch_raw
|
|
583
|
+
print("PASS bootstrap self-test spec staging + verify-only capture: exact source/diff bytes")
|
|
584
|
+
|
|
585
|
+
malformed_work = root / "malformed-spec-repo"
|
|
586
|
+
init_repo(malformed_work)
|
|
587
|
+
bad_spec = malformed_work / "spec.md"
|
|
588
|
+
bad_spec.write_text(
|
|
589
|
+
"# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n{broken\n```\n"
|
|
590
|
+
)
|
|
591
|
+
try:
|
|
592
|
+
bootstrap(["--spec", "spec.md"], malformed_work, script_shared)
|
|
593
|
+
except BootstrapBlocked as exc:
|
|
594
|
+
assert exc.reason == "BLOCKED:invalid-flags"
|
|
595
|
+
else:
|
|
596
|
+
raise AssertionError("malformed spec accepted")
|
|
597
|
+
assert not (malformed_work / ".devlyn").exists()
|
|
598
|
+
print("PASS bootstrap self-test spec validation failure: zero partial filesystem state")
|
|
599
|
+
|
|
600
|
+
return 0
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def main() -> int:
|
|
604
|
+
if sys.argv[1:] == ["--self-test"]:
|
|
605
|
+
return self_test()
|
|
606
|
+
try:
|
|
607
|
+
result = bootstrap(
|
|
608
|
+
sys.argv[1:], pathlib.Path.cwd(), pathlib.Path(__file__).resolve().parent,
|
|
609
|
+
default_engine=os.environ.get("DEVLYN_DEFAULT_ENGINE", "claude"),
|
|
610
|
+
)
|
|
611
|
+
except BootstrapBlocked as exc:
|
|
612
|
+
sys.stdout.write(json.dumps({
|
|
613
|
+
"ok": False,
|
|
614
|
+
"blocked": exc.reason,
|
|
615
|
+
"detail": exc.detail,
|
|
616
|
+
}, sort_keys=True) + "\n")
|
|
617
|
+
return 1
|
|
618
|
+
except Exception as exc:
|
|
619
|
+
sys.stdout.write(json.dumps({
|
|
620
|
+
"ok": False,
|
|
621
|
+
"blocked": "BLOCKED:init-failed",
|
|
622
|
+
"detail": str(exc) or type(exc).__name__,
|
|
623
|
+
}, sort_keys=True) + "\n")
|
|
624
|
+
return 1
|
|
625
|
+
sys.stdout.write(json.dumps(result, sort_keys=True) + "\n")
|
|
626
|
+
return 0
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
if __name__ == "__main__":
|
|
630
|
+
raise SystemExit(main())
|