devlyn-cli 3.1.2 → 3.2.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.
Files changed (33) hide show
  1. package/AGENTS.md +6 -6
  2. package/CLAUDE.md +7 -7
  3. package/README.md +34 -13
  4. package/bin/devlyn.js +44 -65
  5. package/bin/instruction-templates.json +82 -0
  6. package/bin/instructions.js +187 -0
  7. package/config/skills/_shared/adapters/claude.md +1 -5
  8. package/config/skills/_shared/adapters/codex.md +1 -5
  9. package/config/skills/_shared/adapters/omp.md +0 -4
  10. package/config/skills/_shared/archive_run.py +5 -1
  11. package/config/skills/_shared/codex-config.md +3 -14
  12. package/config/skills/_shared/engine-preflight.md +3 -3
  13. package/config/skills/_shared/expected.schema.json +2 -2
  14. package/config/skills/_shared/platform-support.py +4 -1
  15. package/config/skills/_shared/resolve-bootstrap.py +22 -2
  16. package/config/skills/_shared/role-config.py +14 -2
  17. package/config/skills/_shared/spec-verify-check.py +427 -101
  18. package/config/skills/_shared/state-phase-write.py +497 -49
  19. package/config/skills/_shared/task-complete.py +46 -5
  20. package/config/skills/_shared/terminal-claim-check.py +2 -2
  21. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  22. package/config/skills/devlyn:ideate/references/from-spec-mode.md +1 -1
  23. package/config/skills/devlyn:ideate/references/spec-template.md +2 -2
  24. package/config/skills/devlyn:resolve/SKILL.md +29 -51
  25. package/config/skills/devlyn:resolve/references/free-form-mode.md +2 -2
  26. package/config/skills/devlyn:resolve/references/phases/build-gate.md +8 -7
  27. package/config/skills/devlyn:resolve/references/phases/cleanup.md +5 -11
  28. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -0
  29. package/config/skills/devlyn:resolve/references/phases/plan.md +3 -3
  30. package/config/skills/devlyn:resolve/references/state-schema.md +10 -9
  31. package/package.json +2 -1
  32. package/scripts/lint-skills.sh +14 -4
  33. package/scripts/test-owner-phases.py +193 -0
@@ -21,7 +21,8 @@ Default mode (BUILD_GATE invocation, no args):
21
21
  (3) For generated criteria and legacy handwritten specs without a sibling,
22
22
  source markdown extract reads `pipeline.state.json:
23
23
  source.{spec_path | criteria_path}` and extracts a `## Verification`
24
- ```json``` block. If present, overwrite `.devlyn/spec-verify.json`.
24
+ ```json``` block. Stage executable commands; an explicit pure-design
25
+ contract removes stale `.devlyn/spec-verify.json` instead.
25
26
  (4) If no json block in source AND source.type=="generated": emit
26
27
  CRITICAL `correctness.spec-verify-malformed` so the fix-loop reruns
27
28
  BUILD.
@@ -38,14 +39,13 @@ Default mode (BUILD_GATE invocation, no args):
38
39
  run-fixture.sh's combined-stream matching semantics.
39
40
 
40
41
  Check mode (`--check <markdown_path>`):
41
- - Used by /devlyn:ideate after writing each item spec to validate that the
42
- generated `## Verification` ```json``` block parses + matches the schema,
43
- and that present `complexity` frontmatter has a supported value.
44
- - Exits 0 if the block is well-formed (or absent — ideate's check applies
45
- to both new specs that include the block and pre-carrier handwritten
46
- specs that omit it; absence is not failure here, only malformed JSON or
47
- shape error is). Exits 2 on malformed json, shape error, or unsupported
48
- `complexity` value.
42
+ - Spec-source authoring preflight: a present sibling `spec.expected.json`
43
+ takes precedence; otherwise validate the legacy inline carrier. Validate
44
+ metadata against the given Markdown path, including non-canonical names.
45
+ - Exits 2 on malformed carrier or metadata, without falling back from a bad
46
+ sibling to inline content. An absent sibling and sentinel retain opt-in
47
+ success. Never stages or executes commands. Generated-source runtime has
48
+ its own inline validation and must not use this sibling-precedence route.
49
49
 
50
50
  Expected-contract check mode (`--check-expected <json_path>`):
51
51
  - Used by /devlyn:ideate after writing sibling `spec.expected.json`.
@@ -846,14 +846,21 @@ def validate_expected_shape(data) -> str | None:
846
846
 
847
847
  def validate_inline_shape(data: object) -> str | None:
848
848
  if isinstance(data, dict):
849
- unknown = sorted(set(data) - {"verification_commands"})
849
+ unknown = sorted(set(data) - {"verification_commands", "pure_design"})
850
850
  if unknown:
851
851
  return (
852
852
  f"unsupported inline key(s): {', '.join(unknown)}; inline carriers "
853
- "support only verification_commands. Encode these checks as commands, "
853
+ "support only verification_commands and pure_design. Encode these checks as commands, "
854
854
  "or use a real spec with sibling spec.expected.json."
855
855
  )
856
- return validate_shape(data) or validate_expected_shape(data)
856
+ error = validate_expected_shape(data)
857
+ if error:
858
+ return error
859
+ if isinstance(data, dict) and data.get("pure_design") is True:
860
+ if data.get("verification_commands") != []:
861
+ return "inline pure_design: true requires an explicit empty verification_commands list"
862
+ return None
863
+ return validate_shape(data)
857
864
 
858
865
 
859
866
  def validate_expected_against_sibling_spec(spec_path: Path, data: object) -> str | None:
@@ -1159,9 +1166,11 @@ def risk_probes_state_error(state: dict) -> str | None:
1159
1166
 
1160
1167
 
1161
1168
  def source_integrity_error(src_type: str | None, state: dict, source_md: Path | None) -> str | None:
1169
+ src = state.get("source") if isinstance(state.get("source"), dict) else {}
1162
1170
  if source_md is None:
1171
+ if src_type == "generated":
1172
+ return f"source.criteria_path must identify an existing generated criteria file; declared path: {src.get('criteria_path')!r}."
1163
1173
  return None
1164
- src = state.get("source") if isinstance(state.get("source"), dict) else {}
1165
1174
  if src_type == "generated":
1166
1175
  field = "criteria_sha256"
1167
1176
  required = True
@@ -1198,33 +1207,33 @@ def load_expected_contract(expected_path: Path) -> tuple[dict | None, str | None
1198
1207
  return (data, None)
1199
1208
 
1200
1209
 
1201
- def stage_from_source(md: Path, devlyn_dir: Path) -> tuple[bool, str | None]:
1210
+ def stage_from_source(md: Path, devlyn_dir: Path) -> tuple[bool, bool, str | None]:
1202
1211
  """Materialize .devlyn/spec-verify.json from the json block in `md`.
1203
1212
 
1204
- Returns (staged, error). staged=True → wrote spec-verify.json. error
1205
- non-None → carrier was found but malformed (caller emits CRITICAL) —
1206
- this now also covers a `<!-- devlyn:verification -->` sentinel present
1207
- with no fenced ```json``` block inside it (the author clearly intended a
1208
- contract; a missing fence is a mistake, not a no-op). staged=False,
1209
- error=None → the sentinel is absent entirely (handwritten spec or
1210
- generated source missing the contract).
1213
+ Returns (found, staged, error). found=False means no sentinel. A present
1214
+ sentinel without a valid fenced JSON contract returns an error. A valid
1215
+ pure-design contract removes stale staging and returns (True, False, None);
1216
+ executable commands return (True, True, None) after writing spec-verify.json.
1211
1217
  """
1212
1218
  section_found, block = extract_verification_block(md.read_text(encoding="utf-8"))
1213
1219
  if not section_found:
1214
- return (False, None)
1220
+ return (False, False, None)
1215
1221
  if block is None:
1216
- return (False, f"`<!-- devlyn:verification -->` section in {md} has no fenced ```json``` block")
1222
+ return (True, False, f"`<!-- devlyn:verification -->` section in {md} has no fenced ```json``` block")
1217
1223
  try:
1218
1224
  data = loads_strict_json(block)
1219
1225
  except ValueError as e:
1220
- return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
1226
+ return (True, False, f"`<!-- devlyn:verification -->` ```json``` block in {md} has invalid JSON: {e}")
1221
1227
  err = validate_inline_shape(data)
1222
1228
  if err:
1223
- return (False, f"`<!-- devlyn:verification -->` ```json``` block in {md}: {err}")
1229
+ return (True, False, f"`<!-- devlyn:verification -->` ```json``` block in {md}: {err}")
1230
+ if data["verification_commands"] == []:
1231
+ (devlyn_dir / "spec-verify.json").unlink(missing_ok=True)
1232
+ return (True, False, None)
1224
1233
  normalized = {"verification_commands": data["verification_commands"]}
1225
1234
  devlyn_dir.mkdir(parents=True, exist_ok=True)
1226
1235
  (devlyn_dir / "spec-verify.json").write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
1227
- return (True, None)
1236
+ return (True, True, None)
1228
1237
 
1229
1238
 
1230
1239
  def stage_from_expected(
@@ -1335,7 +1344,7 @@ def diff_text_for_expected(work: Path, devlyn_dir: Path, state: dict) -> tuple[s
1335
1344
  external_diff = devlyn_dir / "external-diff.patch"
1336
1345
  if external_diff.is_file():
1337
1346
  try:
1338
- return (external_diff.read_text(encoding="utf-8"), None)
1347
+ return (external_diff.read_text(encoding="utf-8", errors="surrogateescape"), None)
1339
1348
  except OSError as e:
1340
1349
  return ("", f"cannot read {external_diff}: {e}")
1341
1350
  base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
@@ -1374,28 +1383,46 @@ def count_deps_added(work: Path, state: dict) -> int:
1374
1383
  return count
1375
1384
 
1376
1385
 
1377
- def changed_files(work: Path, state: dict, devlyn_dir: Path) -> list[str]:
1386
+ def changed_files(work: Path, state: dict, devlyn_dir: Path) -> tuple[list[str], str | None]:
1378
1387
  external_diff = devlyn_dir / "external-diff.patch"
1379
1388
  if external_diff.is_file():
1380
1389
  names: list[str] = []
1381
1390
  try:
1382
- external_text = external_diff.read_text(encoding="utf-8")
1383
- except OSError:
1384
- return []
1385
- for line in external_text.splitlines():
1386
- if line.startswith("diff --git "):
1387
- parts = line.split()
1388
- if len(parts) >= 4:
1389
- names.append(parts[3].removeprefix("b/"))
1390
- return names
1391
+ external_bytes = external_diff.read_bytes()
1392
+ except OSError as error:
1393
+ return ([], f"cannot read {external_diff}: {error}")
1394
+ if not external_bytes:
1395
+ return ([], None)
1396
+ # Prefix depth is otherwise ambiguous (src/x becomes x with -p1).
1397
+ headers = [line for line in external_bytes.split(b"\n") if line.startswith(b"diff --git ")]
1398
+ if not headers or any(not re.fullmatch(
1399
+ rb'diff --git (a/[^"\r]*|"a/(?:[^"\\]|\\.)*") (b/[^"\r]*|"b/(?:[^"\\]|\\.)*")\r?', line,
1400
+ ) for line in headers):
1401
+ return ([], "external diff requires Git a/ and b/ path prefixes; regenerate with git diff --binary --src-prefix=a/ --dst-prefix=b/")
1402
+ # Statistics mode never applies the patch. Reverse parsing includes
1403
+ # rename sources as well as destinations without parsing quoted headers.
1404
+ for direction in ([], ["--reverse"]):
1405
+ proc = subprocess.run(
1406
+ ["git", "apply", "--numstat", "-z", "-p1", "--whitespace=nowarn", *direction], cwd=str(work),
1407
+ input=external_bytes, capture_output=True,
1408
+ )
1409
+ if proc.returncode != 0:
1410
+ return ([], (proc.stderr or proc.stdout).decode("utf-8", "replace").strip() or "git apply --numstat failed")
1411
+ for record in proc.stdout.split(b"\0"):
1412
+ if record:
1413
+ fields = record.split(b"\t", 2)
1414
+ if len(fields) != 3 or not fields[2]:
1415
+ return ([], "invalid git apply --numstat path record")
1416
+ names.append(fields[2].decode("utf-8", "surrogateescape"))
1417
+ return (list(dict.fromkeys(names)), None)
1391
1418
  base_sha = ((state.get("base_ref") or {}).get("sha") or "").strip()
1392
- cmd = ["git", "diff", "--name-only"]
1419
+ cmd = ["git", "diff", "--name-only", "-z", "--no-renames"]
1393
1420
  if base_sha:
1394
1421
  cmd.append(base_sha)
1395
- proc = subprocess.run(cmd, cwd=str(work), capture_output=True, text=True, encoding="utf-8")
1422
+ proc = subprocess.run(cmd, cwd=str(work), capture_output=True)
1396
1423
  if proc.returncode != 0:
1397
- return []
1398
- return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]
1424
+ return ([], (proc.stderr or proc.stdout).decode("utf-8", "replace").strip() or "git diff --name-only failed")
1425
+ return ([path.decode("utf-8", "surrogateescape") for path in proc.stdout.split(b"\0") if path], None)
1399
1426
 
1400
1427
 
1401
1428
  def expected_contract_findings(
@@ -1411,6 +1438,8 @@ def expected_contract_findings(
1411
1438
  findings: list[dict] = []
1412
1439
  seq = finding_start
1413
1440
  diff_text, diff_error = diff_text_for_expected(work, devlyn_dir, state)
1441
+ paths, paths_error = changed_files(work, state, devlyn_dir) if expected_data.get("forbidden_files") else ([], None)
1442
+ diff_error = diff_error or paths_error
1414
1443
  if diff_error and (
1415
1444
  expected_data.get("forbidden_patterns") or expected_data.get("forbidden_files")
1416
1445
  ):
@@ -1420,12 +1449,12 @@ def expected_contract_findings(
1420
1449
  "level": "error",
1421
1450
  "severity": "CRITICAL",
1422
1451
  "confidence": 1.0,
1423
- "message": f"Cannot compute diff for forbidden_patterns: {diff_error}",
1452
+ "message": f"Cannot compute diff for expected contract: {diff_error}",
1424
1453
  "file": str(expected_path or "spec.expected.json"),
1425
1454
  "line": 1,
1426
1455
  "phase": output_phase(),
1427
- "criterion_ref": "spec.expected.json/forbidden_patterns",
1428
- "fix_hint": "Ensure pipeline.state.json has base_ref.sha or provide .devlyn/external-diff.patch.",
1456
+ "criterion_ref": "spec.expected.json/forbidden_files" if paths_error else "spec.expected.json/forbidden_patterns",
1457
+ "fix_hint": "Ensure base_ref.sha is valid and any external-diff.patch is a readable Git patch with a/ and b/ prefixes.",
1429
1458
  "blocking": True,
1430
1459
  "status": "open",
1431
1460
  })
@@ -1451,7 +1480,7 @@ def expected_contract_findings(
1451
1480
  "status": "open",
1452
1481
  })
1453
1482
  seq += 1
1454
- changed = set(changed_files(work, state, devlyn_dir))
1483
+ changed = set(paths)
1455
1484
  for i, required in enumerate(expected_data.get("required_files", []) or []):
1456
1485
  if (work / required).exists():
1457
1486
  continue
@@ -1725,10 +1754,9 @@ def authorized_surface_findings(
1725
1754
  to catch. A persistent finding exhausts the existing BUILD_GATE
1726
1755
  fix-loop budget and halts for user/orchestrator review instead.
1727
1756
 
1728
- Not re-run at VERIFY MECHANICAL time (post-CLEANUP): CLEANUP's own
1729
- allowlist (tooling artifacts, dead code, doc-reference fixes) licenses
1730
- paths PLAN never declared, so re-checking there would false-positive on
1731
- CLEANUP's own sanctioned changes.
1757
+ Not re-run at VERIFY MECHANICAL time: historical worker CLEANUP can
1758
+ license paths outside PLAN. Owner CLEANUP instead enforces an unchanged
1759
+ source checkpoint and the final untracked baseline before VERIFY.
1732
1760
  """
1733
1761
  surface, surface_error = load_authorized_surface(devlyn_dir)
1734
1762
  if surface_error is not None:
@@ -1770,7 +1798,16 @@ def authorized_surface_findings(
1770
1798
 
1771
1799
  findings: list[dict] = []
1772
1800
  seq = finding_start
1773
- for path in changed_files(work, state, devlyn_dir):
1801
+ paths, paths_error = changed_files(work, state, devlyn_dir)
1802
+ if paths_error is not None:
1803
+ return ([scope_finding(
1804
+ finding_start,
1805
+ "scope.authorized-surface-malformed",
1806
+ f"Cannot read changed files: {paths_error}",
1807
+ ".devlyn/external-diff.patch" if (devlyn_dir / "external-diff.patch").is_file() else ".devlyn/pipeline.state.json",
1808
+ "Ensure base_ref.sha is valid and any external-diff.patch is a readable Git patch with a/ and b/ prefixes.",
1809
+ )], finding_start + 1)
1810
+ for path in paths:
1774
1811
  if path_matches_surface(path, surface):
1775
1812
  continue
1776
1813
  findings.append(scope_finding(
@@ -1859,13 +1896,8 @@ def run_write_untracked_baseline(work: Path, devlyn_dir: Path) -> int:
1859
1896
 
1860
1897
 
1861
1898
  def run_check_mode(md_path: Path) -> int:
1862
- """`--check <markdown>` — validate the verification carrier without
1863
- running any commands. Used by /devlyn:ideate after item-spec write.
1864
-
1865
- Exit 0: `<!-- devlyn:verification -->` sentinel absent OR section present
1866
- and well-formed.
1867
- Exit 2: sentinel present but the section is malformed — no fenced json
1868
- block, invalid JSON, or bad shape (so ideate can re-prompt).
1899
+ """Validate a spec's authoritative sibling or legacy inline carrier;
1900
+ return 0/2 without staging or execution. This is not generated-mode staging.
1869
1901
  """
1870
1902
  if not md_path.is_file():
1871
1903
  print(f"[spec-verify --check] error: {md_path} not found", file=sys.stderr)
@@ -1883,6 +1915,14 @@ def run_check_mode(md_path: Path) -> int:
1883
1915
  if solo_ceiling_err:
1884
1916
  print(f"[spec-verify --check] {md_path}: {solo_ceiling_err}", file=sys.stderr)
1885
1917
  return 2
1918
+ expected_path = md_path.with_name("spec.expected.json")
1919
+ if expected_path.is_file():
1920
+ data, err = load_expected_contract(expected_path)
1921
+ err = err or validate_expected_against_sibling_spec(md_path, data)
1922
+ if err:
1923
+ print(f"[spec-verify --check] {md_path}: sibling {expected_path}: {err}", file=sys.stderr)
1924
+ return 2
1925
+ return 0
1886
1926
  section_found, block = extract_verification_block(text)
1887
1927
  if not section_found:
1888
1928
  # Sentinel absent entirely — opt-in nature preserved for ideate (a
@@ -2060,7 +2100,7 @@ def run_self_test() -> int:
2060
2100
  + "\n```\n",
2061
2101
  encoding="utf-8",
2062
2102
  )
2063
- staged, inline_error = stage_from_source(
2103
+ _found, staged, inline_error = stage_from_source(
2064
2104
  inline_timeout_spec, inline_timeout_devlyn
2065
2105
  )
2066
2106
  if staged or not inline_error or "timeout_sec" not in inline_error:
@@ -2080,7 +2120,7 @@ def run_self_test() -> int:
2080
2120
  + "\n```\n",
2081
2121
  encoding="utf-8",
2082
2122
  )
2083
- staged, inline_error = stage_from_source(
2123
+ _found, staged, inline_error = stage_from_source(
2084
2124
  inline_timeout_spec, inline_timeout_devlyn
2085
2125
  )
2086
2126
  inline_staged = loads_strict_json(
@@ -3641,6 +3681,56 @@ def run_self_test() -> int:
3641
3681
  "```\n",
3642
3682
  encoding="utf-8",
3643
3683
  )
3684
+ generated_raw = generated_criteria.read_bytes()
3685
+ generated_criteria.unlink()
3686
+ missing_source_marker = generated_user / "missing-source-command-ran"
3687
+ for pointer, bench, phase in (
3688
+ ({}, False, "build_gate"),
3689
+ ({"criteria_path": None}, False, "build_gate"),
3690
+ ({"criteria_path": ""}, False, "build_gate"),
3691
+ ({"criteria_path": ".devlyn/criteria.generated.md"}, False, "build_gate"),
3692
+ ({"criteria_path": str(generated_criteria)}, False, "build_gate"),
3693
+ ({"criteria_path": str(generated_user)}, False, "build_gate"),
3694
+ ({"criteria_path": str(generated_criteria)}, True, "build_gate"),
3695
+ ({"criteria_path": str(generated_criteria)}, False, "verify_mechanical"),
3696
+ ):
3697
+ (generated_devlyn / "pipeline.state.json").write_text(json.dumps({
3698
+ "source": {"type": "generated", "criteria_sha256": hashlib.sha256(generated_raw).hexdigest(), **pointer},
3699
+ "risk_profile": {"risk_probes_enabled": True},
3700
+ }), encoding="utf-8")
3701
+ (generated_devlyn / "spec-verify.json").write_text(json.dumps({
3702
+ "verification_commands": [{"cmd": "printf unexpected > missing-source-command-ran"}],
3703
+ }), encoding="utf-8")
3704
+ (generated_devlyn / "spec-verify.results.json").unlink(missing_ok=True)
3705
+ missing_source_env = dict(os.environ, SPEC_VERIFY_PHASE=phase)
3706
+ if bench:
3707
+ missing_source_env["BENCH_WORKDIR"] = str(generated_user)
3708
+ missing_source_run = subprocess.run(
3709
+ [sys.executable, script_path, "--include-risk-probes"],
3710
+ cwd=generated_user, env=missing_source_env,
3711
+ capture_output=True, text=True, encoding="utf-8",
3712
+ )
3713
+ if (
3714
+ missing_source_run.returncode != 1
3715
+ or "source.criteria_path" not in missing_source_run.stderr
3716
+ or f"declared path: {pointer.get('criteria_path')!r}" not in missing_source_run.stderr
3717
+ ):
3718
+ print(f"missing generated source was not rejected: {pointer}, {bench}, {phase}: {missing_source_run.stderr}", file=sys.stderr)
3719
+ return 1
3720
+ missing_findings = [loads_strict_json(line) for line in
3721
+ (generated_devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()]
3722
+ if (
3723
+ len(missing_findings) != 1
3724
+ or missing_findings[0]["rule_id"] != "correctness.spec-verify-malformed"
3725
+ or missing_findings[0]["severity"] != "CRITICAL"
3726
+ or missing_findings[0]["file"] != ".devlyn/pipeline.state.json"
3727
+ or missing_findings[0]["phase"] != phase
3728
+ or missing_source_marker.exists()
3729
+ or (generated_devlyn / "spec-verify.results.json").exists() != (phase == "build_gate")
3730
+ ):
3731
+ print(f"missing generated source lost its finding or executed a stale command: {missing_findings}", file=sys.stderr)
3732
+ return 1
3733
+ generated_criteria.write_bytes(generated_raw)
3644
3734
  (generated_devlyn / "pipeline.state.json").write_text(json.dumps({
3645
3735
  "source": {"type": "generated", "criteria_path": str(generated_criteria)}
3646
3736
  }), encoding="utf-8")
@@ -3796,6 +3886,13 @@ def run_self_test() -> int:
3796
3886
  (real_devlyn / "pipeline.state.json").write_text(json.dumps({
3797
3887
  "source": {"type": "spec", "spec_path": str(real_spec)}
3798
3888
  }), encoding="utf-8")
3889
+ sibling_check = subprocess.run(
3890
+ [sys.executable, script_path, "--check", str(real_spec)],
3891
+ cwd=real_user, capture_output=True, text=True, encoding="utf-8",
3892
+ )
3893
+ if sibling_check.returncode != 0 or (real_devlyn / "spec-verify.json").exists():
3894
+ print(f"sibling authoring failed or staged commands: {sibling_check.stderr}", file=sys.stderr)
3895
+ return 1
3799
3896
  sibling_run = subprocess.run(
3800
3897
  [sys.executable, script_path],
3801
3898
  cwd=real_user,
@@ -3814,51 +3911,137 @@ def run_self_test() -> int:
3814
3911
  pure_prose = "# Design\n\n<!-- devlyn:verification -->\n## Verification\n\n- No runtime verification commands.\n"
3815
3912
  stale_command = {"verification_commands": [{"cmd": "printf unexpected-command"}]}
3816
3913
  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"),
3914
+ pure_contract = {"pure_design": True, "verification_commands": []}
3915
+ for name, filename, source, contract, expected_rule, source_type in (
3916
+ ("pure-prose", "spec.md", pure_prose, {"pure_design": True}, None, "spec"),
3917
+ ("pure-inline", "design-notes.md", pure_inline, {"pure_design": True, "verification_commands": []}, None, "spec"),
3918
+ ("pure-bad-inline", "design-notes.md", pure_prose + "\n```json\n{broken\n```\n", {"pure_design": True}, None, "spec"),
3919
+ ("empty-runtime", "design-notes.md", pure_inline, {"verification_commands": []}, "correctness.spec-verify-malformed", "spec"),
3920
+ ("contradictory-pure", "spec.md", pure_inline, {"pure_design": True, **stale_command}, "correctness.spec-verify-malformed", "spec"),
3921
+ ("pure-required-file", "spec.md", pure_inline, {"pure_design": True, "required_files": ["missing.md"]}, "correctness.required-file-missing", "spec"),
3922
+ ("generated-pure", ".devlyn/criteria.generated.md", pure_contract, None, None, "generated"),
3923
+ ("legacy-inline-pure", "design-notes.md", pure_contract, None, None, "spec"),
3924
+ ("generated-empty-section", ".devlyn/criteria.generated.md", pure_prose, None, "correctness.spec-verify-malformed", "generated"),
3925
+ ("generated-unmarked-empty", ".devlyn/criteria.generated.md", {"verification_commands": []}, None, "correctness.spec-verify-malformed", "generated"),
3926
+ ("generated-false-empty", ".devlyn/criteria.generated.md", {**pure_contract, "pure_design": False}, None, "correctness.spec-verify-malformed", "generated"),
3927
+ ("generated-missing-list", ".devlyn/criteria.generated.md", {"pure_design": True}, None, "correctness.spec-verify-malformed", "generated"),
3928
+ ("generated-nonboolean-pure", ".devlyn/criteria.generated.md", {**pure_contract, "pure_design": 1}, None, "correctness.spec-verify-malformed", "generated"),
3929
+ ("generated-contradictory-pure", ".devlyn/criteria.generated.md", {**stale_command, "pure_design": True}, None, "correctness.spec-verify-malformed", "generated"),
3930
+ ("generated-unsupported-field", ".devlyn/criteria.generated.md", {**pure_contract, "required_files": ["missing.md"]}, None, "correctness.spec-verify-malformed", "generated"),
3823
3931
  ):
3824
3932
  case_root = work / name
3825
3933
  case_root.mkdir()
3826
3934
  case_devlyn = case_root / ".devlyn"
3827
3935
  case_devlyn.mkdir()
3828
3936
  case_spec = case_root / filename
3937
+ if isinstance(source, dict):
3938
+ source = pure_prose + "\n```json\n" + json.dumps(source) + "\n```\n"
3829
3939
  case_spec.write_text(source, encoding="utf-8")
3830
- (case_root / "spec.expected.json").write_text(json.dumps(contract), encoding="utf-8")
3940
+ if contract is not None:
3941
+ (case_root / "spec.expected.json").write_text(json.dumps(contract), encoding="utf-8")
3831
3942
  (case_devlyn / "spec-verify.json").write_text(json.dumps(stale_command), encoding="utf-8")
3943
+ pointer = "criteria" if source_type == "generated" else "spec"
3832
3944
  (case_devlyn / "pipeline.state.json").write_text(json.dumps({
3833
- "source": {"type": "spec", "spec_path": str(case_spec)},
3945
+ "source": {"type": source_type, pointer + "_path": str(case_spec),
3946
+ pointer + "_sha256": hashlib.sha256(case_spec.read_bytes()).hexdigest()},
3834
3947
  }), encoding="utf-8")
3948
+ case_check = subprocess.run(
3949
+ [sys.executable, script_path, "--check", str(case_spec)],
3950
+ cwd=case_root, capture_output=True, text=True, encoding="utf-8",
3951
+ )
3952
+ if case_check.returncode != (2 if expected_rule == "correctness.spec-verify-malformed" else 0):
3953
+ print(f"{name}: authoring returned {case_check.returncode}: {case_check.stderr}", file=sys.stderr)
3954
+ return 1
3835
3955
  case_run = subprocess.run(
3836
3956
  [sys.executable, script_path], cwd=case_root,
3837
3957
  capture_output=True, text=True, encoding="utf-8",
3838
3958
  )
3839
3959
  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)
3960
+ print(f"{name}: contract execution returned {case_run.returncode}: {case_run.stderr}", file=sys.stderr)
3841
3961
  return 1
3842
3962
  case_findings = [
3843
3963
  loads_strict_json(line) for line in
3844
3964
  (case_devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()
3845
3965
  ]
3846
3966
  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)
3967
+ print(f"{name}: unexpected contract findings: {case_findings}", file=sys.stderr)
3848
3968
  return 1
3849
3969
  case_results = case_devlyn / "spec-verify.results.json"
3850
3970
  if expected_rule == "correctness.spec-verify-malformed":
3851
- if case_results.exists():
3852
- print(f"{name}: malformed sibling reached command execution", file=sys.stderr)
3971
+ rejected_results = loads_strict_json(case_results.read_text(encoding="utf-8"))
3972
+ if (rejected_results["commands"] != [] or rejected_results["process_evidence"] is not None
3973
+ or "preflight_failure" not in rejected_results):
3974
+ print(f"{name}: malformed contract reached command execution", file=sys.stderr)
3853
3975
  return 1
3854
3976
  elif (
3855
3977
  loads_strict_json(case_results.read_text(encoding="utf-8"))
3856
3978
  != {"commands": [], "process_evidence": None}
3857
3979
  or (case_devlyn / "spec-verify.json").exists()
3858
3980
  ):
3859
- print(f"{name}: pure-design sibling ran or retained a stale command", file=sys.stderr)
3981
+ print(f"{name}: pure-design contract ran or retained a stale command", file=sys.stderr)
3982
+ return 1
3983
+
3984
+ generated_root = work / "generated-pure"
3985
+ generated_devlyn = generated_root / ".devlyn"
3986
+ for prestaged in (False, True):
3987
+ if prestaged:
3988
+ (generated_devlyn / "spec-verify.json").write_text(json.dumps(pure_contract), encoding="utf-8")
3989
+ bench_pure = subprocess.run(
3990
+ [sys.executable, script_path], cwd=generated_root,
3991
+ env=dict(os.environ, BENCH_WORKDIR=str(generated_root)),
3992
+ capture_output=True, text=True, encoding="utf-8",
3993
+ )
3994
+ if bench_pure.returncode != (1 if prestaged else 0):
3995
+ print(f"pure-design benchmark precedence changed: {bench_pure.stderr}", file=sys.stderr)
3996
+ return 1
3997
+ if prestaged and "verification_commands must contain at least one entry" not in bench_pure.stderr:
3998
+ print(f"prestaged empty benchmark did not fail command validation: {bench_pure.stderr}", file=sys.stderr)
3860
3999
  return 1
3861
4000
 
4001
+ generated_state_path = generated_devlyn / "pipeline.state.json"
4002
+ generated_state = loads_strict_json(generated_state_path.read_text(encoding="utf-8"))
4003
+ generated_state["risk_profile"] = {"risk_probes_enabled": True, "risk_probes_explicit": True}
4004
+ generated_state_path.write_text(json.dumps(generated_state), encoding="utf-8")
4005
+ missing_pure_probe = subprocess.run(
4006
+ [sys.executable, script_path, "--include-risk-probes"], cwd=generated_root,
4007
+ capture_output=True, text=True, encoding="utf-8",
4008
+ )
4009
+ if missing_pure_probe.returncode != 1 or "risk probes integrity failed" not in missing_pure_probe.stderr:
4010
+ print(f"pure-design contract bypassed required probes: {missing_pure_probe.stderr}", file=sys.stderr)
4011
+ return 1
4012
+
4013
+ generated_source = generated_devlyn / "criteria.generated.md"
4014
+ generated_source.write_text(
4015
+ generated_source.read_text(encoding="utf-8") + "\n- probe must pass visible marker.\n",
4016
+ encoding="utf-8",
4017
+ )
4018
+ generated_state["source"]["criteria_sha256"] = hashlib.sha256(generated_source.read_bytes()).hexdigest()
4019
+ (generated_devlyn / "probes").mkdir()
4020
+ (generated_devlyn / "probes/P1.py").write_bytes(probe_script.read_bytes())
4021
+ (generated_devlyn / "risk-probes.jsonl").write_text(json.dumps(risk_probe_payload) + "\n", encoding="utf-8")
4022
+ generated_state["risk_probes_digest"], pure_probe_error = risk_probes_digest(generated_devlyn)
4023
+ if pure_probe_error:
4024
+ print(pure_probe_error, file=sys.stderr)
4025
+ return 1
4026
+ generated_state_path.write_text(json.dumps(generated_state), encoding="utf-8")
4027
+ valid_pure_probe = subprocess.run(
4028
+ [sys.executable, script_path, "--include-risk-probes"], cwd=generated_root,
4029
+ capture_output=True, text=True, encoding="utf-8",
4030
+ )
4031
+ if valid_pure_probe.returncode != 0:
4032
+ print(f"pure-design contract did not execute valid probe: {valid_pure_probe.stderr}", file=sys.stderr)
4033
+ return 1
4034
+ pure_probe_results = loads_strict_json((generated_devlyn / "spec-verify.results.json").read_text(encoding="utf-8"))
4035
+ if (
4036
+ len(pure_probe_results["commands"]) != 1
4037
+ or pure_probe_results["commands"][0]["evidence_id"] != "risk-probe-0001"
4038
+ or not pure_probe_results["commands"][0]["pass"]
4039
+ or pure_probe_results["process_evidence"] is None
4040
+ or (generated_devlyn / "spec-verify.json").exists()
4041
+ ):
4042
+ print("pure-design probe did not retain exactly one verified process result", file=sys.stderr)
4043
+ return 1
4044
+
3862
4045
  malformed = work / "malformed-sibling"
3863
4046
  malformed.mkdir()
3864
4047
  malformed_devlyn = malformed / ".devlyn"
@@ -3897,7 +4080,7 @@ def run_self_test() -> int:
3897
4080
  }), encoding="utf-8")
3898
4081
  (devlyn / "spec-verify.json").write_text(json.dumps({
3899
4082
  "verification_commands": [
3900
- {"cmd": "printf bench-staged", "stdout_contains": ["bench-staged"]}
4083
+ {"cmd": "printf bench-staged", "stdout_contains": ["bench-staged"], "contract_refs": ["R1"]}
3901
4084
  ]
3902
4085
  }) + "\n", encoding="utf-8")
3903
4086
  bench_pre_staged = subprocess.run(
@@ -3916,6 +4099,38 @@ def run_self_test() -> int:
3916
4099
  print("benchmark pre-staged contract was overwritten", file=sys.stderr)
3917
4100
  return 1
3918
4101
 
4102
+ bench_marker = work / "bench-command-ran"
4103
+ bench_command = {"cmd": "printf bad > bench-command-ran; printf bad"}
4104
+ for phase in ("build_gate", "verify_mechanical"):
4105
+ for carrier, diagnostic in (
4106
+ ({"verification_commands": [{**bench_command, "stdout_not_contians": ["bad"]}]}, "unknown key(s): stdout_not_contians"),
4107
+ ({"verification_commands": [{**bench_command, "contract_refs": [""]}]}, "contract_refs must be a list of non-empty strings"),
4108
+ ({"verification_commands": [bench_command], "required_files": ["missing.txt"]}, "unsupported inline key(s): required_files"),
4109
+ ({"verification_commands": [bench_command], "pure_design": True}, "requires an explicit empty verification_commands list"),
4110
+ ({"verification_commands": [], "pure_design": True}, "must contain at least one entry"),
4111
+ ):
4112
+ (devlyn / "spec-verify.json").write_text(json.dumps(carrier), encoding="utf-8")
4113
+ old_bench_results = (devlyn / "spec-verify.results.json").read_bytes()
4114
+ rejected_bench = subprocess.run(
4115
+ [sys.executable, script_path], cwd=work,
4116
+ env={**env, "SPEC_VERIFY_PHASE": phase},
4117
+ capture_output=True, text=True, encoding="utf-8",
4118
+ )
4119
+ rejected_findings = [loads_strict_json(line) for line in
4120
+ (devlyn / output_findings_name()).read_text(encoding="utf-8").splitlines()]
4121
+ if (
4122
+ rejected_bench.returncode != 1 or diagnostic not in rejected_bench.stderr
4123
+ or bench_marker.exists()
4124
+ or (phase == "verify_mechanical" and
4125
+ (devlyn / "spec-verify.results.json").read_bytes() != old_bench_results)
4126
+ or len(rejected_findings) != 1
4127
+ or rejected_findings[0]["rule_id"] != "correctness.spec-verify-malformed"
4128
+ or rejected_findings[0]["severity"] != "CRITICAL"
4129
+ or rejected_findings[0]["phase"] != phase
4130
+ ):
4131
+ print(f"benchmark {phase} failed closed incorrectly for {carrier}: {rejected_bench.stderr}", file=sys.stderr)
4132
+ return 1
4133
+
3919
4134
  verify_output = work / "verify-output"
3920
4135
  verify_output.mkdir()
3921
4136
  verify_devlyn = verify_output / ".devlyn"
@@ -4551,6 +4766,84 @@ def run_self_test() -> int:
4551
4766
  print("atomic_batch_state without success-order evidence was accepted", file=sys.stderr)
4552
4767
  return 1
4553
4768
 
4769
+ # Literal diff paths must survive Git quoting, renames and both consumers.
4770
+ literal_root = work / "literal-paths"
4771
+ literal_root.mkdir()
4772
+ literal_devlyn = literal_root / ".devlyn"
4773
+ literal_devlyn.mkdir()
4774
+ (literal_root / ".gitignore").write_text(".devlyn/\n", encoding="utf-8")
4775
+ literal_names = ["plain.txt", "설정.txt", "my file.txt"]
4776
+ if os.name != "nt":
4777
+ literal_names += ["a\tb.txt", "a\nb.txt", " leading.txt", "trailing.txt ", 'quote".txt', "back\\slash.txt", "literal*.txt"]
4778
+ for name in literal_names + ["old name.txt"]:
4779
+ (literal_root / name).write_text("original\n", encoding="utf-8")
4780
+ subprocess.run(["git", "init", "-q"], cwd=literal_root, check=True)
4781
+ subprocess.run(["git", "add", "-A"], cwd=literal_root, check=True)
4782
+ subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "base"], cwd=literal_root, check=True)
4783
+ for name in literal_names:
4784
+ (literal_root / name).write_text("updated\n", encoding="utf-8")
4785
+ subprocess.run(["git", "mv", "old name.txt", "new name.txt"], cwd=literal_root, check=True)
4786
+ literal_names += ["old name.txt", "new name.txt"]
4787
+ literal_state = {"base_ref": {"sha": "HEAD"}}
4788
+ literal_expected = {"forbidden_files": literal_names}
4789
+ (literal_devlyn / "untracked.baseline").write_text("", encoding="utf-8")
4790
+ (literal_devlyn / "plan.md").write_text(
4791
+ "<!-- devlyn:authorized-surface -->\n## Files to touch\n\n```json\n"
4792
+ + json.dumps({"authorized_surface": literal_names}) + "\n```\n", encoding="utf-8",
4793
+ )
4794
+ literal_patch = subprocess.check_output(
4795
+ ["git", "diff", "--binary", "--src-prefix=a/", "--dst-prefix=b/", "HEAD"], cwd=literal_root,
4796
+ )
4797
+ literal_external = literal_devlyn / "external-diff.patch"
4798
+ for external in (False, True):
4799
+ if external:
4800
+ literal_external.write_bytes(literal_patch)
4801
+ hits, _ = expected_contract_findings(literal_expected, None, literal_root, literal_devlyn, literal_state, 1)
4802
+ assert len(hits) == len(literal_names) and all(hit["rule_id"] == "scope.forbidden-file-touched" for hit in hits), hits
4803
+ scope_hits, _ = authorized_surface_findings(literal_root, literal_devlyn, literal_state, 1)
4804
+ assert not scope_hits, scope_hits
4805
+ edit = b"diff --git a/plain.txt b/plain.txt\n--- a/plain.txt\n+++ b/plain.txt\n@@ -1 +1 @@\n-original\n+updated\n"
4806
+ (literal_devlyn / "plan.md").write_text(
4807
+ '<!-- devlyn:authorized-surface -->\n## Files to touch\n\n```json\n{"authorized_surface":["untouched.txt"]}\n```\n', encoding="utf-8",
4808
+ )
4809
+ literal_external.write_bytes(edit)
4810
+ scope_hits, _ = authorized_surface_findings(literal_root, literal_devlyn, literal_state, 1)
4811
+ assert len(scope_hits) == 1 and scope_hits[0]["rule_id"] == "scope.out-of-scope-file", scope_hits
4812
+ literal_external.write_bytes(edit.replace(b"+updated", b"+updated "))
4813
+ subprocess.run(["git", "config", "apply.whitespace", "error"], cwd=literal_root, check=True)
4814
+ try:
4815
+ names, error = changed_files(literal_root, literal_state, literal_devlyn)
4816
+ assert names == ["plain.txt"] and error is None, (names, error)
4817
+ finally:
4818
+ subprocess.run(["git", "config", "--unset", "apply.whitespace"], cwd=literal_root, check=True)
4819
+ for patch, paths in (
4820
+ (b"", []),
4821
+ (edit.replace(b"\n", b"\r\n"), ["plain.txt"]),
4822
+ (b"diff --git a/logo.png b/logo.png\nindex 1111111..2222222 100644\nBinary files a/logo.png and b/logo.png differ\n", ["logo.png"]),
4823
+ (b"diff --git a/script b/script\nold mode 100644\nnew mode 100755\n", ["script"]),
4824
+ (b"diff --git a/keep.py b/new.py\nsimilarity index 100%\ncopy from keep.py\ncopy to new.py\n", ["new.py", "keep.py"]),
4825
+ ):
4826
+ literal_external.write_bytes(patch)
4827
+ names, error = changed_files(literal_root, literal_state, literal_devlyn)
4828
+ assert names == paths and error is None, (names, error)
4829
+ for bad_patch in (
4830
+ b"not a patch\n", b"\n", edit[:-5],
4831
+ edit.replace(b"a/plain.txt", b"plain.txt").replace(b"b/plain.txt", b"plain.txt"),
4832
+ edit.replace(b"a/plain.txt", b"src/plain.txt").replace(b"b/plain.txt", b"src/plain.txt"),
4833
+ edit.replace(b"a/plain.txt", b"i/plain.txt").replace(b"b/plain.txt", b"w/plain.txt"),
4834
+ ):
4835
+ literal_external.write_bytes(bad_patch)
4836
+ hits, _ = expected_contract_findings(literal_expected, None, literal_root, literal_devlyn, literal_state, 1)
4837
+ assert len(hits) == 1 and hits[0]["rule_id"] == "correctness.expected-contract-unverifiable", hits
4838
+ scope_hits, _ = authorized_surface_findings(literal_root, literal_devlyn, literal_state, 1)
4839
+ assert len(scope_hits) == 1 and scope_hits[0]["file"] == ".devlyn/external-diff.patch", scope_hits
4840
+ literal_external.unlink()
4841
+ names, error = changed_files(literal_root, {"base_ref": {"sha": "missing-literal-ref"}}, literal_devlyn)
4842
+ assert not names and error, (names, error)
4843
+ assert subprocess.check_output(
4844
+ ["git", "diff", "--binary", "--src-prefix=a/", "--dst-prefix=b/", "HEAD"], cwd=literal_root,
4845
+ ) == literal_patch
4846
+
4554
4847
  # iter-0046: PLAN-declared authorized_surface enforced at BUILD_GATE.
4555
4848
  scope_root = work / "scope-gate"
4556
4849
  scope_root.mkdir()
@@ -4965,7 +5258,8 @@ def main() -> int:
4965
5258
  # continue to legacy source-extract.
4966
5259
  # 3. Source-extract reads
4967
5260
  # `pipeline.state.json:source.{spec_path | criteria_path}`. If it has
4968
- # a json block, overwrite .devlyn/spec-verify.json with it.
5261
+ # a json block, stage its commands or clear stale staging for an
5262
+ # explicit pure-design contract.
4969
5263
  # 4. If source has no json block AND source.type=="generated":
4970
5264
  # CRITICAL spec-verify-malformed — generated criteria must ship a
4971
5265
  # verifiable contract per the generated-criteria output contract.
@@ -4980,6 +5274,39 @@ def main() -> int:
4980
5274
  trust_bench_staged = bench_mode and pre_staged
4981
5275
  src_type, source_md = read_source(work, devlyn_dir)
4982
5276
  state = read_state(devlyn_dir)
5277
+
5278
+ def reject_preflight() -> int:
5279
+ # A completed rejection is not an interrupted checker. Preserve actual
5280
+ # earlier gate observations without inventing an executed command.
5281
+ if validate_risk_probes_only or output_phase() != "build_gate":
5282
+ return 1
5283
+ runner = process_evidence_module()
5284
+ phase, run_id, round_, relative = mechanical_evidence_identity(state, runner)
5285
+ carrier = None
5286
+ try:
5287
+ if os.path.lexists(work / relative):
5288
+ carrier = runner.validate_manifest(
5289
+ work, relative, run_id, phase, round_, require_expectations=False,
5290
+ )
5291
+ findings = devlyn_dir / output_findings_name()
5292
+ result = {
5293
+ "commands": runner.bound_carrier_summary_commands(work, carrier) if carrier else [],
5294
+ "process_evidence": carrier,
5295
+ "preflight_failure": {
5296
+ "run_id": run_id, "phase": phase, "round": round_,
5297
+ "findings": {
5298
+ "path": findings.relative_to(work).as_posix(),
5299
+ "sha256": hashlib.sha256(findings.read_bytes()).hexdigest(),
5300
+ },
5301
+ },
5302
+ }
5303
+ (devlyn_dir / "spec-verify.results.json").write_text(
5304
+ json.dumps(result, indent=2) + "\n", encoding="utf-8",
5305
+ )
5306
+ except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
5307
+ print(f"[spec-verify] rejection evidence could not be finalized: {exc}", file=sys.stderr)
5308
+ return 1
5309
+
4983
5310
  external_diff = devlyn_dir / "external-diff.patch"
4984
5311
  if external_diff.is_file() and state.get("mode") != "verify-only":
4985
5312
  error = (
@@ -4997,14 +5324,15 @@ def main() -> int:
4997
5324
  "intentionally verifying an external patch."
4998
5325
  ),
4999
5326
  )
5000
- return 1
5327
+ return reject_preflight()
5001
5328
  integrity_error = source_integrity_error(src_type, state, source_md)
5002
5329
  if integrity_error:
5003
5330
  print(f"[spec-verify] carrier malformed: {integrity_error}", file=sys.stderr)
5004
5331
  write_malformed_finding(devlyn_dir, integrity_error, source_md)
5005
- return 1
5332
+ return reject_preflight()
5006
5333
  expected_data: dict | None = None
5007
5334
  expected_path: Path | None = None
5335
+ contract_found = False
5008
5336
  if validate_risk_probes_only:
5009
5337
  _risk_probes, risk_error = load_risk_probes(
5010
5338
  devlyn_dir, source_md, require_present=True
@@ -5012,29 +5340,29 @@ def main() -> int:
5012
5340
  if risk_error:
5013
5341
  print(f"[spec-verify] risk probes malformed: {risk_error}", file=sys.stderr)
5014
5342
  write_malformed_finding(devlyn_dir, risk_error, devlyn_dir / "risk-probes.jsonl")
5015
- return 1
5343
+ return reject_preflight()
5016
5344
  print("[spec-verify] risk probes valid", file=sys.stderr)
5017
5345
  return 0
5018
5346
  if source_md is not None and not trust_bench_staged:
5019
5347
  if src_type == "spec":
5020
- expected_found, expected_staged, expected_error, expected_path, expected_data = stage_from_expected(
5348
+ contract_found, _staged, expected_error, expected_path, expected_data = stage_from_expected(
5021
5349
  source_md, devlyn_dir
5022
5350
  )
5023
5351
  if expected_error is not None:
5024
5352
  print(f"[spec-verify] carrier malformed: {expected_error}", file=sys.stderr)
5025
5353
  write_malformed_finding(devlyn_dir, expected_error, expected_path)
5026
- return 1
5027
- if expected_found:
5028
- staged, error = (expected_staged, None)
5354
+ return reject_preflight()
5355
+ if contract_found:
5356
+ error = None
5029
5357
  else:
5030
- staged, error = stage_from_source(source_md, devlyn_dir)
5358
+ contract_found, _staged, error = stage_from_source(source_md, devlyn_dir)
5031
5359
  else:
5032
- staged, error = stage_from_source(source_md, devlyn_dir)
5360
+ contract_found, _staged, error = stage_from_source(source_md, devlyn_dir)
5033
5361
  if error is not None:
5034
5362
  print(f"[spec-verify] carrier malformed: {error}", file=sys.stderr)
5035
5363
  write_malformed_finding(devlyn_dir, error, source_md)
5036
- return 1
5037
- if not staged:
5364
+ return reject_preflight()
5365
+ if not contract_found:
5038
5366
  if src_type == "generated":
5039
5367
  msg = (
5040
5368
  f"generated {source_md.name} must include a "
@@ -5043,9 +5371,9 @@ def main() -> int:
5043
5371
  )
5044
5372
  print(f"[spec-verify] {msg}", file=sys.stderr)
5045
5373
  write_malformed_finding(devlyn_dir, msg, source_md)
5046
- return 1
5374
+ return reject_preflight()
5047
5375
  # source.type=="spec", no block in spec markdown.
5048
- if not bench_mode and expected_data is None:
5376
+ if not bench_mode:
5049
5377
  # Real-user handwritten spec: silent no-op. Drop any stale
5050
5378
  # pre-staged file so a killed prior run cannot poison this
5051
5379
  # run's gate.
@@ -5067,11 +5395,10 @@ def main() -> int:
5067
5395
 
5068
5396
  commands: list[dict] = []
5069
5397
  if not spec_path.exists():
5070
- # No source markdown carrier AND no pre-staged file. Silent no-op
5071
- # for benchmark misconfigurations (no fixture to gate against) and
5072
- # for real-user runs without spec/criteria. Generated source case
5073
- # is handled above.
5074
- if expected_data is None:
5398
+ # A declared pure-design contract continues through probes/results.
5399
+ # Missing handwritten/benchmark contracts remain an opt-in no-op;
5400
+ # missing generated contracts were rejected above.
5401
+ if not contract_found:
5075
5402
  return 0
5076
5403
  else:
5077
5404
  try:
@@ -5080,27 +5407,26 @@ def main() -> int:
5080
5407
  print(f"[spec-verify] error: cannot parse {spec_path}: {e}", file=sys.stderr)
5081
5408
  return 2
5082
5409
 
5083
- # iter-0019.8 (Codex R2 #2): apply full shape validation to pre-staged
5084
- # carriers too — bool exit_code, empty list, whitespace-only cmd were
5085
- # silently accepted on the benchmark path. Empty list is rejected
5086
- # because "all 0 commands passed" is vacuously true.
5087
- shape_err = validate_shape(spec)
5410
+ # Pre-staged commands need the strict carrier vocabulary too: unknown
5411
+ # expectations were silently ignored. Keep the nonempty shape gate;
5412
+ # a benchmark's staged empty list must not become a pure-design pass.
5413
+ shape_err = validate_shape(spec) or validate_inline_shape(spec)
5088
5414
  if shape_err:
5089
5415
  print(f"[spec-verify] error: {spec_path}: {shape_err}", file=sys.stderr)
5090
5416
  write_malformed_finding(devlyn_dir, f"{spec_path}: {shape_err}", None)
5091
- return 1
5417
+ return reject_preflight()
5092
5418
  commands = list(spec["verification_commands"])
5093
5419
  if include_risk_probes:
5094
5420
  risk_state_error = risk_probes_state_error(state)
5095
5421
  if risk_state_error:
5096
5422
  print(f"[spec-verify] risk probes malformed: {risk_state_error}", file=sys.stderr)
5097
5423
  write_malformed_finding(devlyn_dir, risk_state_error, Path("pipeline.state.json"))
5098
- return 1
5424
+ return reject_preflight()
5099
5425
  integrity_error = risk_probe_integrity_error(state, devlyn_dir)
5100
5426
  if integrity_error:
5101
5427
  print(f"[spec-verify] risk probes integrity failed: {integrity_error}", file=sys.stderr)
5102
5428
  write_risk_probe_integrity_finding(devlyn_dir, integrity_error)
5103
- return 1
5429
+ return reject_preflight()
5104
5430
  risk_probes, risk_error = load_risk_probes(
5105
5431
  devlyn_dir,
5106
5432
  source_md,
@@ -5109,7 +5435,7 @@ def main() -> int:
5109
5435
  if risk_error:
5110
5436
  print(f"[spec-verify] risk probes malformed: {risk_error}", file=sys.stderr)
5111
5437
  write_malformed_finding(devlyn_dir, risk_error, devlyn_dir / "risk-probes.jsonl")
5112
- return 1
5438
+ return reject_preflight()
5113
5439
  commands.extend(risk_probes)
5114
5440
 
5115
5441
  devlyn_dir.mkdir(parents=True, exist_ok=True)