devlyn-cli 2.9.3 → 2.10.1

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 (40) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +5 -5
  3. package/README.md +5 -5
  4. package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
  5. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
  6. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  7. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  8. package/bin/devlyn.js +108 -15
  9. package/config/skills/_shared/adapters/README.md +2 -2
  10. package/config/skills/_shared/adapters/grok.md +99 -0
  11. package/config/skills/_shared/archive_run.py +35 -1
  12. package/config/skills/_shared/collect-codex-findings.py +231 -50
  13. package/config/skills/_shared/engine-doctor.sh +6 -3
  14. package/config/skills/_shared/engine-preflight.md +12 -5
  15. package/config/skills/_shared/expected.schema.json +7 -0
  16. package/config/skills/_shared/grok-anchor-guard.py +61 -0
  17. package/config/skills/_shared/phase-prompt-render.py +181 -0
  18. package/config/skills/_shared/resolve-bootstrap.py +630 -0
  19. package/config/skills/_shared/resolve-stop-hook.py +325 -0
  20. package/config/skills/_shared/spec-verify-check.py +308 -31
  21. package/config/skills/_shared/state-phase-write.py +2966 -27
  22. package/config/skills/_shared/terminal-claim-check.py +537 -0
  23. package/config/skills/_shared/verify-merge-findings.py +533 -16
  24. package/config/skills/devlyn:engines/SKILL.md +2 -2
  25. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  26. package/config/skills/devlyn:queue/SKILL.md +12 -0
  27. package/config/skills/devlyn:resolve/SKILL.md +60 -78
  28. package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
  29. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
  30. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  31. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  32. package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
  33. package/config/skills/devlyn:resolve/references/phases/verify.md +70 -97
  34. package/config/skills/devlyn:resolve/references/state-schema.md +26 -19
  35. package/optional-skills/devlyn:reap/SKILL.md +1 -1
  36. package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
  37. package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
  38. package/package.json +1 -1
  39. package/scripts/lint-fixtures.sh +4 -1
  40. package/scripts/lint-skills.sh +269 -77
@@ -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())