devlyn-cli 2.8.0 → 2.9.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.
@@ -126,6 +126,14 @@ FORBIDDEN_RISK_PROBE_CMD_RE = re.compile(
126
126
  r'BENCH_FIXTURE_DIR|benchmark/auto-resolve/fixtures|/verifiers/|verifiers/'
127
127
  )
128
128
  EXTERNAL_URL_RE = re.compile(r"https?://([^/\s\"']+)", re.IGNORECASE)
129
+ RISK_PROBE_SCRIPT_RE = re.compile(
130
+ r'(?<![\w./-])(\.devlyn/probes/[A-Za-z0-9][A-Za-z0-9._/-]*)'
131
+ )
132
+ RISK_PROBE_INTEGRITY_FIX_HINT = (
133
+ "Probe artifacts changed after PHASE 1.5. Only the orchestrator may regenerate probes: "
134
+ "re-run probe validation and re-write state.risk_probes_digest; workers must never modify "
135
+ ".devlyn/risk-probes.jsonl or .devlyn/probes/."
136
+ )
129
137
  INLINE_JSON_OBJECT_RE = re.compile(r'`?\{\s*"[^"\n]+"\s*:', re.IGNORECASE)
130
138
  BACKTICKED_TEXT_RE = re.compile(r"`[^`\n]+`")
131
139
  OBSERVABLE_COMMAND_MARKERS = ("command", "observable", "expose")
@@ -457,6 +465,113 @@ def external_url_hosts(text: str) -> list[str]:
457
465
  return hosts
458
466
 
459
467
 
468
+ def referenced_risk_probe_scripts(cmd: str) -> list[str]:
469
+ scripts: list[str] = []
470
+ for match in RISK_PROBE_SCRIPT_RE.finditer(cmd or ""):
471
+ path = match.group(1)
472
+ if path not in scripts:
473
+ scripts.append(path)
474
+ return scripts
475
+
476
+
477
+ def validate_risk_probe_scripts(cmd: str, index: int, work: Path) -> str | None:
478
+ for rel_path in referenced_risk_probe_scripts(cmd):
479
+ path = Path(rel_path)
480
+ if ".." in path.parts:
481
+ return f"risk-probes[{index}].cmd references invalid probe script path: {rel_path}"
482
+ script_path = work / path
483
+ if not script_path.is_file():
484
+ return f"risk-probes[{index}].cmd references missing probe script: {rel_path}"
485
+ try:
486
+ content = script_path.read_text(encoding="utf-8")
487
+ except OSError as e:
488
+ return f"risk-probes[{index}].cmd references unreadable probe script {rel_path}: {e}"
489
+ if FORBIDDEN_RISK_PROBE_CMD_RE.search(content):
490
+ return (
491
+ f"risk-probes[{index}].cmd references {rel_path}, whose content "
492
+ "references hidden fixture/verifier paths; risk probes must "
493
+ "derive from visible spec text only"
494
+ )
495
+ external_hosts = external_url_hosts(content)
496
+ if external_hosts:
497
+ return (
498
+ f"risk-probes[{index}].cmd references {rel_path}, whose content "
499
+ f"references external URL(s): {', '.join(external_hosts)}; "
500
+ "use only worktree-local or localhost resources"
501
+ )
502
+ return None
503
+
504
+
505
+ def risk_probe_script_path(work: Path, rel_path: str) -> tuple[Path | None, str | None]:
506
+ path = Path(rel_path)
507
+ if path.is_absolute() or ".." in path.parts:
508
+ return (None, f"referenced probe script path is invalid: {rel_path}")
509
+ script_path = work / path
510
+ if not script_path.is_file():
511
+ return (None, f"referenced probe script is missing: {rel_path}")
512
+ return (script_path, None)
513
+
514
+
515
+ def risk_probes_digest(devlyn_dir: Path) -> tuple[str | None, str | None]:
516
+ probes_path = devlyn_dir / "risk-probes.jsonl"
517
+ if not probes_path.is_file():
518
+ return (None, "missing .devlyn/risk-probes.jsonl")
519
+ try:
520
+ probes_bytes = probes_path.read_bytes()
521
+ except OSError as e:
522
+ return (None, f"cannot read .devlyn/risk-probes.jsonl: {e}")
523
+ try:
524
+ probes_text = probes_bytes.decode("utf-8")
525
+ except UnicodeDecodeError as e:
526
+ return (None, f".devlyn/risk-probes.jsonl is not UTF-8: {e}")
527
+
528
+ scripts: set[str] = set()
529
+ for index, line in enumerate(probes_text.splitlines()):
530
+ if not line.strip():
531
+ continue
532
+ try:
533
+ probe = loads_strict_json(line)
534
+ except ValueError as e:
535
+ return (None, f"risk-probes[{index}] invalid JSON: {e}")
536
+ cmd = probe.get("cmd") if isinstance(probe, dict) else ""
537
+ scripts.update(referenced_risk_probe_scripts(cmd))
538
+
539
+ digest = hashlib.sha256()
540
+ digest.update(b".devlyn/risk-probes.jsonl\0")
541
+ digest.update(probes_bytes)
542
+ digest.update(b"\0")
543
+ for rel_path in sorted(scripts):
544
+ script_path, path_error = risk_probe_script_path(devlyn_dir.parent, rel_path)
545
+ if path_error:
546
+ return (None, path_error)
547
+ assert script_path is not None
548
+ try:
549
+ script_bytes = script_path.read_bytes()
550
+ except OSError as e:
551
+ return (None, f"cannot read {rel_path}: {e}")
552
+ digest.update(rel_path.encode("utf-8"))
553
+ digest.update(b"\0")
554
+ digest.update(script_bytes)
555
+ digest.update(b"\0")
556
+ return (digest.hexdigest(), None)
557
+
558
+
559
+ def risk_probe_integrity_error(state: dict, devlyn_dir: Path) -> str | None:
560
+ if not state_requires_risk_probes(state) and not (devlyn_dir / "risk-probes.jsonl").is_file():
561
+ return None
562
+ expected = state.get("risk_probes_digest")
563
+ if not isinstance(expected, str) or not expected.strip():
564
+ return "pipeline.state.json risk_probes_digest is required when risk probes are enabled or present"
565
+ actual, digest_error = risk_probes_digest(devlyn_dir)
566
+ if digest_error:
567
+ return digest_error
568
+ assert actual is not None
569
+ expected = expected.strip()
570
+ if expected != actual:
571
+ return f"pipeline.state.json risk_probes_digest mismatch: expected {expected}, actual {actual}"
572
+ return None
573
+
574
+
460
575
  def validate_shape(data) -> str | None:
461
576
  """Return None if shape matches the canonical verification_commands
462
577
  schema; else a human-readable error string.
@@ -629,7 +744,12 @@ def validate_sibling_spec_complexity(expected_path: Path) -> str | None:
629
744
  return validate_present_spec_complexity(spec_text)
630
745
 
631
746
 
632
- def validate_risk_probe(probe: object, index: int, verification_text: str) -> str | None:
747
+ def validate_risk_probe(
748
+ probe: object,
749
+ index: int,
750
+ verification_text: str,
751
+ work: Path,
752
+ ) -> str | None:
633
753
  if not isinstance(probe, dict):
634
754
  return f"risk-probes[{index}] must be a JSON object"
635
755
  probe_id = probe.get("id")
@@ -658,6 +778,9 @@ def validate_risk_probe(probe: object, index: int, verification_text: str) -> st
658
778
  f"risk-probes[{index}].cmd references external URL(s): "
659
779
  f"{', '.join(external_hosts)}; use only worktree-local or localhost resources"
660
780
  )
781
+ script_err = validate_risk_probe_scripts(cmd, index, work)
782
+ if script_err:
783
+ return script_err
661
784
  if len(cmd) > 4000:
662
785
  return f"risk-probes[{index}].cmd exceeds 4000 characters"
663
786
  tags = probe.get("tags")
@@ -786,7 +909,7 @@ def load_risk_probes(
786
909
  probe = loads_strict_json(line)
787
910
  except ValueError as e:
788
911
  return ([], f"risk-probes[{index}] invalid JSON: {e}")
789
- err = validate_risk_probe(probe, index, verification_text)
912
+ err = validate_risk_probe(probe, index, verification_text, devlyn_dir.parent)
790
913
  if err:
791
914
  return ([], err)
792
915
  normalized = dict(probe)
@@ -1006,6 +1129,28 @@ def write_malformed_finding(devlyn_dir: Path, error: str, source_path: Path | No
1006
1129
  fh.write(json.dumps(finding) + "\n")
1007
1130
 
1008
1131
 
1132
+ def write_risk_probe_integrity_finding(devlyn_dir: Path, error: str) -> None:
1133
+ devlyn_dir.mkdir(parents=True, exist_ok=True)
1134
+ findings_path = devlyn_dir / output_findings_name()
1135
+ finding = {
1136
+ "id": f"{output_finding_prefix()}-0001",
1137
+ "rule_id": "correctness.risk-probe-integrity",
1138
+ "level": "error",
1139
+ "severity": "CRITICAL",
1140
+ "confidence": 1.0,
1141
+ "message": f"Risk probe artifact integrity check failed: {error}.",
1142
+ "file": ".devlyn/risk-probes.jsonl",
1143
+ "line": 1,
1144
+ "phase": output_phase(),
1145
+ "criterion_ref": "risk-probes://digest",
1146
+ "fix_hint": RISK_PROBE_INTEGRITY_FIX_HINT,
1147
+ "blocking": True,
1148
+ "status": "open",
1149
+ }
1150
+ with findings_path.open("w") as fh:
1151
+ fh.write(json.dumps(finding) + "\n")
1152
+
1153
+
1009
1154
  def slice_diff_to_files(diff_text: str, files: list[str]) -> str:
1010
1155
  if not files:
1011
1156
  return diff_text
@@ -1238,62 +1383,82 @@ def path_matches_surface(path: str, surface: list[str]) -> bool:
1238
1383
  return False
1239
1384
 
1240
1385
 
1241
- def authorized_surface_findings(
1242
- work: Path, devlyn_dir: Path, state: dict, finding_start: int,
1243
- ) -> tuple[list[dict], int]:
1244
- """BUILD_GATE-only (caller gates on `output_phase() == "build_gate"`):
1245
- enforce PLAN's declared `authorized_surface` against this run's diff.
1386
+ def is_devlyn_path(path: str) -> bool:
1387
+ return path == ".devlyn" or path.startswith(".devlyn/")
1246
1388
 
1247
- iter-0046: closes the scope-leak drift class measured in
1248
- autoresearch/iterations/0042 + 0045 — bare-model diffs leak an
1249
- out-of-scope tracked file even with the full CLAUDE.md contract loaded,
1250
- on every measured model tier. `fix_hint` deliberately never offers
1251
- "widen the surface" — the fix-loop respawns the same IMPLEMENT worker
1252
- that produced the leak, and letting it edit `plan.md` to authorize its
1253
- own diff would let it self-authorize the exact drift this gate exists
1254
- to catch. A persistent finding exhausts the existing BUILD_GATE
1255
- fix-loop budget and halts for user/orchestrator review instead.
1256
1389
 
1257
- Not re-run at VERIFY MECHANICAL time (post-CLEANUP): CLEANUP's own
1258
- allowlist (tooling artifacts, dead code, doc-reference fixes) licenses
1259
- paths PLAN never declared, so re-checking there would false-positive on
1260
- CLEANUP's own sanctioned changes.
1261
- """
1390
+ def git_status_entries(work: Path) -> tuple[list[tuple[str, str]], str | None]:
1391
+ proc = subprocess.run(
1392
+ ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
1393
+ cwd=str(work),
1394
+ capture_output=True,
1395
+ )
1396
+ if proc.returncode != 0:
1397
+ error = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip()
1398
+ return ([], error or "git status failed")
1399
+ entries: list[tuple[str, str]] = []
1400
+ fields = proc.stdout.split(b"\0")
1401
+ i = 0
1402
+ while i < len(fields):
1403
+ raw = fields[i]
1404
+ i += 1
1405
+ if not raw:
1406
+ continue
1407
+ if len(raw) < 4:
1408
+ return ([], f"unexpected git status record: {raw!r}")
1409
+ status = raw[:2].decode("utf-8", "surrogateescape")
1410
+ path = raw[3:].decode("utf-8", "surrogateescape")
1411
+ entries.append((status, path))
1412
+ if ("R" in status or "C" in status) and i < len(fields) and fields[i]:
1413
+ old_path = fields[i].decode("utf-8", "surrogateescape")
1414
+ i += 1
1415
+ entries.append(("D ", old_path))
1416
+ return (entries, None)
1417
+
1418
+
1419
+ def current_worktree_changed_paths(work: Path) -> tuple[list[str], str | None]:
1420
+ entries, error = git_status_entries(work)
1421
+ if error:
1422
+ return ([], error)
1423
+ paths: list[str] = []
1424
+ seen: set[str] = set()
1425
+ for _status, path in entries:
1426
+ if is_devlyn_path(path) or path in seen:
1427
+ continue
1428
+ paths.append(path)
1429
+ seen.add(path)
1430
+ return (sorted(paths), None)
1431
+
1432
+
1433
+ def current_untracked_files(work: Path) -> tuple[set[str], str | None]:
1434
+ entries, error = git_status_entries(work)
1435
+ if error:
1436
+ return (set(), error)
1437
+ return ({path for status, path in entries if status == "??" and not is_devlyn_path(path)}, None)
1438
+
1439
+
1440
+ def load_untracked_baseline(devlyn_dir: Path) -> tuple[set[str], str | None]:
1441
+ baseline_path = devlyn_dir / "untracked.baseline"
1442
+ if not baseline_path.is_file():
1443
+ return (set(), "BUILD_GATE requires .devlyn/untracked.baseline from PHASE 0; the file is missing.")
1444
+ try:
1445
+ lines = baseline_path.read_text(encoding="utf-8").splitlines()
1446
+ except OSError as e:
1447
+ return (set(), f"Cannot read {baseline_path}: {e}")
1448
+ return ({line for line in lines if line and not is_devlyn_path(line)}, None)
1449
+
1450
+
1451
+ def load_authorized_surface(devlyn_dir: Path) -> tuple[list[str] | None, str | None]:
1262
1452
  plan_path = devlyn_dir / "plan.md"
1263
1453
  if not plan_path.is_file():
1264
- return ([{
1265
- "id": f"{output_finding_prefix()}-{finding_start:04d}",
1266
- "rule_id": "scope.authorized-surface-malformed",
1267
- "level": "error",
1268
- "severity": "CRITICAL",
1269
- "confidence": 1.0,
1270
- "message": "BUILD_GATE requires .devlyn/plan.md with a declared authorized_surface; the file is missing.",
1271
- "file": ".devlyn/plan.md",
1272
- "line": 1,
1273
- "phase": output_phase(),
1274
- "criterion_ref": "plan.md/authorized_surface",
1275
- "fix_hint": "PLAN must write .devlyn/plan.md with a `Files to touch` section and an authorized_surface json block before BUILD_GATE can run.",
1276
- "blocking": True,
1277
- "status": "open",
1278
- }], finding_start + 1)
1454
+ return (
1455
+ None,
1456
+ "BUILD_GATE requires .devlyn/plan.md with a declared authorized_surface; the file is missing.",
1457
+ )
1279
1458
  try:
1280
1459
  plan_text = plan_path.read_text(encoding="utf-8")
1281
1460
  except OSError as e:
1282
- return ([{
1283
- "id": f"{output_finding_prefix()}-{finding_start:04d}",
1284
- "rule_id": "scope.authorized-surface-malformed",
1285
- "level": "error",
1286
- "severity": "CRITICAL",
1287
- "confidence": 1.0,
1288
- "message": f"Cannot read {plan_path}: {e}",
1289
- "file": ".devlyn/plan.md",
1290
- "line": 1,
1291
- "phase": output_phase(),
1292
- "criterion_ref": "plan.md/authorized_surface",
1293
- "fix_hint": "Ensure .devlyn/plan.md is a readable UTF-8 file.",
1294
- "blocking": True,
1295
- "status": "open",
1296
- }], finding_start + 1)
1461
+ return (None, f"Cannot read {plan_path}: {e}")
1297
1462
 
1298
1463
  _section_found, block = extract_authorized_surface_block(plan_text)
1299
1464
  parse_error: str | None = None
@@ -1306,64 +1471,191 @@ def authorized_surface_findings(
1306
1471
  )
1307
1472
  else:
1308
1473
  try:
1309
- data = loads_strict_json(block)
1474
+ parsed = loads_strict_json(block)
1310
1475
  except ValueError as e:
1311
1476
  parse_error = f"authorized_surface json block is invalid JSON: {e}"
1477
+ else:
1478
+ data = parsed
1312
1479
  if parse_error is None:
1313
1480
  parse_error = validate_authorized_surface_shape(data)
1314
1481
  if parse_error is not None:
1315
- return ([{
1316
- "id": f"{output_finding_prefix()}-{finding_start:04d}",
1317
- "rule_id": "scope.authorized-surface-malformed",
1318
- "level": "error",
1319
- "severity": "CRITICAL",
1320
- "confidence": 1.0,
1321
- "message": f"plan.md authorized_surface is malformed: {parse_error}",
1322
- "file": ".devlyn/plan.md",
1323
- "line": 1,
1324
- "phase": output_phase(),
1325
- "criterion_ref": "plan.md/authorized_surface",
1326
- "fix_hint": (
1327
- "Restate section 1's already-listed paths as a fenced "
1328
- "```json``` block immediately after the `Files to touch` "
1329
- 'list: `{"authorized_surface": ["path/one", "path/two"]}`. '
1330
- "Do not invent new paths."
1482
+ return (None, f"plan.md authorized_surface is malformed: {parse_error}")
1483
+ assert data is not None
1484
+ return (list(data["authorized_surface"]), None)
1485
+
1486
+
1487
+ def scope_finding(
1488
+ seq: int,
1489
+ rule_id: str,
1490
+ message: str,
1491
+ file_ref: str,
1492
+ fix_hint: str,
1493
+ ) -> dict:
1494
+ return {
1495
+ "id": f"{output_finding_prefix()}-{seq:04d}",
1496
+ "rule_id": rule_id,
1497
+ "level": "error",
1498
+ "severity": "CRITICAL",
1499
+ "confidence": 1.0,
1500
+ "message": message,
1501
+ "file": file_ref,
1502
+ "line": 1,
1503
+ "phase": output_phase(),
1504
+ "criterion_ref": "plan.md/authorized_surface",
1505
+ "fix_hint": fix_hint,
1506
+ "blocking": True,
1507
+ "status": "open",
1508
+ }
1509
+
1510
+
1511
+ def authorized_surface_findings(
1512
+ work: Path, devlyn_dir: Path, state: dict, finding_start: int,
1513
+ ) -> tuple[list[dict], int]:
1514
+ """BUILD_GATE-only (caller gates on `output_phase() == "build_gate"`):
1515
+ enforce PLAN's declared `authorized_surface` against this run's diff.
1516
+
1517
+ This closes the measured scope-leak drift class: bare-model diffs leaked
1518
+ an out-of-scope tracked file even with the full CLAUDE.md contract loaded,
1519
+ on every measured model tier. `fix_hint` deliberately never offers
1520
+ "widen the surface" — the fix-loop respawns the same IMPLEMENT worker
1521
+ that produced the leak, and letting it edit `plan.md` to authorize its
1522
+ own diff would let it self-authorize the exact drift this gate exists
1523
+ to catch. A persistent finding exhausts the existing BUILD_GATE
1524
+ fix-loop budget and halts for user/orchestrator review instead.
1525
+
1526
+ Not re-run at VERIFY MECHANICAL time (post-CLEANUP): CLEANUP's own
1527
+ allowlist (tooling artifacts, dead code, doc-reference fixes) licenses
1528
+ paths PLAN never declared, so re-checking there would false-positive on
1529
+ CLEANUP's own sanctioned changes.
1530
+ """
1531
+ surface, surface_error = load_authorized_surface(devlyn_dir)
1532
+ if surface_error is not None:
1533
+ return ([scope_finding(
1534
+ finding_start,
1535
+ "scope.authorized-surface-malformed",
1536
+ surface_error,
1537
+ ".devlyn/plan.md",
1538
+ (
1539
+ "PLAN must write .devlyn/plan.md with a `Files to touch` "
1540
+ "section and an authorized_surface json block before "
1541
+ "BUILD_GATE can run."
1331
1542
  ),
1332
- "blocking": True,
1333
- "status": "open",
1334
- }], finding_start + 1)
1543
+ )], finding_start + 1)
1544
+ assert surface is not None
1545
+
1546
+ baseline, baseline_error = load_untracked_baseline(devlyn_dir)
1547
+ if baseline_error is not None:
1548
+ return ([scope_finding(
1549
+ finding_start,
1550
+ "scope.authorized-surface-malformed",
1551
+ baseline_error,
1552
+ ".devlyn/untracked.baseline",
1553
+ (
1554
+ "PHASE 0 must write .devlyn/untracked.baseline before "
1555
+ "BUILD_GATE so created-during-run untracked files remain "
1556
+ "visible to the scope gate."
1557
+ ),
1558
+ )], finding_start + 1)
1559
+ current_untracked, untracked_error = current_untracked_files(work)
1560
+ if untracked_error is not None:
1561
+ return ([scope_finding(
1562
+ finding_start,
1563
+ "scope.authorized-surface-malformed",
1564
+ f"Cannot read current untracked files: {untracked_error}",
1565
+ ".devlyn/untracked.baseline",
1566
+ "Ensure BUILD_GATE runs inside a readable git worktree.",
1567
+ )], finding_start + 1)
1335
1568
 
1336
- surface = data["authorized_surface"]
1337
1569
  findings: list[dict] = []
1338
1570
  seq = finding_start
1339
1571
  for path in changed_files(work, state, devlyn_dir):
1340
1572
  if path_matches_surface(path, surface):
1341
1573
  continue
1342
- findings.append({
1343
- "id": f"{output_finding_prefix()}-{seq:04d}",
1344
- "rule_id": "scope.out-of-scope-file",
1345
- "level": "error",
1346
- "severity": "CRITICAL",
1347
- "confidence": 1.0,
1348
- "message": f"{path} is outside PLAN's declared authorized_surface.",
1349
- "file": path,
1350
- "line": 1,
1351
- "phase": output_phase(),
1352
- "criterion_ref": "plan.md/authorized_surface",
1353
- "fix_hint": (
1574
+ findings.append(scope_finding(
1575
+ seq,
1576
+ "scope.out-of-scope-file",
1577
+ f"{path} is outside PLAN's declared authorized_surface.",
1578
+ path,
1579
+ (
1354
1580
  f"Remove {path} from the diff. Do not widen plan.md's "
1355
1581
  "authorized_surface to include it — that would let this "
1356
1582
  "fix loop self-authorize its own scope leak. If the file "
1357
1583
  "is genuinely required, halt per implement.md's contract "
1358
1584
  "so this finding reaches the user/orchestrator for a new run."
1359
1585
  ),
1360
- "blocking": True,
1361
- "status": "open",
1362
- })
1586
+ ))
1587
+ seq += 1
1588
+ for path in sorted(current_untracked - baseline):
1589
+ if path_matches_surface(path, surface):
1590
+ continue
1591
+ findings.append(scope_finding(
1592
+ seq,
1593
+ "scope.out-of-scope-file",
1594
+ f"{path} is a created-during-run unauthorized untracked file.",
1595
+ path,
1596
+ (
1597
+ f"Remove {path}. Do not widen plan.md's authorized_surface "
1598
+ "to include it — new untracked leaks must be removed, not "
1599
+ "self-authorized by the fix loop."
1600
+ ),
1601
+ ))
1363
1602
  seq += 1
1364
1603
  return (findings, seq)
1365
1604
 
1366
1605
 
1606
+ def run_print_authorized_surface(work: Path, devlyn_dir: Path) -> int:
1607
+ surface, surface_error = load_authorized_surface(devlyn_dir)
1608
+ if surface_error is not None:
1609
+ print(f"[spec-verify --print-authorized-surface] {surface_error}", file=sys.stderr)
1610
+ return 2
1611
+ assert surface is not None
1612
+ paths, status_error = current_worktree_changed_paths(work)
1613
+ if status_error is not None:
1614
+ print(f"[spec-verify --print-authorized-surface] git status failed: {status_error}", file=sys.stderr)
1615
+ return 2
1616
+ authorized_paths = [path for path in paths if path_matches_surface(path, surface)]
1617
+ if authorized_paths:
1618
+ sys.stdout.buffer.write("\0".join(authorized_paths).encode("utf-8", "surrogateescape") + b"\0")
1619
+ return 0
1620
+
1621
+
1622
+ def run_print_risk_probes_digest(devlyn_dir: Path) -> int:
1623
+ digest, error = risk_probes_digest(devlyn_dir)
1624
+ if error:
1625
+ print(f"[spec-verify --print-risk-probes-digest] {error}", file=sys.stderr)
1626
+ return 2
1627
+ assert digest is not None
1628
+ print(digest)
1629
+ return 0
1630
+
1631
+
1632
+ def run_write_untracked_baseline(work: Path, devlyn_dir: Path) -> int:
1633
+ """PHASE 0 writer for `.devlyn/untracked.baseline`. Shares
1634
+ git_status_entries with the BUILD_GATE reader so writer and comparer can
1635
+ never disagree on quoting or directory collapsing (a shell-side
1636
+ `git status --porcelain | awk` writer records untracked directories as
1637
+ `dir/` and C-quotes special characters, while the comparer sees
1638
+ `--untracked-files=all` unquoted per-file paths — every pre-existing
1639
+ file under an untracked directory would false-positive as
1640
+ created-during-run)."""
1641
+ entries, error = git_status_entries(work)
1642
+ if error:
1643
+ print(f"[spec-verify --write-untracked-baseline] git status failed: {error}", file=sys.stderr)
1644
+ return 2
1645
+ untracked = sorted(
1646
+ path for status, path in entries
1647
+ if status == "??" and not is_devlyn_path(path)
1648
+ )
1649
+ devlyn_dir.mkdir(parents=True, exist_ok=True)
1650
+ baseline_path = devlyn_dir / "untracked.baseline"
1651
+ baseline_path.write_text(
1652
+ "".join(path + "\n" for path in untracked),
1653
+ encoding="utf-8",
1654
+ errors="surrogateescape",
1655
+ )
1656
+ return 0
1657
+
1658
+
1367
1659
  def run_check_mode(md_path: Path) -> int:
1368
1660
  """`--check <markdown>` — validate the verification carrier without
1369
1661
  running any commands. Used by /devlyn:ideate after item-spec write.
@@ -1461,10 +1753,14 @@ def run_self_test() -> int:
1461
1753
  {"cmd": "printf ok", "exit_code": 0, "stdout_contains": ["ok"]}
1462
1754
  ]
1463
1755
  }) + "\n")
1464
- (devlyn / "risk-probes.jsonl").write_text(json.dumps({
1756
+ probes_dir = devlyn / "probes"
1757
+ probes_dir.mkdir()
1758
+ probe_script = probes_dir / "P1.py"
1759
+ probe_script.write_text("print('probe-ok')\n", encoding="utf-8")
1760
+ risk_probe_payload = {
1465
1761
  "id": "P1",
1466
1762
  "derived_from": "probe must pass visible marker.",
1467
- "cmd": "printf probe-ok",
1763
+ "cmd": "python3 .devlyn/probes/P1.py",
1468
1764
  "exit_code": 0,
1469
1765
  "stdout_contains": ["probe-ok"],
1470
1766
  "stdout_not_contains": [],
@@ -1476,9 +1772,42 @@ def run_self_test() -> int:
1476
1772
  "asserts_no_unexpected_output_keys",
1477
1773
  ],
1478
1774
  },
1479
- }) + "\n")
1775
+ }
1776
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps(risk_probe_payload) + "\n")
1480
1777
  env = os.environ.copy()
1481
1778
  env["BENCH_WORKDIR"] = str(work)
1779
+ validate_without_digest = subprocess.run(
1780
+ [sys.executable, script_path, "--validate-risk-probes"],
1781
+ cwd=work,
1782
+ env=env,
1783
+ capture_output=True,
1784
+ text=True,
1785
+ )
1786
+ if validate_without_digest.returncode != 0:
1787
+ print("--validate-risk-probes rejected valid probes without digest", file=sys.stderr)
1788
+ print(validate_without_digest.stderr, file=sys.stderr)
1789
+ return 1
1790
+ digest_run = subprocess.run(
1791
+ [sys.executable, script_path, "--print-risk-probes-digest"],
1792
+ cwd=work,
1793
+ env=env,
1794
+ capture_output=True,
1795
+ text=True,
1796
+ )
1797
+ if digest_run.returncode != 0:
1798
+ print("--print-risk-probes-digest rejected valid probes", file=sys.stderr)
1799
+ print(digest_run.stderr, file=sys.stderr)
1800
+ return 1
1801
+ risk_digest = digest_run.stdout.strip()
1802
+ if not re.fullmatch(r"[0-9a-f]{64}", risk_digest):
1803
+ print("--print-risk-probes-digest printed a non-sha256 digest", file=sys.stderr)
1804
+ print(repr(digest_run.stdout), file=sys.stderr)
1805
+ return 1
1806
+ (devlyn / "pipeline.state.json").write_text(json.dumps({
1807
+ "source": {"type": "spec", "spec_path": str(spec_md)},
1808
+ "risk_profile": {"risk_probes_enabled": True},
1809
+ "risk_probes_digest": risk_digest,
1810
+ }))
1482
1811
  good = subprocess.run(
1483
1812
  [sys.executable, script_path, "--include-risk-probes"],
1484
1813
  cwd=work,
@@ -1490,10 +1819,84 @@ def run_self_test() -> int:
1490
1819
  print(good.stderr, file=sys.stderr)
1491
1820
  return 1
1492
1821
 
1822
+ probe_script.write_text("print('mutated-probe')\n", encoding="utf-8")
1823
+ mutated_script = subprocess.run(
1824
+ [sys.executable, script_path, "--include-risk-probes"],
1825
+ cwd=work,
1826
+ env=env,
1827
+ capture_output=True,
1828
+ text=True,
1829
+ )
1830
+ if mutated_script.returncode == 0:
1831
+ print("--include-risk-probes accepted mutated probe script bytes", file=sys.stderr)
1832
+ return 1
1833
+ integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
1834
+ if "correctness.risk-probe-integrity" not in integrity_findings:
1835
+ print("mutated probe script did not emit correctness.risk-probe-integrity", file=sys.stderr)
1836
+ print(integrity_findings, file=sys.stderr)
1837
+ return 1
1838
+ probe_script.write_text("print('probe-ok')\n", encoding="utf-8")
1839
+
1840
+ mutated_payload = dict(risk_probe_payload)
1841
+ mutated_payload["id"] = "P1-mutated"
1842
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps(mutated_payload) + "\n")
1843
+ mutated_jsonl = subprocess.run(
1844
+ [sys.executable, script_path, "--include-risk-probes"],
1845
+ cwd=work,
1846
+ env=env,
1847
+ capture_output=True,
1848
+ text=True,
1849
+ )
1850
+ if mutated_jsonl.returncode == 0:
1851
+ print("--include-risk-probes accepted mutated risk-probes.jsonl bytes", file=sys.stderr)
1852
+ return 1
1853
+ integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
1854
+ if "correctness.risk-probe-integrity" not in integrity_findings:
1855
+ print("mutated risk-probes.jsonl did not emit correctness.risk-probe-integrity", file=sys.stderr)
1856
+ print(integrity_findings, file=sys.stderr)
1857
+ return 1
1858
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps(risk_probe_payload) + "\n")
1859
+
1860
+ (devlyn / "pipeline.state.json").write_text(json.dumps({
1861
+ "source": {"type": "spec", "spec_path": str(spec_md)},
1862
+ "risk_profile": {"risk_probes_enabled": True},
1863
+ }))
1864
+ missing_digest = subprocess.run(
1865
+ [sys.executable, script_path, "--include-risk-probes"],
1866
+ cwd=work,
1867
+ env=env,
1868
+ capture_output=True,
1869
+ text=True,
1870
+ )
1871
+ if missing_digest.returncode == 0:
1872
+ print("--include-risk-probes accepted enabled risk probes with missing digest", file=sys.stderr)
1873
+ return 1
1874
+ integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
1875
+ if "correctness.risk-probe-integrity" not in integrity_findings:
1876
+ print("missing risk_probes_digest did not emit correctness.risk-probe-integrity", file=sys.stderr)
1877
+ print(integrity_findings, file=sys.stderr)
1878
+ return 1
1879
+
1493
1880
  (devlyn / "risk-probes.jsonl").unlink()
1881
+ missing_jsonl_digest = subprocess.run(
1882
+ [sys.executable, script_path, "--print-risk-probes-digest"],
1883
+ cwd=work,
1884
+ env=env,
1885
+ capture_output=True,
1886
+ text=True,
1887
+ )
1888
+ if missing_jsonl_digest.returncode != 2:
1889
+ print("--print-risk-probes-digest accepted missing risk-probes.jsonl", file=sys.stderr)
1890
+ print(missing_jsonl_digest.stderr, file=sys.stderr)
1891
+ return 1
1892
+ if "missing .devlyn/risk-probes.jsonl" not in missing_jsonl_digest.stderr:
1893
+ print("missing risk-probes.jsonl digest mode had the wrong error", file=sys.stderr)
1894
+ print(missing_jsonl_digest.stderr, file=sys.stderr)
1895
+ return 1
1494
1896
  (devlyn / "pipeline.state.json").write_text(json.dumps({
1495
1897
  "source": {"type": "spec", "spec_path": str(spec_md)},
1496
1898
  "risk_profile": {"risk_probes_enabled": True},
1899
+ "risk_probes_digest": risk_digest,
1497
1900
  }))
1498
1901
  missing_required_probe = subprocess.run(
1499
1902
  [sys.executable, script_path, "--include-risk-probes"],
@@ -1505,8 +1908,8 @@ def run_self_test() -> int:
1505
1908
  if missing_required_probe.returncode == 0:
1506
1909
  print("--include-risk-probes accepted missing required risk-probes.jsonl", file=sys.stderr)
1507
1910
  return 1
1508
- if "risk-probes.jsonl is required when --risk-probes is enabled" not in missing_required_probe.stderr:
1509
- print("--include-risk-probes missing required probe had the wrong error", file=sys.stderr)
1911
+ if "risk probes integrity failed" not in missing_required_probe.stderr:
1912
+ print("--include-risk-probes missing required probe had the wrong integrity error", file=sys.stderr)
1510
1913
  print(missing_required_probe.stderr, file=sys.stderr)
1511
1914
  return 1
1512
1915
 
@@ -1790,6 +2193,117 @@ def run_self_test() -> int:
1790
2193
  print("hidden verifier path was accepted", file=sys.stderr)
1791
2194
  return 1
1792
2195
 
2196
+ probes_dir = devlyn / "probes"
2197
+ probes_dir.mkdir(exist_ok=True)
2198
+ (probes_dir / "Pscript.py").write_text("print('script-ok')\n", encoding="utf-8")
2199
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2200
+ "id": "Pscript",
2201
+ "derived_from": "probe must pass visible marker.",
2202
+ "cmd": "python3 .devlyn/probes/Pscript.py",
2203
+ "exit_code": 0,
2204
+ "stdout_contains": ["script-ok"],
2205
+ "stdout_not_contains": [],
2206
+ "tags": ["shape_contract"],
2207
+ "tag_evidence": {
2208
+ "shape_contract": [
2209
+ "uses_visible_input_key_names",
2210
+ "asserts_visible_output_key_names",
2211
+ "asserts_no_unexpected_output_keys",
2212
+ ],
2213
+ },
2214
+ }) + "\n")
2215
+ good_script_probe = subprocess.run(
2216
+ [sys.executable, script_path, "--validate-risk-probes"],
2217
+ cwd=work,
2218
+ env=env,
2219
+ capture_output=True,
2220
+ text=True,
2221
+ )
2222
+ if good_script_probe.returncode != 0:
2223
+ print("risk probe script file was rejected", file=sys.stderr)
2224
+ print(good_script_probe.stderr, file=sys.stderr)
2225
+ return 1
2226
+
2227
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2228
+ "id": "Pmissing",
2229
+ "derived_from": "probe must pass visible marker.",
2230
+ "cmd": "python3 .devlyn/probes/missing.py",
2231
+ "exit_code": 0,
2232
+ }) + "\n")
2233
+ missing_script_probe = subprocess.run(
2234
+ [sys.executable, script_path, "--validate-risk-probes"],
2235
+ cwd=work,
2236
+ env=env,
2237
+ capture_output=True,
2238
+ text=True,
2239
+ )
2240
+ if missing_script_probe.returncode == 0:
2241
+ print("risk probe missing script file was accepted", file=sys.stderr)
2242
+ return 1
2243
+ if "references missing probe script" not in missing_script_probe.stderr:
2244
+ print("missing risk probe script had the wrong error", file=sys.stderr)
2245
+ print(missing_script_probe.stderr, file=sys.stderr)
2246
+ return 1
2247
+ missing_script_digest = subprocess.run(
2248
+ [sys.executable, script_path, "--print-risk-probes-digest"],
2249
+ cwd=work,
2250
+ env=env,
2251
+ capture_output=True,
2252
+ text=True,
2253
+ )
2254
+ if missing_script_digest.returncode != 2:
2255
+ print("--print-risk-probes-digest accepted a missing referenced script", file=sys.stderr)
2256
+ print(missing_script_digest.stderr, file=sys.stderr)
2257
+ return 1
2258
+ if "referenced probe script is missing" not in missing_script_digest.stderr:
2259
+ print("missing script digest mode had the wrong error", file=sys.stderr)
2260
+ print(missing_script_digest.stderr, file=sys.stderr)
2261
+ return 1
2262
+
2263
+ (probes_dir / "Phidden.py").write_text("print('benchmark/auto-resolve/fixtures')\n", encoding="utf-8")
2264
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2265
+ "id": "Phidden",
2266
+ "derived_from": "probe must pass visible marker.",
2267
+ "cmd": "python3 .devlyn/probes/Phidden.py",
2268
+ "exit_code": 0,
2269
+ }) + "\n")
2270
+ hidden_script_probe = subprocess.run(
2271
+ [sys.executable, script_path, "--validate-risk-probes"],
2272
+ cwd=work,
2273
+ env=env,
2274
+ capture_output=True,
2275
+ text=True,
2276
+ )
2277
+ if hidden_script_probe.returncode == 0:
2278
+ print("risk probe script containing a hidden fixture path was accepted", file=sys.stderr)
2279
+ return 1
2280
+ if "whose content references hidden fixture/verifier paths" not in hidden_script_probe.stderr:
2281
+ print("hidden-path risk probe script had the wrong error", file=sys.stderr)
2282
+ print(hidden_script_probe.stderr, file=sys.stderr)
2283
+ return 1
2284
+
2285
+ (probes_dir / "Pexternal.py").write_text("print('https://example.com/check')\n", encoding="utf-8")
2286
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2287
+ "id": "Pexternal",
2288
+ "derived_from": "probe must pass visible marker.",
2289
+ "cmd": "python3 .devlyn/probes/Pexternal.py",
2290
+ "exit_code": 0,
2291
+ }) + "\n")
2292
+ external_script_probe = subprocess.run(
2293
+ [sys.executable, script_path, "--validate-risk-probes"],
2294
+ cwd=work,
2295
+ env=env,
2296
+ capture_output=True,
2297
+ text=True,
2298
+ )
2299
+ if external_script_probe.returncode == 0:
2300
+ print("risk probe script containing an external URL was accepted", file=sys.stderr)
2301
+ return 1
2302
+ if "whose content references external URL" not in external_script_probe.stderr:
2303
+ print("external-URL risk probe script had the wrong error", file=sys.stderr)
2304
+ print(external_script_probe.stderr, file=sys.stderr)
2305
+ return 1
2306
+
1793
2307
  (devlyn / "risk-probes.jsonl").write_text('{"id":NaN}\n')
1794
2308
  bad_probe_nan = subprocess.run(
1795
2309
  [sys.executable, script_path, "--validate-risk-probes"],
@@ -3135,6 +3649,27 @@ def run_self_test() -> int:
3135
3649
  "source": {"type": "spec", "spec_path": str(scope_spec)},
3136
3650
  "base_ref": {"sha": scope_base_sha},
3137
3651
  }))
3652
+ (scope_root / "preexisting.local").write_text("pre-existing untracked\n", encoding="utf-8")
3653
+ # Writer parity: --write-untracked-baseline must share the reader's
3654
+ # parser — untracked DIRECTORIES expand to per-file paths and
3655
+ # special-character paths stay unquoted, or every pre-existing file
3656
+ # under an untracked directory false-positives as created-during-run.
3657
+ preexisting_dir = scope_root / "pre existing dir"
3658
+ preexisting_dir.mkdir()
3659
+ (preexisting_dir / "nested file.txt").write_text("scaffold\n", encoding="utf-8")
3660
+ write_baseline_run = subprocess.run(
3661
+ [sys.executable, script_path, "--write-untracked-baseline"],
3662
+ cwd=scope_root, capture_output=True, text=True,
3663
+ )
3664
+ if write_baseline_run.returncode != 0:
3665
+ print("--write-untracked-baseline failed", file=sys.stderr)
3666
+ print(write_baseline_run.stderr, file=sys.stderr)
3667
+ return 1
3668
+ baseline_lines = (scope_devlyn / "untracked.baseline").read_text(encoding="utf-8").splitlines()
3669
+ if baseline_lines != ["pre existing dir/nested file.txt", "preexisting.local"]:
3670
+ print("--write-untracked-baseline wrote wrong content", file=sys.stderr)
3671
+ print(repr(baseline_lines), file=sys.stderr)
3672
+ return 1
3138
3673
  scope_findings_path = scope_devlyn / "spec-verify-findings.jsonl"
3139
3674
 
3140
3675
  # Test 1: no .devlyn/plan.md at all -> fail-closed CRITICAL, not a no-op.
@@ -3162,15 +3697,58 @@ def run_self_test() -> int:
3162
3697
  if "scope.authorized-surface-malformed" not in scope_findings_path.read_text():
3163
3698
  print("missing authorized_surface block did not emit scope.authorized-surface-malformed", file=sys.stderr)
3164
3699
  return 1
3700
+ malformed_print_surface = subprocess.run(
3701
+ [sys.executable, script_path, "--print-authorized-surface"],
3702
+ cwd=scope_root,
3703
+ capture_output=True,
3704
+ )
3705
+ if malformed_print_surface.returncode == 0:
3706
+ print("--print-authorized-surface accepted malformed plan.md", file=sys.stderr)
3707
+ return 1
3165
3708
 
3166
3709
  # Test 3: valid surface, in-scope-only diff -> no scope findings, exit 0.
3167
3710
  (scope_devlyn / "plan.md").write_text(
3168
3711
  "# PLAN\n\n<!-- devlyn:authorized-surface -->\n## 1. Files to touch\n\n"
3169
3712
  "- `bin/cli.js` (edit): ship the fix.\n\n"
3170
3713
  "```json\n"
3171
- '{"authorized_surface": ["bin/cli.js", "lib/**"]}\n'
3714
+ '{"authorized_surface": ["bin/cli.js", "lib/**", "authorized-but-uncreated.txt"]}\n'
3172
3715
  "```\n"
3173
3716
  )
3717
+ (scope_root / "lib" / "new.js").write_text("module.exports = { created: true };\n")
3718
+ print_surface = subprocess.run(
3719
+ [sys.executable, script_path, "--print-authorized-surface"],
3720
+ cwd=scope_root,
3721
+ capture_output=True,
3722
+ )
3723
+ if print_surface.returncode != 0:
3724
+ print("--print-authorized-surface rejected valid plan.md", file=sys.stderr)
3725
+ print(print_surface.stderr.decode("utf-8", "replace"), file=sys.stderr)
3726
+ return 1
3727
+ printed_paths = {
3728
+ path.decode("utf-8", "surrogateescape")
3729
+ for path in print_surface.stdout.split(b"\0") if path
3730
+ }
3731
+ if printed_paths != {"bin/cli.js", "lib/new.js"}:
3732
+ print("--print-authorized-surface printed the wrong paths", file=sys.stderr)
3733
+ print(repr(printed_paths), file=sys.stderr)
3734
+ return 1
3735
+ (scope_devlyn / "untracked.baseline").unlink()
3736
+ missing_baseline_run = subprocess.run(
3737
+ [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
3738
+ )
3739
+ if missing_baseline_run.returncode == 0:
3740
+ print("BUILD_GATE accepted missing .devlyn/untracked.baseline", file=sys.stderr)
3741
+ return 1
3742
+ if "untracked.baseline" not in scope_findings_path.read_text():
3743
+ print("missing untracked baseline did not emit a scope finding", file=sys.stderr)
3744
+ return 1
3745
+ rewrite_baseline_run = subprocess.run(
3746
+ [sys.executable, script_path, "--write-untracked-baseline"],
3747
+ cwd=scope_root, capture_output=True, text=True,
3748
+ )
3749
+ if rewrite_baseline_run.returncode != 0:
3750
+ print("--write-untracked-baseline re-run failed", file=sys.stderr)
3751
+ return 1
3174
3752
  in_scope_run = subprocess.run(
3175
3753
  [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
3176
3754
  )
@@ -3187,6 +3765,7 @@ def run_self_test() -> int:
3187
3765
  # flagged, and the fix_hint must never suggest self-authorization.
3188
3766
  (scope_root / "lib" / "keep.js").write_text("module.exports = { touched: true };\n")
3189
3767
  (scope_root / "data" / "usage-stats.json").write_text('{"leaked": true}\n')
3768
+ (scope_root / "data" / "scratch.json").write_text('{"untracked": true}\n')
3190
3769
  out_of_scope_run = subprocess.run(
3191
3770
  [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
3192
3771
  )
@@ -3199,6 +3778,15 @@ def run_self_test() -> int:
3199
3778
  if "data/usage-stats.json" not in flagged_files:
3200
3779
  print("scope.out-of-scope-file did not name data/usage-stats.json", file=sys.stderr)
3201
3780
  return 1
3781
+ if "data/scratch.json" not in flagged_files:
3782
+ print("created-during-run unauthorized untracked file was not flagged", file=sys.stderr)
3783
+ return 1
3784
+ if "pre existing dir/nested file.txt" in flagged_files:
3785
+ print("pre-existing untracked directory file was flagged (writer/reader parity broken)", file=sys.stderr)
3786
+ return 1
3787
+ if "preexisting.local" in flagged_files:
3788
+ print("pre-existing untracked baseline file was flagged", file=sys.stderr)
3789
+ return 1
3202
3790
  if "lib/keep.js" in flagged_files:
3203
3791
  print("lib/** directory grant did not cover lib/keep.js", file=sys.stderr)
3204
3792
  return 1
@@ -3307,12 +3895,24 @@ def run_self_test() -> int:
3307
3895
  def main() -> int:
3308
3896
  include_risk_probes = False
3309
3897
  validate_risk_probes_only = False
3898
+ print_risk_probes_digest = False
3899
+ print_authorized_surface = False
3900
+ write_untracked_baseline = False
3310
3901
  if "--include-risk-probes" in sys.argv[1:]:
3311
3902
  include_risk_probes = True
3312
3903
  sys.argv = [arg for arg in sys.argv if arg != "--include-risk-probes"]
3313
3904
  if "--validate-risk-probes" in sys.argv[1:]:
3314
3905
  validate_risk_probes_only = True
3315
3906
  sys.argv = [arg for arg in sys.argv if arg != "--validate-risk-probes"]
3907
+ if "--print-risk-probes-digest" in sys.argv[1:]:
3908
+ print_risk_probes_digest = True
3909
+ sys.argv = [arg for arg in sys.argv if arg != "--print-risk-probes-digest"]
3910
+ if "--print-authorized-surface" in sys.argv[1:]:
3911
+ print_authorized_surface = True
3912
+ sys.argv = [arg for arg in sys.argv if arg != "--print-authorized-surface"]
3913
+ if "--write-untracked-baseline" in sys.argv[1:]:
3914
+ write_untracked_baseline = True
3915
+ sys.argv = [arg for arg in sys.argv if arg != "--write-untracked-baseline"]
3316
3916
 
3317
3917
  if len(sys.argv) == 2 and sys.argv[1] == "--self-test":
3318
3918
  return run_self_test()
@@ -3334,6 +3934,24 @@ def main() -> int:
3334
3934
  devlyn_dir = work / ".devlyn"
3335
3935
  spec_path = devlyn_dir / "spec-verify.json"
3336
3936
 
3937
+ if print_risk_probes_digest:
3938
+ if include_risk_probes or validate_risk_probes_only or print_authorized_surface or write_untracked_baseline or len(sys.argv) != 1:
3939
+ print("usage: spec-verify-check.py --print-risk-probes-digest", file=sys.stderr)
3940
+ return 2
3941
+ return run_print_risk_probes_digest(devlyn_dir)
3942
+
3943
+ if print_authorized_surface:
3944
+ if include_risk_probes or validate_risk_probes_only or write_untracked_baseline or len(sys.argv) != 1:
3945
+ print("usage: spec-verify-check.py --print-authorized-surface", file=sys.stderr)
3946
+ return 2
3947
+ return run_print_authorized_surface(work, devlyn_dir)
3948
+
3949
+ if write_untracked_baseline:
3950
+ if include_risk_probes or validate_risk_probes_only or len(sys.argv) != 1:
3951
+ print("usage: spec-verify-check.py --write-untracked-baseline", file=sys.stderr)
3952
+ return 2
3953
+ return run_write_untracked_baseline(work, devlyn_dir)
3954
+
3337
3955
  # iter-0019.8 + iter-0019.9 (Codex R-phaseA): determine the contract
3338
3956
  # carrier source for THIS run. Order:
3339
3957
  # 1. Benchmark mode (BENCH_WORKDIR set) AND a pre-staged
@@ -3461,6 +4079,11 @@ def main() -> int:
3461
4079
  print(f"[spec-verify] risk probes malformed: {risk_state_error}", file=sys.stderr)
3462
4080
  write_malformed_finding(devlyn_dir, risk_state_error, Path("pipeline.state.json"))
3463
4081
  return 1
4082
+ integrity_error = risk_probe_integrity_error(state, devlyn_dir)
4083
+ if integrity_error:
4084
+ print(f"[spec-verify] risk probes integrity failed: {integrity_error}", file=sys.stderr)
4085
+ write_risk_probe_integrity_finding(devlyn_dir, integrity_error)
4086
+ return 1
3464
4087
  risk_probes, risk_error = load_risk_probes(
3465
4088
  devlyn_dir,
3466
4089
  source_md,