devlyn-cli 3.1.1 → 3.1.2

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.
@@ -185,7 +185,7 @@ def _source_expected_path(work: pathlib.Path, state: dict) -> pathlib.Path | Non
185
185
  if not isinstance(spec_path, str) or not spec_path:
186
186
  raise EvidenceError("state.source.spec_path is missing")
187
187
  spec_rel = _safe_relative_path(spec_path, "state.source.spec_path")
188
- expected = work.joinpath(*spec_rel.parts).with_suffix(".expected.json")
188
+ expected = work.joinpath(*spec_rel.parts).with_name("spec.expected.json")
189
189
  try:
190
190
  expected.resolve().relative_to(work.resolve())
191
191
  except (OSError, ValueError) as exc:
@@ -682,6 +682,21 @@ def self_test() -> int:
682
682
  "cmd": "printf raw-out; printf raw-err >&2; exit 7",
683
683
  "exit_code": 7, "stdout_contains": ["raw-outraw-err"],
684
684
  })
685
+ spec_dir = work / "docs"
686
+ spec_dir.mkdir()
687
+ (spec_dir / "X.md").write_text("# Named spec\n", encoding="utf-8")
688
+ expected = spec_dir / "spec.expected.json"
689
+ expected.write_text(json.dumps({
690
+ "process_evidence": [obligation],
691
+ "verification_commands": [{"cmd": "printf required"}],
692
+ }), encoding="utf-8")
693
+ (spec_dir / "X.expected.json").write_text("{}\n", encoding="utf-8")
694
+ named_state = {**state, "source": {"type": "spec", "spec_path": "docs/X.md"}}
695
+ assert declared_obligations(work, named_state, "implement") == [obligation]
696
+ assert mechanical_evidence_required(work, named_state)
697
+ expected.unlink()
698
+ assert declared_obligations(work, named_state, "implement") == []
699
+ assert not mechanical_evidence_required(work, named_state)
685
700
  entry = capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
686
701
  assert entry["expectation_met"]
687
702
  assert (manifest.parent / "red-first.stdout").read_bytes() == b"raw-out"
@@ -282,16 +282,13 @@ def init_spec_source(
282
282
  block("BLOCKED:invalid-flags", f"spec unreadable: {raw_path}: {exc}")
283
283
  helper = shared_dir / "spec-verify-check.py"
284
284
  expected = path.with_name("spec.expected.json")
285
- if expected.is_file():
286
- run_checked([sys.executable, str(helper), "--check-expected", str(expected)], cwd)
287
- else:
288
- run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
289
285
  module = load_spec_helper(shared_dir)
290
286
  if expected.is_file():
291
287
  found, _staged, error, _expected_path, _data = module.stage_from_expected(path, staging_dir)
292
288
  if not found or error:
293
289
  block("BLOCKED:invalid-flags", error or f"expected contract not found: {expected}")
294
290
  else:
291
+ run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
295
292
  _staged, error = module.stage_from_source(path, staging_dir)
296
293
  if error:
297
294
  block("BLOCKED:invalid-flags", error)
@@ -1210,6 +1207,34 @@ def self_test() -> int:
1210
1207
  subprocess.run(["git", "restore", "app.py"], cwd=work, check=True)
1211
1208
  print("PASS bootstrap self-test patch lifecycle: full-mode removal + dirty verify-only exact capture")
1212
1209
 
1210
+ for valid_named in (True, False):
1211
+ named_work = root / f"named-spec-{valid_named}"
1212
+ init_repo(named_work)
1213
+ named_spec = named_work / "X.md"
1214
+ named_spec.write_text(
1215
+ "---\ncomplexity: " + ("medium" if valid_named else "hihg") + "\n---\n# Named spec\n",
1216
+ encoding="utf-8",
1217
+ )
1218
+ (named_work / "spec.md").write_text(
1219
+ "---\ncomplexity: " + ("hihg" if valid_named else "medium") + "\n---\n# Unrelated spec\n",
1220
+ encoding="utf-8",
1221
+ )
1222
+ (named_work / "spec.expected.json").write_text(json.dumps({
1223
+ "verification_commands": [{"cmd": "printf named-contract"}],
1224
+ }), encoding="utf-8")
1225
+ try:
1226
+ result = bootstrap(["--spec", "X.md"], named_work, script_shared)
1227
+ except BootstrapBlocked as exc:
1228
+ assert not valid_named and exc.reason == "BLOCKED:invalid-flags", exc
1229
+ assert "frontmatter complexity" in exc.detail, exc.detail
1230
+ assert not (named_work / ".devlyn").exists()
1231
+ else:
1232
+ assert valid_named, "invalid named spec was accepted via unrelated spec.md"
1233
+ assert result["source"]["spec_path"] == "X.md"
1234
+ assert result["source"]["spec_sha256"] == sha256(named_spec.read_bytes())
1235
+ staged = strict_json((named_work / ".devlyn/spec-verify.json").read_text(encoding="utf-8"))
1236
+ assert staged["verification_commands"] == [{"cmd": "printf named-contract"}]
1237
+
1213
1238
  dirty_work = root / "dirty-repo"
1214
1239
  init_repo(dirty_work)
1215
1240
  bootstrap(["clean", "baseline"], dirty_work, script_shared)
@@ -844,10 +844,21 @@ def validate_expected_shape(data) -> str | None:
844
844
  return None
845
845
 
846
846
 
847
- def validate_expected_against_sibling_spec(expected_path: Path, data: object) -> str | None:
847
+ def validate_inline_shape(data: object) -> str | None:
848
+ if isinstance(data, dict):
849
+ unknown = sorted(set(data) - {"verification_commands"})
850
+ if unknown:
851
+ return (
852
+ f"unsupported inline key(s): {', '.join(unknown)}; inline carriers "
853
+ "support only verification_commands. Encode these checks as commands, "
854
+ "or use a real spec with sibling spec.expected.json."
855
+ )
856
+ return validate_shape(data) or validate_expected_shape(data)
857
+
858
+
859
+ def validate_expected_against_sibling_spec(spec_path: Path, data: object) -> str | None:
848
860
  if not isinstance(data, dict):
849
861
  return None
850
- spec_path = expected_path.with_name("spec.md")
851
862
  if not spec_path.is_file():
852
863
  return None
853
864
  try:
@@ -881,8 +892,7 @@ def validate_expected_against_sibling_spec(expected_path: Path, data: object) ->
881
892
  )
882
893
 
883
894
 
884
- def validate_sibling_spec_complexity(expected_path: Path) -> str | None:
885
- spec_path = expected_path.with_name("spec.md")
895
+ def validate_sibling_spec_complexity(spec_path: Path) -> str | None:
886
896
  if not spec_path.is_file():
887
897
  return None
888
898
  try:
@@ -1208,7 +1218,7 @@ def stage_from_source(md: Path, devlyn_dir: Path) -> tuple[bool, str | None]:
1208
1218
  data = loads_strict_json(block)
1209
1219
  except ValueError as e:
1210
1220
  return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
1211
- err = validate_shape(data)
1221
+ err = validate_inline_shape(data)
1212
1222
  if err:
1213
1223
  return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md}: {err}")
1214
1224
  normalized = {"verification_commands": data["verification_commands"]}
@@ -1236,6 +1246,9 @@ def stage_from_expected(
1236
1246
  if err:
1237
1247
  return (True, False, err, expected_path, None)
1238
1248
  assert data is not None
1249
+ err = validate_sibling_spec_complexity(md) or validate_expected_against_sibling_spec(md, data)
1250
+ if err:
1251
+ return (True, False, f"{expected_path}: {err}", expected_path, None)
1239
1252
  commands = data.get("verification_commands")
1240
1253
  if not commands:
1241
1254
  spec_path = devlyn_dir / "spec-verify.json"
@@ -1892,7 +1905,7 @@ def run_check_mode(md_path: Path) -> int:
1892
1905
  file=sys.stderr,
1893
1906
  )
1894
1907
  return 2
1895
- err = validate_shape(data)
1908
+ err = validate_inline_shape(data)
1896
1909
  if err:
1897
1910
  print(f"[spec-verify --check] {md_path}: shape error: {err}", file=sys.stderr)
1898
1911
  return 2
@@ -1915,11 +1928,11 @@ def run_check_expected_mode(expected_path: Path) -> int:
1915
1928
  if err:
1916
1929
  print(f"[spec-verify --check-expected] {expected_path}: shape error: {err}", file=sys.stderr)
1917
1930
  return 2
1918
- complexity_err = validate_sibling_spec_complexity(expected_path)
1931
+ complexity_err = validate_sibling_spec_complexity(expected_path.with_name("spec.md"))
1919
1932
  if complexity_err:
1920
1933
  print(f"[spec-verify --check-expected] {expected_path}: shape error: {complexity_err}", file=sys.stderr)
1921
1934
  return 2
1922
- sibling_err = validate_expected_against_sibling_spec(expected_path, _data)
1935
+ sibling_err = validate_expected_against_sibling_spec(expected_path.with_name("spec.md"), _data)
1923
1936
  if sibling_err:
1924
1937
  print(f"[spec-verify --check-expected] {expected_path}: shape error: {sibling_err}", file=sys.stderr)
1925
1938
  return 2
@@ -3691,6 +3704,54 @@ def run_self_test() -> int:
3691
3704
  print("generated criteria carrier was not staged into .devlyn/spec-verify.json", file=sys.stderr)
3692
3705
  return 1
3693
3706
 
3707
+ inline_marker = generated_user / "inline-command-ran"
3708
+ inline_command = "printf bad > inline-command-ran; printf bad"
3709
+ for source_type in ("generated", "spec"):
3710
+ for contract, diagnostic in (
3711
+ ({"verification_commands": [{"cmd": inline_command}],
3712
+ "required_files": ["missing.txt"]}, "unsupported inline key(s): required_files"),
3713
+ ({"verification_commands": [{"cmd": inline_command,
3714
+ "stdout_not_contians": ["bad"]}]}, "unknown key(s): stdout_not_contians"),
3715
+ ({"verification_commands": [{"cmd": inline_command,
3716
+ "stdout_not_contains": ["bad"]}]}, None),
3717
+ ):
3718
+ inline_marker.unlink(missing_ok=True)
3719
+ (generated_devlyn / output_findings_name()).unlink(missing_ok=True)
3720
+ generated_criteria.write_text(
3721
+ "# Inline constraints\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n"
3722
+ + json.dumps(contract) + "\n```\n", encoding="utf-8",
3723
+ )
3724
+ prefix = "criteria" if source_type == "generated" else "spec"
3725
+ (generated_devlyn / "pipeline.state.json").write_text(json.dumps({
3726
+ "source": {
3727
+ "type": source_type,
3728
+ f"{prefix}_path": str(generated_criteria),
3729
+ f"{prefix}_sha256": hashlib.sha256(generated_criteria.read_bytes()).hexdigest(),
3730
+ }
3731
+ }), encoding="utf-8")
3732
+ # A stale valid carrier must not bypass malformed source validation.
3733
+ (generated_devlyn / "spec-verify.json").write_text(json.dumps({
3734
+ "verification_commands": [{"cmd": inline_command}]
3735
+ }), encoding="utf-8")
3736
+ inline_check = subprocess.run(
3737
+ [sys.executable, script_path, "--check", str(generated_criteria)],
3738
+ cwd=generated_user, capture_output=True, text=True, encoding="utf-8",
3739
+ )
3740
+ inline_run = subprocess.run(
3741
+ [sys.executable, script_path], cwd=generated_user,
3742
+ capture_output=True, text=True, encoding="utf-8",
3743
+ )
3744
+ assert inline_check.returncode == (2 if diagnostic else 0)
3745
+ assert inline_run.returncode == 1
3746
+ if diagnostic:
3747
+ assert diagnostic in inline_check.stderr and diagnostic in inline_run.stderr
3748
+ assert not inline_marker.exists(), "malformed inline constraint executed a command"
3749
+ else:
3750
+ assert inline_marker.exists(), "valid inline output guard was not executed"
3751
+ assert "correctness.spec-literal-mismatch" in (
3752
+ generated_devlyn / output_findings_name()
3753
+ ).read_text(encoding="utf-8")
3754
+
3694
3755
  generated_criteria.write_text(
3695
3756
  "# Criteria\n\n## Verification\n\n- generated criteria omitted its machine-readable carrier.\n",
3696
3757
  encoding="utf-8",
@@ -3750,6 +3811,54 @@ def run_self_test() -> int:
3750
3811
  print("sibling spec.expected.json was not staged into .devlyn/spec-verify.json", file=sys.stderr)
3751
3812
  return 1
3752
3813
 
3814
+ pure_prose = "# Design\n\n<!-- devlyn:verification -->\n## Verification\n\n- No runtime verification commands.\n"
3815
+ stale_command = {"verification_commands": [{"cmd": "printf unexpected-command"}]}
3816
+ pure_inline = pure_prose + "\n```json\n" + json.dumps(stale_command) + "\n```\n"
3817
+ for name, filename, source, contract, expected_rule in (
3818
+ ("pure-prose", "spec.md", pure_prose, {"pure_design": True}, None),
3819
+ ("pure-inline", "design-notes.md", pure_inline, {"pure_design": True, "verification_commands": []}, None),
3820
+ ("empty-runtime", "design-notes.md", pure_inline, {"verification_commands": []}, "correctness.spec-verify-malformed"),
3821
+ ("contradictory-pure", "spec.md", pure_inline, {"pure_design": True, **stale_command}, "correctness.spec-verify-malformed"),
3822
+ ("pure-required-file", "spec.md", pure_inline, {"pure_design": True, "required_files": ["missing.md"]}, "correctness.required-file-missing"),
3823
+ ):
3824
+ case_root = work / name
3825
+ case_root.mkdir()
3826
+ case_devlyn = case_root / ".devlyn"
3827
+ case_devlyn.mkdir()
3828
+ case_spec = case_root / filename
3829
+ case_spec.write_text(source, encoding="utf-8")
3830
+ (case_root / "spec.expected.json").write_text(json.dumps(contract), encoding="utf-8")
3831
+ (case_devlyn / "spec-verify.json").write_text(json.dumps(stale_command), encoding="utf-8")
3832
+ (case_devlyn / "pipeline.state.json").write_text(json.dumps({
3833
+ "source": {"type": "spec", "spec_path": str(case_spec)},
3834
+ }), encoding="utf-8")
3835
+ case_run = subprocess.run(
3836
+ [sys.executable, script_path], cwd=case_root,
3837
+ capture_output=True, text=True, encoding="utf-8",
3838
+ )
3839
+ if case_run.returncode != (1 if expected_rule else 0):
3840
+ print(f"{name}: sibling execution returned {case_run.returncode}: {case_run.stderr}", file=sys.stderr)
3841
+ return 1
3842
+ case_findings = [
3843
+ loads_strict_json(line) for line in
3844
+ (case_devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()
3845
+ ]
3846
+ if [finding["rule_id"] for finding in case_findings] != ([expected_rule] if expected_rule else []):
3847
+ print(f"{name}: unexpected sibling findings: {case_findings}", file=sys.stderr)
3848
+ return 1
3849
+ case_results = case_devlyn / "spec-verify.results.json"
3850
+ if expected_rule == "correctness.spec-verify-malformed":
3851
+ if case_results.exists():
3852
+ print(f"{name}: malformed sibling reached command execution", file=sys.stderr)
3853
+ return 1
3854
+ elif (
3855
+ loads_strict_json(case_results.read_text(encoding="utf-8"))
3856
+ != {"commands": [], "process_evidence": None}
3857
+ or (case_devlyn / "spec-verify.json").exists()
3858
+ ):
3859
+ print(f"{name}: pure-design sibling ran or retained a stale command", file=sys.stderr)
3860
+ return 1
3861
+
3753
3862
  malformed = work / "malformed-sibling"
3754
3863
  malformed.mkdir()
3755
3864
  malformed_devlyn = malformed / ".devlyn"
@@ -4915,8 +5024,8 @@ def main() -> int:
4915
5024
  print(f"[spec-verify] carrier malformed: {expected_error}", file=sys.stderr)
4916
5025
  write_malformed_finding(devlyn_dir, expected_error, expected_path)
4917
5026
  return 1
4918
- if expected_staged:
4919
- staged, error = (True, None)
5027
+ if expected_found:
5028
+ staged, error = (expected_staged, None)
4920
5029
  else:
4921
5030
  staged, error = stage_from_source(source_md, devlyn_dir)
4922
5031
  else:
@@ -204,6 +204,9 @@ def allocate(args):
204
204
  path = directory / "receipt.json"
205
205
  receipt["id"] = key
206
206
  receipt["recovery_ref"] = "refs/devlyn/completed/" + key
207
+ scratch = directory / "scratch"
208
+ scratch.mkdir()
209
+ receipt["scratch_identity"] = workspace_identity(scratch, directory)
207
210
  # Persist prospective intent BEFORE native creation. An interrupted allocation
208
211
  # stays visibly incomplete; a later call may not adopt whatever now exists.
209
212
  atomic_json(path, receipt)
@@ -215,7 +218,7 @@ def allocate(args):
215
218
  receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
216
219
  receipt["allocation"] = "owned"
217
220
  atomic_json(path, receipt)
218
- return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target)}
221
+ return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch)}
219
222
 
220
223
 
221
224
  def pipeline_acceptance(work, acceptance, files, directory):
@@ -269,7 +272,7 @@ def pipeline_acceptance(work, acceptance, files, directory):
269
272
  require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "required process evidence failed")
270
273
  source = state.get("source") or {}
271
274
  if source.get("spec_path"):
272
- expected = Path(source["spec_path"]).with_suffix(".expected.json")
275
+ expected = Path(source["spec_path"]).with_name("spec.expected.json")
273
276
  original = safe_path(work, str(expected))
274
277
  if original.exists():
275
278
  dest = safe_path(reconstructed, str(expected))
@@ -342,7 +345,8 @@ def bind_acceptance(receipt, path, supplied):
342
345
  require(committed.returncode == 0 and committed.stdout == source_file.read_bytes(), "source contract changed since accepted commit")
343
346
  bound_digest = source.get(key.replace("_path", "_sha256"))
344
347
  require(file_record(source_file)["sha256"] == bound_digest, "source contract differs from run binding")
345
- expected = str(Path(source[key]).with_suffix(".expected.json"))
348
+ source_path = Path(source[key])
349
+ expected = str(source_path.with_name("spec.expected.json") if key == "spec_path" else source_path.with_suffix(".expected.json"))
346
350
  if safe_path(work, expected).exists():
347
351
  paths.append(expected)
348
352
  else:
@@ -437,6 +441,75 @@ def stopped_writers(work, *, linked):
437
441
  raise CompletionError("writer observation unsupported on this platform; retain workspace")
438
442
 
439
443
 
444
+ def scratch_mounts():
445
+ if sys.platform.startswith("linux"):
446
+ rows = Path("/proc/self/mountinfo").read_text(encoding="utf-8").splitlines()
447
+ paths = []
448
+ for line in rows:
449
+ fields = line.split()
450
+ require(len(fields) >= 6, "cannot parse mount table; retain scratch")
451
+ paths.append(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4]))
452
+ elif sys.platform == "darwin":
453
+ paths = []
454
+ for line in command(["mount"]).splitlines():
455
+ description, separator, options = line.rpartition(" (")
456
+ candidates = [description[match.end():] for match in re.finditer(" on ", description)]
457
+ require(separator and options.endswith(")") and candidates, "cannot parse mount table; retain scratch")
458
+ # Darwin prints literal paths. Consider every possible separator so
459
+ # ' on ' in a device or directory name can only cause retention.
460
+ paths.extend(candidates)
461
+ else:
462
+ raise CompletionError("mount observation unsupported; retain scratch")
463
+ return [Path(value).resolve() for value in paths]
464
+
465
+
466
+ def raise_walk_error(error):
467
+ raise error
468
+
469
+
470
+ def clean_scratch(receipt, path, writers_stopped):
471
+ """Empty the owned build directory; keep its identity across resume/reuse."""
472
+ scratch = path.parent / "scratch"
473
+ if "scratch_identity" not in receipt:
474
+ return {"status": "NOT_OWNED"}
475
+ require(writers_stopped, "stop/yield scratch writers, then use --writers-stopped")
476
+ require(scratch == scratch.resolve() and scratch.is_dir(), "owned scratch missing or redirected; retain and inspect")
477
+ identity = receipt["scratch_identity"]
478
+ require(workspace_identity(scratch, path.parent) == identity, "scratch directory was replaced; retain")
479
+ require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
480
+ require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
481
+ stopped_writers(scratch, linked=True)
482
+ size = 0
483
+ for directory, dirs, files in os.walk(scratch, followlinks=False, onerror=raise_walk_error):
484
+ require(not any(name.casefold() == ".git" for name in dirs + files), "scratch contains Git recovery data; move it to retained custody before cleanup")
485
+ for name in dirs + files:
486
+ item = Path(directory) / name
487
+ info = item.lstat()
488
+ require(info.st_dev == identity[0][0], "scratch contains a mounted filesystem; retain")
489
+ size += info.st_size if stat.S_ISREG(info.st_mode) else 0
490
+ stopped_writers(scratch, linked=True)
491
+ require(workspace_identity(scratch, path.parent) == identity, "scratch changed before removal; retain")
492
+ require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch mount appeared before removal; retain")
493
+ for item in scratch.iterdir():
494
+ if item.is_dir() and not item.is_symlink():
495
+ shutil.rmtree(item)
496
+ else:
497
+ item.unlink()
498
+ require(workspace_identity(scratch, path.parent) == identity and not any(scratch.iterdir()), "scratch changed during cleanup; retain and inspect")
499
+ result = {"status": "CLEAN", "logical_bytes_removed": size}
500
+ receipt["scratch_cleanup"] = result
501
+ atomic_json(path, receipt)
502
+ return result
503
+
504
+
505
+ def clean_scratch_command(args):
506
+ path = Path(args.receipt).absolute()
507
+ with locked_receipt(path) as receipt:
508
+ result = clean_scratch(receipt, path, args.writers_stopped)
509
+ return {"status": "SCRATCH_CLEAN" if result["status"] == "CLEAN" else "SCRATCH_NOT_OWNED",
510
+ "receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
511
+
512
+
440
513
  def cleanup(receipt, path, pr, writers_stopped):
441
514
  require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
442
515
  require(writers_stopped, "wait actual children, stop/yield known writers, then resume with --writers-stopped")
@@ -522,11 +595,27 @@ def locked_receipt(path):
522
595
 
523
596
  def complete(args):
524
597
  path = Path(args.receipt).absolute()
525
- resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])
598
+ resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
599
+ (["--writers-stopped"] if args.writers_stopped else []))
526
600
  with locked_receipt(path) as receipt:
527
601
  def result(status):
528
- return {"status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
529
- "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True}
602
+ scratch = {"status": "NOT_OWNED"}
603
+ if "scratch_identity" in receipt:
604
+ try:
605
+ scratch = clean_scratch(receipt, path, args.writers_stopped)
606
+ except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
607
+ scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
608
+ sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
609
+ receipt["scratch_cleanup"] = scratch
610
+ try:
611
+ atomic_json(path, receipt)
612
+ except OSError as record_error:
613
+ scratch["record_error"] = str(record_error)
614
+ return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and scratch["status"] == "RETAINED" else status,
615
+ "delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
616
+ "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
617
+ "scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
618
+ "scratch_cleanup": scratch}
530
619
  if args.local_only or receipt.get("local_only"):
531
620
  receipt["local_only"] = True
532
621
  atomic_json(path, receipt)
@@ -610,20 +699,24 @@ def main():
610
699
  completion.add_argument("--mode", choices=("auto", "pr"))
611
700
  completion.add_argument("--local-only", "--no-push", action="store_true")
612
701
  completion.add_argument("--writers-stopped", action="store_true")
702
+ scratch_cleanup = actions.add_parser("clean-scratch")
703
+ scratch_cleanup.add_argument("--receipt", required=True)
704
+ scratch_cleanup.add_argument("--writers-stopped", action="store_true")
613
705
  args = parser.parse_args()
614
706
  if args.self_test:
615
707
  return self_test()
616
708
  if args.action is None:
617
- parser.error("allocate or complete is required")
709
+ parser.error("allocate, complete or clean-scratch is required")
618
710
  try:
619
- result = allocate(args) if args.action == "allocate" else complete(args)
711
+ result = (allocate(args) if args.action == "allocate" else
712
+ clean_scratch_command(args) if args.action == "clean-scratch" else complete(args))
620
713
  print(json.dumps(result, sort_keys=True))
621
714
  return 0
622
- except (CompletionError, OSError, ValueError, KeyError, TypeError, SystemExit) as exc:
715
+ except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
623
716
  result = {"status": "BLOCKED", "reason": str(exc)}
624
- if args.action == "complete":
717
+ if args.action in {"complete", "clean-scratch"}:
625
718
  result["receipt"] = str(Path(args.receipt).absolute())
626
- result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", result["receipt"]])
719
+ result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
627
720
  print(json.dumps(result, sort_keys=True))
628
721
  return 1
629
722
 
@@ -770,11 +863,138 @@ class CompletionTests(unittest.TestCase):
770
863
  self.task = Path(result["worktree"])
771
864
  return result
772
865
 
773
- def accept(self, pipeline=False, queue=False, spec_expected=None):
866
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
867
+ def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
868
+ result = self.allocate()
869
+ scratch = Path(result["scratch"])
870
+ (scratch / "target").mkdir()
871
+ (scratch / "target" / "object").write_bytes(b"rebuildable")
872
+ (self.task / "uncommitted-work").write_text("preserve")
873
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
874
+ self.assertEqual(result["status"], "LOCAL_ONLY")
875
+ self.assertFalse(any(scratch.iterdir()))
876
+ self.assertEqual((self.task / "uncommitted-work").read_text(), "preserve")
877
+ self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 11)
878
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
879
+ self.assertEqual(result["status"], "SCRATCH_CLEAN")
880
+
881
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
882
+ def test_scratch_rejects_active_writer_and_requires_owner_assertion(self):
883
+ scratch = Path(self.allocate()["scratch"])
884
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, success=False)
885
+ self.assertIn("--writers-stopped", result["reason"])
886
+ child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
887
+ try:
888
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
889
+ self.assertIn("active process", result["reason"])
890
+ self.assertTrue(scratch.is_dir())
891
+ finally:
892
+ child.terminate()
893
+ child.wait(timeout=5)
894
+ self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
895
+
896
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
897
+ def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
898
+ scratch = Path(self.allocate()["scratch"])
899
+ outside = self.root / "outside"
900
+ outside.mkdir()
901
+ (outside / "keep").write_text("source")
902
+ retained = scratch.with_name("retained")
903
+ scratch.rename(retained)
904
+ scratch.symlink_to(outside, target_is_directory=True)
905
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
906
+ self.assertIn("redirected", result["reason"])
907
+ scratch.unlink()
908
+ retained.rename(scratch)
909
+ (scratch / ".GiT").write_text("gitdir: elsewhere")
910
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
911
+ self.assertIn("Git recovery data", result["reason"])
912
+ (scratch / ".GiT").unlink()
913
+ (scratch / "external-link").symlink_to(outside, target_is_directory=True)
914
+ self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
915
+ self.assertEqual((outside / "keep").read_text(), "source")
916
+
917
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
918
+ def test_scratch_reuse_and_partial_cleanup_keep_original_identity(self):
919
+ scratch = Path(self.allocate()["scratch"])
920
+ (scratch / "first-build").write_bytes(b"first")
921
+ self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
922
+ (scratch / "resumed-build").write_bytes(b"next")
923
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
924
+ self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 4)
925
+ self.assertFalse(any(scratch.iterdir()))
926
+ scratch.rename(scratch.with_name("original"))
927
+ scratch.mkdir()
928
+ (scratch / "foreign").write_text("preserve")
929
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
930
+ self.assertIn("replaced", result["reason"])
931
+ self.assertIn("--writers-stopped", result["resume"])
932
+ self.assertEqual((scratch / "foreign").read_text(), "preserve")
933
+
934
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
935
+ def test_scratch_refusal_does_not_block_local_only_delivery(self):
936
+ scratch = Path(self.allocate()["scratch"])
937
+ (scratch / ".git").mkdir()
938
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
939
+ self.assertEqual(result["status"], "LOCAL_ONLY")
940
+ self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
941
+ self.assertIn("Git recovery data", result["scratch_cleanup"]["reason"])
942
+ self.assertEqual(json.loads(self.receipt.read_text())["scratch_cleanup"]["status"], "RETAINED")
943
+ self.assertTrue((scratch / ".git").is_dir())
944
+
945
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
946
+ def test_scratch_refusal_reports_delivery_separately(self):
947
+ scratch = Path(self.allocate()["scratch"])
948
+ self.accept()
949
+ (scratch / ".git").mkdir()
950
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped")
951
+ self.assertEqual(result["status"], "CLEANUP_PENDING")
952
+ self.assertEqual(result["delivery_status"], "COMPLETE")
953
+ self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
954
+ self.assertIn("--writers-stopped", result["scratch_cleanup"]["resume"])
955
+ self.assertIn("--writers-stopped", result["resume"])
956
+ (scratch / ".git").rmdir()
957
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--writers-stopped")
958
+ self.assertEqual(result["status"], "COMPLETE")
959
+
960
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
961
+ def test_scratch_mount_and_scan_failure_preserve_contents(self):
962
+ from unittest.mock import patch
963
+ scratch = Path(self.allocate()["scratch"])
964
+ (scratch / "keep").write_text("retained")
965
+ receipt = json.loads(self.receipt.read_text())
966
+ with patch.dict(clean_scratch.__globals__, {"scratch_mounts": lambda: [scratch / "same-device-bind"]}):
967
+ with self.assertRaisesRegex(CompletionError, "mounted filesystem"):
968
+ clean_scratch(receipt, self.receipt, True)
969
+ def failed_walk(*args, **kwargs):
970
+ kwargs["onerror"](PermissionError("unreadable subtree"))
971
+ with patch.object(os, "walk", failed_walk):
972
+ with self.assertRaisesRegex(PermissionError, "unreadable subtree"):
973
+ clean_scratch(receipt, self.receipt, True)
974
+ self.assertEqual((scratch / "keep").read_text(), "retained")
975
+
976
+ def test_scratch_mount_table_parsing_preserves_ambiguous_literal_paths(self):
977
+ from unittest.mock import patch
978
+ target = "/task/scratch/name on inside\\040literal"
979
+ with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
980
+ "command": lambda args: "device on source on " + target + " (local, journaled)",
981
+ }):
982
+ self.assertIn(Path(target).resolve(), scratch_mounts())
983
+ alias = self.root / "scratch-alias"
984
+ alias.symlink_to(self.root, target_is_directory=True)
985
+ with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
986
+ "command": lambda args: "device on " + str(alias / "mounted") + " (local)",
987
+ }):
988
+ self.assertEqual(scratch_mounts(), [(self.root / "mounted").resolve()])
989
+ with patch.object(sys, "platform", "linux"), patch.object(Path, "read_text", return_value="truncated row"):
990
+ with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
991
+ scratch_mounts()
992
+
993
+ def accept(self, pipeline=False, queue=False, spec_expected=None, spec_name="spec.md"):
774
994
  if spec_expected is not None:
775
- (self.task / "spec.md").write_text("# Fixture\nProduct contains accepted bytes.\n", encoding="utf-8")
995
+ (self.task / spec_name).write_text("# Fixture\nProduct contains accepted bytes.\n", encoding="utf-8")
776
996
  (self.task / "spec.expected.json").write_text(json.dumps(spec_expected), encoding="utf-8")
777
- self.g("add", "spec.md", "spec.expected.json", work=self.task)
997
+ self.g("add", spec_name, "spec.expected.json", work=self.task)
778
998
  (self.task / "product").write_text("accepted\n", encoding="utf-8")
779
999
  self.g("add", "product", work=self.task)
780
1000
  self.g("commit", "-m", "scoped task", work=self.task)
@@ -802,7 +1022,7 @@ class CompletionTests(unittest.TestCase):
802
1022
  self.state = {"run_id": a["run_id"], "mode": "free-form", "source": source, "phases": phases, "process_evidence": None}
803
1023
  if spec_expected is not None:
804
1024
  self.state["mode"] = "spec"
805
- self.state["source"] = {"type": "spec", "spec_path": "spec.md", "spec_sha256": hashlib.sha256((self.task / "spec.md").read_bytes()).hexdigest()}
1025
+ self.state["source"] = {"type": "spec", "spec_path": spec_name, "spec_sha256": hashlib.sha256((self.task / spec_name).read_bytes()).hexdigest()}
806
1026
  (archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
807
1027
  (archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}), encoding="utf-8")
808
1028
  (archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}), encoding="utf-8")
@@ -1167,6 +1387,22 @@ class CompletionTests(unittest.TestCase):
1167
1387
  self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
1168
1388
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1169
1389
 
1390
+ def test_named_spec_custody(self):
1391
+ self.allocate(); self.accept(pipeline=True, spec_expected={"pure_design": True}, spec_name="X.md")
1392
+ result, _ = self.complete("--mode", "pr")
1393
+ self.assertEqual(result["status"], "PR")
1394
+ receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
1395
+ self.assertIn("spec.expected.json", receipt["files"])
1396
+ self.assertEqual((self.receipt.parent / "custody/spec.expected.json").read_bytes(), (self.task / "spec.expected.json").read_bytes())
1397
+
1398
+ def test_named_spec_missing_required_process_evidence(self):
1399
+ expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
1400
+ self.allocate(); self.accept(pipeline=True, spec_expected=expected, spec_name="X.md")
1401
+ _, r = self.complete(success=False)
1402
+ self.assertNotEqual(r.returncode, 0)
1403
+ self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
1404
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1405
+
1170
1406
 
1171
1407
  if __name__ == "__main__":
1172
1408
  runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
@@ -96,7 +96,7 @@ python3 "$DEVLYN_SHARED_DIR/spec-verify-check.py" --check-expected <expected-pat
96
96
  ```
97
97
 
98
98
  Map mechanically decidable rules from both Requirements and Constraints into the existing carriers. Validate each new guard with a violating control that fails and an allowed control that passes, preserving permitted pre-existing occurrences:
99
- - `verification_commands` ← `## Verification` + commands the conversation surfaced. For semantic annotation or exception-handling rules, use a suitable syntax-aware/executable check, preferring existing checks; explicitly retain uncovered semantics as source-review obligations.
99
+ - `verification_commands` ← `## Verification` + commands the conversation surfaced. While drafting both Verification and its carrier, omit a standalone check only when another command in that contract runs the same assertions under the required execution conditions and propagates failure; describe that shared coverage in Verification and keep both carriers aligned. Preserve distinct scenarios and required independent verification; execute approved commands unchanged without reusing results across phases. For semantic annotation or exception-handling rules, use a suitable syntax-aware/executable check, preferring existing checks; explicitly retain uncovered semantics as source-review obligations.
100
100
  - `forbidden_patterns` ← literal syntax constraints only; regex does not prove semantics. It scans the whole file-filtered diff, including context, deletions and headers; additions-only guards must exclude those lines.
101
101
  - `required_files` / `forbidden_files` ← file requirements and exclusions in Requirements, Constraints and Out of Scope.
102
102
  - `max_deps_added` ← dependency limits in Requirements or Constraints (default 0 unless the spec licenses a new dep).
@@ -75,7 +75,7 @@ When the rules are silent (rare — pathological goal text), default to `medium`
75
75
  The internal mini-spec written for trivial / medium / large-assumptions paths must satisfy:
76
76
 
77
77
  - `## Requirements` non-empty, each bullet testable (CLI command, test command, observable file change).
78
- - For generated verification carriers, follow `../../devlyn:ideate/references/spec-template.md` § "Sibling file: `spec.expected.json`" for constraint coverage, diff scope and guard controls.
78
+ - For executable generated verification, put one fenced `json` object with only `verification_commands` inside the sentinel-marked `## Verification` section. Generated mode reads this inline carrier, not a sibling file. Follow `../../devlyn:ideate/references/spec-template.md` § "Sibling file: `spec.expected.json`" for constraint coverage, diff scope and violating/allowed controls, but express those checks as executable `verification_commands`; sibling-only fields such as `required_files` and `forbidden_patterns` are rejected inline. Preserve uncovered semantics as source-review obligations.
79
79
  - `## Verification` is preceded by a `<!-- devlyn:verification -->` sentinel on its own line directly above the heading — the machine locator `spec-verify-check.py` uses; the heading text itself is decorative and may be any language. `## Verification` non-empty if the goal implies any runnable acceptance check. Empty Verification is allowed only when all Requirements are pure-design (e.g. "follow existing pattern X").
80
80
  - If a free-form goal includes pair-evidence intent and already includes an actionable solo-headroom hypothesis, preserve that literal hypothesis in `.devlyn/criteria.generated.md` unchanged enough for VERIFY to detect `solo-headroom hypothesis`, `solo_claude`, `miss`, and the backticked observable command line that itself contains `miss`, emit the canonical `spec.solo_headroom_hypothesis` pair trigger reason, and satisfy regenerated-evidence checks such as `benchmark audit --require-hypothesis-trigger`.
81
81
  - If a free-form goal includes unmeasured pair-candidate intent and already includes solo ceiling avoidance, preserve that literal note in `.devlyn/criteria.generated.md` unchanged enough for reviewers to see `solo ceiling avoidance`, `solo_claude`, and the concrete difference from rejected or solo-saturated controls such as `S2`-`S6`.
@@ -59,7 +59,7 @@ Append all findings; do not stop on the first failure.
59
59
 
60
60
  <output>
61
61
  - `.devlyn/build_gate.findings.jsonl` — JSONL stream, one finding per line. Schema: `{id, rule_id, severity, file, line, message, fix_hint, criterion_ref}`.
62
- - `.devlyn/build_gate.log.md` — human-readable summary of which gates ran and their raw output.
62
+ - `.devlyn/build_gate.log.md` — human-readable gate results with raw output or links to complete raw-output files, using sealed artifacts where available. Retain complete inventories/hash maps in `.devlyn/` files; report their paths, digests and relevant deltas instead of dumping them into the conversation.
63
63
  - Report `PASS` if zero CRITICAL/HIGH findings, `FAIL` for genuine product findings, or `BLOCKED:build-env-underprovisioned` only for the evidence-backed capability path above. A capability blocker leaves no product finding. Do not edit `pipeline.state.json` yourself — the orchestrator records it via `state-phase-write.py` from these artifacts.
64
64
  </output>
65
65
 
@@ -10,11 +10,13 @@ Independent quality layer. You answer one question: did the diff deliver what th
10
10
  - `spec.md` (or `.devlyn/criteria.generated.md` for free-form mode) — the contract.
11
11
  - `spec.expected.json` — the mechanical acceptance contract per `_shared/expected.schema.json`.
12
12
  - The cumulative diff against `state.base_ref.sha`.
13
- - The source hash (`state.source.spec_sha256` for spec mode, `state.source.criteria_sha256` for generated free-form mode) — re-read the source contract from disk and confirm the hash matches; if it does not, write `state.phases.verify.verdict: "BLOCKED"` with reason `source_sha256_mismatch` and stop.
14
- - `.devlyn/spec-verify.results.json` plus the validated VERIFY
13
+ - The source hash (`state.source.spec_sha256` for spec mode, `state.source.criteria_sha256` for generated free-form mode) — re-read the source contract from disk and confirm the hash matches; if it does not, emit a verdict-binding finding with reason `source_sha256_mismatch` and terminal verdict `BLOCKED`, then stop reviewing without editing state.
14
+ - `.devlyn/spec-verify.results.json` plus any required validated VERIFY
15
15
  process-evidence manifest and raw stdout/stderr streams named by its
16
16
  `process_evidence` carrier. These are immutable MECHANICAL results; rehash
17
- every named byte before JUDGE spawn and again during merge.
17
+ every named byte before JUDGE spawn and again during merge. Valid pure-design
18
+ contracts with no executable obligations have `commands: []` and
19
+ `process_evidence: null`.
18
20
 
19
21
  You do NOT receive: PLAN, IMPLEMENT's reasoning, BUILD_GATE's findings, CLEANUP's allowlist negotiations. Reading those would compromise independence. Inspect authorized source, diff and sealed evidence using native read/search tools or non-mutating shell commands; executable verification belongs exclusively to MECHANICAL.
20
22
  </input>
@@ -40,14 +42,14 @@ On the constrained Windows read route, the orchestrator supplies the complete sp
40
42
 
41
43
  Grade the diff against the spec on rubric axes:
42
44
 
43
- - **Spec compliance** — did every Requirement get an `evidence` record pointing at code that satisfies it?
45
+ - **Spec compliance** — does cited evidence show how every applicable Requirement and Constraint is satisfied?
44
46
  - **Scope** — does the diff touch only files PLAN listed (or the cleanup allowlist)? Out-of-scope file = HIGH finding `scope.out-of-scope-violation`.
45
47
  - **Quality** — does the implementation follow the framework's idiomatic patterns, or are there hand-rolled helpers replacing standard primitives? `design.unidiomatic-pattern` MEDIUM if so.
46
48
  - **Consistency** — internal style (naming, error shape, module boundaries) consistent with the surrounding code.
47
49
 
48
50
  **Bounded primary review**: the primary JUDGE makes one broad pass over the
49
51
  source contract, sealed MECHANICAL carrier, and cumulative diff, covering all
50
- four rubric axes and every binding Requirement clause. It then makes one
52
+ four rubric axes and every binding Requirement and Constraint clause. It then makes one
51
53
  targeted interaction pass over the clauses the broad pass left unresolved.
52
54
  Before any third pass, emit the required terminal result. If R1–R8 or another
53
55
  spec axis remains uncovered, emit a verdict-binding BLOCKED coverage finding
@@ -55,7 +57,7 @@ instead of continuing or assuming PASS.
55
57
 
56
58
  For each finding, write file:line evidence. Do not paraphrase code; quote it.
57
59
 
58
- **Clause-level check**: split each Requirement into its binding clauses before
60
+ **Clause-level check**: split each Requirement and Constraint into its binding clauses before
59
61
  you pass it. Words like `before`, `after`, `once`, `always`, `never`,
60
62
  `regardless`, `irrelevant`, `permanent`, `idempotent`, `duplicate`, `raw`, and
61
63
  `signature` usually encode a separate invariant. A passing verification command
@@ -90,7 +92,7 @@ risk probes and present in the sealed MECHANICAL evidence. Missing coverage is
90
92
  a verdict-binding finding; review the implementation's clause and code order
91
93
  without inventing a replacement command.
92
94
 
93
- **Coverage check**: before declaring done, confirm you have sealed evidence and code-order support for every spec axis. If an axis lacks declared MECHANICAL coverage, or the diff does not touch the code that produces it, set `state.verify.coverage_failed: true` and surface the missing-evidence finding rather than passing on assumption.
95
+ **Coverage check**: account for every applicable Requirement and Constraint using sealed MECHANICAL evidence for required executable checks and cited source/design evidence for pure-design clauses or explicitly retained source-review obligations. Inspect applicable unchanged code and documentation; absence of a code change alone is not missing coverage. Source review never substitutes for required executable coverage. For any unsupported clause, emit a verdict-binding coverage finding identifying the missing evidence. JUDGE does not run checks or edit state; the orchestrator records `state.verify.coverage_failed: true` from coverage findings before merge.
94
96
 
95
97
  **Verdict-binding check**: a demonstrated violation of an applicable mandatory
96
98
  task, public, or existing test contract is binding, including unmet new
@@ -67,7 +67,7 @@ Single authoritative verdict source for `/devlyn:resolve`. The orchestrator bran
67
67
  - **implement_passed_sha** — captured at end of PHASE 2; null until then. Activates the post-implement invariant for SURFACE_CLOSE, CLEANUP, and VERIFY.
68
68
  - **criteria** — generated from spec's `## Requirements` checklist (one per `- [ ]`). `status: pending → implemented` is the legal transition. `failed_by_finding_ids` populates when VERIFY surfaces a finding tied to a criterion.
69
69
  - **`.devlyn/untracked.baseline` artifact** — written at PHASE 0 (`spec-verify-check.py --write-untracked-baseline`); BUILD_GATE flags created-during-run unauthorized untracked files against it (`.devlyn/` exempt; semantics in `phases/build-gate.md` step 4).
70
- - **verify.coverage_failed** — outcome-dependent telemetry set when JUDGE cannot exercise a spec axis. It no longer gates pair dispatch: after MECHANICAL passes, `pair.default` dispatches both judges concurrently whenever `pair_default_enabled != false` and the OTHER engine is available. An orchestrator without foreground parallel dispatch runs the same two required judges sequentially; a primary blocker never skips the pair. A verdict-binding MECHANICAL blocker still skips both. Legacy complexity values remain accepted only for archived compatibility.
70
+ - **verify.coverage_failed** — outcome-dependent telemetry set by the orchestrator when JUDGE reports missing clause evidence. It no longer gates pair dispatch: after MECHANICAL passes, `pair.default` dispatches both judges concurrently whenever `pair_default_enabled != false` and the OTHER engine is available. An orchestrator without foreground parallel dispatch runs the same two required judges sequentially; a primary blocker never skips the pair. A verdict-binding MECHANICAL blocker still skips both. Legacy complexity values remain accepted only for archived compatibility.
71
71
  - **verify.pair_trigger** — strict decision state: `{ "eligible": boolean, "reasons": string[], "skipped_reason": string|null }`. Schema v3.0 eligible state requires `pair.default` plus every applicable telemetry reason and `skipped_reason: null`; outcome-dependent reasons are appended after both judges return. Canonical reasons are `pair.default`, `mode.verify-only`, `mode.pair-verify`, `complexity.high`, `complexity.large`, `spec.complexity.high`, `spec.complexity.large`, `spec.solo_headroom_hypothesis`, `risk.high`, `risk_probes.enabled`, `risk_probes.present`, `coverage.failed`, `mechanical.warning`, and `judge.warning`. Ineligible new-run state has empty reasons and only `user_no_pair`, `mechanical_blocker`, `auto_pair_other_engine_unavailable`, or null; `primary_judge_blocker` remains parser-recognized only for archived v2.0 replay and retains its existing pre-known-reason rejection. Explicit `mode.pair-verify` cannot use the unavailable-engine skip. Missing, contradictory, incomplete, or unknown trigger state BLOCKs VERIFY.
72
72
 
73
73
  ## Per-phase shape
@@ -24,6 +24,33 @@ names look generated. An interrupted allocation stays blocked for inspection;
24
24
  do not delete its receipt and enroll the resulting branch. Pre-existing tasks
25
25
  remain owner-managed and cannot use this helper retroactively.
26
26
 
27
+ Allocation also returns a receipt-owned `scratch` directory. Put disposable
28
+ build intermediates there (for example, set
29
+ `CARGO_TARGET_DIR` to `<scratch>/target`). Keep source checkouts, Git data,
30
+ lockfiles, reports and restart evidence outside scratch. Do not create unowned
31
+ temporary build trees or preserve them by copying them into evidence custody.
32
+ Prefer `TemporaryDirectory`/`finally` for short-lived test fixtures.
33
+
34
+ After waiting for task writers, `complete --writers-stopped` empties this
35
+ scratch on delivery returns, including local-only and pending-PR routes. To
36
+ release build caches when parking or after a failed run, use:
37
+
38
+ ```sh
39
+ python3 "$DEVLYN_SHARED_DIR/task-complete.py" clean-scratch \
40
+ --receipt '<returned receipt.json>' --writers-stopped
41
+ ```
42
+
43
+ This empties only the prospectively owned scratch, preserves its directory
44
+ identity for interrupted cleanup and later builds, checks live users and reports
45
+ the logical byte count separately from actual free disk space. It does not depend on a product PASS or rewrite that verdict.
46
+ Unknown historical roots, source/recovery data, shared caches and terminal
47
+ history are never adopted or swept. A refused cleanup does not block source
48
+ delivery; it stays visible in the handoff with its receipt and retry command
49
+ (`CLEANUP_PENDING` after delivered source). No task is described as cleaned
50
+ while owned disposable files remain. On resumption, finish any pending scratch
51
+ cleanup before allocating more temporary storage. Rebuild from retained inputs
52
+ when needed; cleanup is not an instruction to restart parked work.
53
+
27
54
  ## Accept a scoped commit
28
55
 
29
56
  Direct work: finish actual decisive checks and diff review, stage only the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — take intent to shipped, engineer-quality software hands-free with phase-gated pipelines and agent orchestration",
5
5
  "homepage": "https://github.com/fysoul17/devlyn-cli#readme",
6
6
  "bin": {