devlyn-cli 2.10.0 → 3.0.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 (82) hide show
  1. package/AGENTS.md +4 -9
  2. package/CLAUDE.md +11 -15
  3. package/README.md +31 -7
  4. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
  5. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
  6. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
  7. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
  8. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
  9. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
  10. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
  11. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
  12. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
  13. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
  14. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
  15. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
  16. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
  17. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
  18. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
  19. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
  20. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
  21. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
  22. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
  23. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
  24. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
  25. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
  26. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
  27. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
  28. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
  29. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
  30. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
  31. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
  32. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
  33. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
  34. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
  35. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
  36. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
  37. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
  38. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
  39. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
  40. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  41. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  42. package/bin/devlyn.js +59 -9
  43. package/config/skills/_shared/.ruff.toml +2 -0
  44. package/config/skills/_shared/adapters/claude.md +9 -1
  45. package/config/skills/_shared/adapters/codex.md +5 -0
  46. package/config/skills/_shared/adapters/grok.md +37 -13
  47. package/config/skills/_shared/archive_run.py +738 -56
  48. package/config/skills/_shared/codex-config.md +19 -4
  49. package/config/skills/_shared/codex-monitored.sh +81 -12
  50. package/config/skills/_shared/collect-codex-findings.py +419 -60
  51. package/config/skills/_shared/engine-preflight.md +8 -7
  52. package/config/skills/_shared/expected.schema.json +68 -0
  53. package/config/skills/_shared/finish-gate.py +12 -11
  54. package/config/skills/_shared/grok-anchor-guard.py +78 -0
  55. package/config/skills/_shared/invocation-receipt.py +1041 -0
  56. package/config/skills/_shared/judge-output-parser.py +200 -0
  57. package/config/skills/_shared/judge-role-evidence.py +273 -0
  58. package/config/skills/_shared/phase-prompt-render.py +181 -0
  59. package/config/skills/_shared/process-evidence.py +909 -0
  60. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  61. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  62. package/config/skills/_shared/role-config.py +552 -0
  63. package/config/skills/_shared/run-bounded.py +1 -1
  64. package/config/skills/_shared/spec-verify-check.py +1037 -192
  65. package/config/skills/_shared/state-phase-write.py +2551 -83
  66. package/config/skills/_shared/terminal-claim-check.py +15 -0
  67. package/config/skills/_shared/verify-merge-findings.py +1127 -50
  68. package/config/skills/devlyn:engines/SKILL.md +8 -16
  69. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  70. package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
  71. package/config/skills/devlyn:queue/SKILL.md +7 -4
  72. package/config/skills/devlyn:resolve/SKILL.md +94 -35
  73. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  74. package/config/skills/devlyn:resolve/references/phases/build-gate.md +33 -4
  75. package/config/skills/devlyn:resolve/references/phases/implement.md +4 -3
  76. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  77. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  78. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -80
  79. package/config/skills/devlyn:resolve/references/state-schema.md +23 -9
  80. package/package.json +5 -1
  81. package/scripts/lint-fixtures.sh +4 -1
  82. package/scripts/lint-skills.sh +447 -290
@@ -31,10 +31,11 @@ Default mode (BUILD_GATE invocation, no args):
31
31
  mis-config). Real-user mode silent no-op + drops any stale
32
32
  pre-staged file (preserves iter-0019.6 backward compat for
33
33
  handwritten specs without the carrier).
34
- - For each verification_commands entry, runs the command in the work-dir,
35
- captures combined stdout+stderr, and asserts exit_code matches +
36
- stdout_contains all required literals + stdout_not_contains none of the
37
- forbidden literals. Mirrors run-fixture.sh's post-run verifier semantics.
34
+ - For each verification_commands entry and included risk probe, routes the
35
+ command through the sibling `process-evidence.py` runner. Raw stdout/stderr,
36
+ execution outcome, classification, and byte digests are written under the
37
+ authenticated run/phase/round manifest; expectations retain
38
+ run-fixture.sh's combined-stream matching semantics.
38
39
 
39
40
  Check mode (`--check <markdown_path>`):
40
41
  - Used by /devlyn:ideate after writing each item spec to validate that the
@@ -60,6 +61,8 @@ Output routing:
60
61
  `SPEC_VERIFY_FINDINGS_FILE=verify-mechanical.findings.jsonl`, and
61
62
  `SPEC_VERIFY_FINDING_PREFIX=VERIFY-MECH` so `verify-merge-findings.py` consumes
62
63
  deterministic blockers directly.
64
+ - `.devlyn/spec-verify.results.json` points each result at its sealed raw
65
+ streams and includes the validated process-evidence carrier.
63
66
 
64
67
  Why: iter-0018.5's prompt-only contract enforcement was empirically dead
65
68
  (F9 verify=0.4 across all engines in iter-0019). Same lesson as iter-0008
@@ -80,11 +83,13 @@ Exit codes:
80
83
  `SPEC_VERIFY_FINDINGS_FILE` (for example, VERIFY uses
81
84
  `.devlyn/verify-mechanical.findings.jsonl`).
82
85
  - 2: invocation error (unreadable spec-verify.json, missing markdown in
83
- --check mode, etc.)
86
+ --check mode, etc.) or explicit evidence-backed
87
+ `BLOCKED:build-env-underprovisioned` capability denial.
84
88
  """
85
89
 
86
90
  from __future__ import annotations
87
91
 
92
+ import importlib.util
88
93
  import json
89
94
  import hashlib
90
95
  import os
@@ -99,8 +104,21 @@ def reject_json_constant(token: str) -> None:
99
104
  raise ValueError(f"invalid JSON numeric constant: {token}")
100
105
 
101
106
 
107
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
108
+ result = {}
109
+ for key, value in pairs:
110
+ if key in result:
111
+ raise ValueError(f"duplicate JSON key: {key}")
112
+ result[key] = value
113
+ return result
114
+
115
+
102
116
  def loads_strict_json(text: str):
103
- return json.loads(text, parse_constant=reject_json_constant)
117
+ return json.loads(
118
+ text,
119
+ parse_constant=reject_json_constant,
120
+ object_pairs_hook=reject_duplicate_keys,
121
+ )
104
122
 
105
123
 
106
124
  def output_phase() -> str:
@@ -115,6 +133,95 @@ def output_finding_prefix() -> str:
115
133
  return os.environ.get("SPEC_VERIFY_FINDING_PREFIX", "BGATE")
116
134
 
117
135
 
136
+ _PROCESS_EVIDENCE_MODULE = None
137
+ RUNNER_ENV_KEYS = (
138
+ "SPEC_VERIFY_PHASE",
139
+ "SPEC_VERIFY_FINDINGS_FILE",
140
+ "SPEC_VERIFY_FINDING_PREFIX",
141
+ )
142
+
143
+
144
+ def process_evidence_module():
145
+ global _PROCESS_EVIDENCE_MODULE
146
+ if _PROCESS_EVIDENCE_MODULE is None:
147
+ module_path = Path(__file__).with_name("process-evidence.py")
148
+ spec = importlib.util.spec_from_file_location("devlyn_process_evidence", module_path)
149
+ if spec is None or spec.loader is None:
150
+ raise RuntimeError(f"cannot load process evidence runner: {module_path}")
151
+ module = importlib.util.module_from_spec(spec)
152
+ previous_bytecode_setting = sys.dont_write_bytecode
153
+ sys.dont_write_bytecode = True
154
+ try:
155
+ spec.loader.exec_module(module)
156
+ finally:
157
+ sys.dont_write_bytecode = previous_bytecode_setting
158
+ _PROCESS_EVIDENCE_MODULE = module
159
+ return _PROCESS_EVIDENCE_MODULE
160
+
161
+
162
+ def mechanical_evidence_identity(state: dict, runner) -> tuple[str, str, int, str]:
163
+ phase = "verify" if output_phase() == "verify_mechanical" else "build_gate"
164
+ run_id = state.get("run_id")
165
+ phase_state = (state.get("phases") or {}).get(phase)
166
+ round_ = phase_state.get("round") if isinstance(phase_state, dict) else None
167
+ invalid_identity = (
168
+ not isinstance(run_id, str)
169
+ or runner.ID_RE.fullmatch(run_id) is None
170
+ or isinstance(round_, bool)
171
+ or not isinstance(round_, int)
172
+ or round_ < 0
173
+ )
174
+ if invalid_identity and state.get("version") == "3.0":
175
+ raise runner.EvidenceError(
176
+ f"schema-v3 MECHANICAL evidence identity is invalid for phase {phase}"
177
+ )
178
+ if invalid_identity:
179
+ # Standalone/benchmark invocations predate pipeline run identity. Keep
180
+ # them evidence-backed without letting one process reuse another's
181
+ # manifest. Real pipeline runs always take the authenticated branch.
182
+ run_id = f"standalone-{os.getpid()}"
183
+ round_ = 0
184
+ identity = {"run_id": run_id, "phases": {phase: {"round": round_}}}
185
+ return phase, run_id, round_, runner.manifest_relative_path(identity, phase)
186
+
187
+
188
+ def mechanical_obligation(vc: dict, idx: int, phase: str) -> dict:
189
+ evidence_id = (
190
+ f"risk-probe-{idx + 1:04d}"
191
+ if vc.get("_risk_probe")
192
+ else f"verification-command-{idx + 1:04d}"
193
+ )
194
+ return {
195
+ "id": evidence_id,
196
+ "phase": phase,
197
+ "cmd": vc["cmd"],
198
+ "exit_code": vc.get("exit_code", 0),
199
+ "timeout_sec": verification_timeout_sec(vc),
200
+ "stdout_contains": vc.get("stdout_contains", []) or [],
201
+ "stdout_not_contains": vc.get("stdout_not_contains", []) or [],
202
+ }
203
+
204
+
205
+ def capture_mechanical_command(
206
+ runner, work: Path, manifest_path: Path, run_id: str, phase: str,
207
+ round_: int, obligation: dict,
208
+ ) -> dict:
209
+ saved = {key: os.environ.get(key) for key in (*RUNNER_ENV_KEYS, "BENCH_WORKDIR")}
210
+ for key in RUNNER_ENV_KEYS:
211
+ os.environ.pop(key, None)
212
+ os.environ["BENCH_WORKDIR"] = str(work)
213
+ try:
214
+ return runner.capture_process(
215
+ work, manifest_path, run_id, phase, round_, obligation,
216
+ )
217
+ finally:
218
+ for key, value in saved.items():
219
+ if value is None:
220
+ os.environ.pop(key, None)
221
+ else:
222
+ os.environ[key] = value
223
+
224
+
118
225
  VERIFICATION_SECTION_RE = re.compile(
119
226
  r'(?ms)^<!--[ \t]*devlyn:verification[ \t]*-->[ \t]*\n(#{1,6}[ \t]+[^\n]*\n.*?)(?=^#{1,6}[ \t]+|\Z)'
120
227
  )
@@ -127,8 +234,9 @@ FORBIDDEN_RISK_PROBE_CMD_RE = re.compile(
127
234
  )
128
235
  EXTERNAL_URL_RE = re.compile(r"https?://([^/\s\"']+)", re.IGNORECASE)
129
236
  RISK_PROBE_SCRIPT_RE = re.compile(
130
- r'(?<![\w./-])(\.devlyn/probes/[A-Za-z0-9][A-Za-z0-9._/-]*)'
237
+ r'(?<![\w./-])(?:\./)?(\.devlyn/probes/[A-Za-z0-9][A-Za-z0-9._/-]*)'
131
238
  )
239
+ RISK_PROBE_RAW_REF = ".devlyn/probes/"
132
240
  RISK_PROBE_INTEGRITY_FIX_HINT = (
133
241
  "Probe artifacts changed after PHASE 1.5. Only the orchestrator may regenerate probes: "
134
242
  "re-run probe validation and re-write state.risk_probes_digest; workers must never modify "
@@ -260,13 +368,19 @@ EXPECTED_TOP_LEVEL_KEYS = {
260
368
  EXPECTED_VERIFICATION_COMMAND_KEYS = {
261
369
  "cmd",
262
370
  "exit_code",
371
+ "timeout_sec",
263
372
  "stdout_contains",
264
373
  "stdout_not_contains",
265
374
  "contract_refs",
266
375
  }
376
+ DEFAULT_TIMEOUT_SEC = 60
267
377
  SPEC_COMPLEXITY_VALUES = {"trivial", "medium", "high", "large"}
268
378
 
269
379
 
380
+ def verification_timeout_sec(command: dict) -> int:
381
+ return command.get("timeout_sec", DEFAULT_TIMEOUT_SEC)
382
+
383
+
270
384
  def extract_verification_block(text: str) -> tuple[bool, str | None]:
271
385
  """Locate the verification section via the `<!-- devlyn:verification -->`
272
386
  sentinel (language-neutral — the human-readable heading after it may be
@@ -474,7 +588,28 @@ def referenced_risk_probe_scripts(cmd: str) -> list[str]:
474
588
  return scripts
475
589
 
476
590
 
591
+ def unrecognized_risk_probe_reference(cmd: str) -> str | None:
592
+ command = cmd or ""
593
+ matches = list(RISK_PROBE_SCRIPT_RE.finditer(command))
594
+ offset = command.find(RISK_PROBE_RAW_REF)
595
+ while offset >= 0:
596
+ if not any(
597
+ match.start() <= offset and offset + len(RISK_PROBE_RAW_REF) <= match.end()
598
+ for match in matches
599
+ ):
600
+ tail = command[offset:].split(maxsplit=1)[0][:80]
601
+ return (
602
+ f"unrecognized probe script reference {tail!r}; write it as "
603
+ ".devlyn/probes/<file> (a ./ prefix is the only alias)"
604
+ )
605
+ offset = command.find(RISK_PROBE_RAW_REF, offset + len(RISK_PROBE_RAW_REF))
606
+ return None
607
+
608
+
477
609
  def validate_risk_probe_scripts(cmd: str, index: int, work: Path) -> str | None:
610
+ unrecognized = unrecognized_risk_probe_reference(cmd)
611
+ if unrecognized:
612
+ return f"risk-probes[{index}].cmd has {unrecognized}"
478
613
  for rel_path in referenced_risk_probe_scripts(cmd):
479
614
  path = Path(rel_path)
480
615
  if ".." in path.parts:
@@ -534,6 +669,9 @@ def risk_probes_digest(devlyn_dir: Path) -> tuple[str | None, str | None]:
534
669
  except ValueError as e:
535
670
  return (None, f"risk-probes[{index}] invalid JSON: {e}")
536
671
  cmd = probe.get("cmd") if isinstance(probe, dict) else ""
672
+ unrecognized = unrecognized_risk_probe_reference(cmd)
673
+ if unrecognized:
674
+ return (None, f"risk-probes[{index}].cmd has {unrecognized}")
537
675
  scripts.update(referenced_risk_probe_scripts(cmd))
538
676
 
539
677
  digest = hashlib.sha256()
@@ -579,10 +717,12 @@ def validate_shape(data) -> str | None:
579
717
  Schema (iter-0019.8): top-level object with a non-empty
580
718
  `verification_commands` list of objects. Each object requires a
581
719
  non-empty string `cmd`; `exit_code` defaults to 0 and must be a
582
- non-bool int; `stdout_contains` and `stdout_not_contains` default to
583
- empty list and must be lists of strings. Bool is rejected explicitly
584
- because Python's `bool` subclasses `int` — `isinstance(True, int) is
585
- True` would otherwise let `exit_code: true` slip through.
720
+ non-bool int; `timeout_sec` defaults to DEFAULT_TIMEOUT_SEC and must be a
721
+ non-bool int from 1 through 600; `stdout_contains` and
722
+ `stdout_not_contains` default to empty list and must be lists of strings.
723
+ Bool is rejected explicitly because Python's `bool` subclasses `int` —
724
+ `isinstance(True, int) is True` would otherwise let numeric fields accept
725
+ true.
586
726
  """
587
727
  if not isinstance(data, dict):
588
728
  return "top-level must be a JSON object"
@@ -600,6 +740,13 @@ def validate_shape(data) -> str | None:
600
740
  ec = c.get("exit_code", 0)
601
741
  if isinstance(ec, bool) or not isinstance(ec, int):
602
742
  return f"verification_commands[{i}].exit_code must be int (not bool)"
743
+ timeout_sec = c.get("timeout_sec", DEFAULT_TIMEOUT_SEC)
744
+ if (
745
+ isinstance(timeout_sec, bool)
746
+ or not isinstance(timeout_sec, int)
747
+ or not 1 <= timeout_sec <= 600
748
+ ):
749
+ return f"verification_commands[{i}].timeout_sec must be int from 1 to 600 (not bool)"
603
750
  for k in ("stdout_contains", "stdout_not_contains"):
604
751
  v = c.get(k, [])
605
752
  if not isinstance(v, list) or not all(isinstance(s, str) for s in v):
@@ -810,7 +957,7 @@ def validate_risk_probe(
810
957
  if not isinstance(actual, list) or not all(isinstance(item, str) for item in actual):
811
958
  return f"risk-probes[{index}].tag_evidence.shape_contract must be a list of strings"
812
959
  required_shape = set(SHAPE_CONTRACT_REQUIRED_EVIDENCE)
813
- if {"error_contract", "http_error_contract"} & set(tags):
960
+ if "visible_text_names_exact_json_error_object" in set(actual):
814
961
  required_shape.add("asserts_exact_error_object")
815
962
  missing_shape = sorted(required_shape - set(actual))
816
963
  if missing_shape:
@@ -1100,7 +1247,13 @@ def stage_from_expected(
1100
1247
  return (True, True, None, expected_path, data)
1101
1248
 
1102
1249
 
1103
- def write_malformed_finding(devlyn_dir: Path, error: str, source_path: Path | None) -> None:
1250
+ def write_malformed_finding(
1251
+ devlyn_dir: Path,
1252
+ error: str,
1253
+ source_path: Path | None,
1254
+ *,
1255
+ fix_hint: str | None = None,
1256
+ ) -> None:
1104
1257
  """Emit a single CRITICAL finding for a malformed verification carrier."""
1105
1258
  devlyn_dir.mkdir(parents=True, exist_ok=True)
1106
1259
  findings_path = devlyn_dir / output_findings_name()
@@ -1116,7 +1269,7 @@ def write_malformed_finding(devlyn_dir: Path, error: str, source_path: Path | No
1116
1269
  "line": 1,
1117
1270
  "phase": output_phase(),
1118
1271
  "criterion_ref": "spec-verify://carrier",
1119
- "fix_hint": (
1272
+ "fix_hint": fix_hint if fix_hint is not None else (
1120
1273
  "Fix the sibling `spec.expected.json` file or the `## Verification` "
1121
1274
  "```json``` block: a JSON object with a non-empty `verification_commands` array of "
1122
1275
  "{cmd, exit_code?, stdout_contains?, stdout_not_contains?} "
@@ -1345,6 +1498,24 @@ def expected_contract_findings(
1345
1498
  return (findings, seq)
1346
1499
 
1347
1500
 
1501
+ def validate_surface_brace_glob(entry: str) -> str | None:
1502
+ if "{" not in entry and "}" not in entry:
1503
+ return None
1504
+ brace = re.search(r"\{([^{}]*)\}", entry)
1505
+ alternatives = brace.group(1).split(",") if brace else []
1506
+ if (
1507
+ entry.count("{") != 1
1508
+ or entry.count("}") != 1
1509
+ or len(alternatives) < 2
1510
+ or any(not value or any(char in value for char in "{},/*") for value in alternatives)
1511
+ ):
1512
+ return (
1513
+ f"unsupported brace glob {entry!r}; supported form is {{alt1,alt2,...}} "
1514
+ "with at least two non-empty plain alternatives"
1515
+ )
1516
+ return None
1517
+
1518
+
1348
1519
  def validate_authorized_surface_shape(data: object) -> str | None:
1349
1520
  if not isinstance(data, dict):
1350
1521
  return "top-level must be a JSON object"
@@ -1369,17 +1540,34 @@ def validate_authorized_surface_shape(data: object) -> str | None:
1369
1540
  return f"authorized_surface directory grant needs a non-empty prefix before '/**': {entry!r}"
1370
1541
  if ".." in stem.split("/"):
1371
1542
  return f"authorized_surface entry must not contain '..': {entry!r}"
1543
+ brace_error = validate_surface_brace_glob(entry)
1544
+ if brace_error is not None:
1545
+ return brace_error
1372
1546
  return None
1373
1547
 
1374
1548
 
1375
1549
  def path_matches_surface(path: str, surface: list[str]) -> bool:
1376
1550
  for entry in surface:
1377
- if entry.endswith("/**"):
1378
- prefix = entry[:-3]
1379
- if path == prefix or path.startswith(prefix + "/"):
1551
+ brace_error = validate_surface_brace_glob(entry)
1552
+ if brace_error is not None:
1553
+ raise ValueError(brace_error)
1554
+ if "{" in entry:
1555
+ brace = re.search(r"\{([^{}]*)\}", entry)
1556
+ assert brace is not None
1557
+ alternatives = brace.group(1).split(",")
1558
+ entries = tuple(
1559
+ entry[:brace.start()] + value + entry[brace.end():]
1560
+ for value in alternatives
1561
+ )
1562
+ else:
1563
+ entries = (entry,)
1564
+ for expanded in entries:
1565
+ if expanded.endswith("/**"):
1566
+ prefix = expanded[:-3].rstrip("/")
1567
+ if path == prefix or path.startswith(f"{prefix}/"):
1568
+ return True
1569
+ elif path == expanded:
1380
1570
  return True
1381
- elif path == entry:
1382
- return True
1383
1571
  return False
1384
1572
 
1385
1573
 
@@ -1738,11 +1926,67 @@ def run_check_expected_mode(expected_path: Path) -> int:
1738
1926
 
1739
1927
 
1740
1928
  def run_self_test() -> int:
1929
+ try:
1930
+ loads_strict_json('{"verification_commands":[],"verification_commands":[{}]}')
1931
+ except ValueError as exc:
1932
+ assert "duplicate JSON key" in str(exc)
1933
+ else:
1934
+ raise AssertionError("duplicate expected-contract key was accepted")
1935
+ # Hermeticity: a pipeline replay exports BENCH_WORKDIR at the live repo;
1936
+ # inherited into scenario children it wins over their tmp cwd (the
1937
+ # default-mode work resolution below) and re-executes the LIVE
1938
+ # .devlyn/spec-verify.json — with lint as the live command this mutually
1939
+ # recursed (lint runs this self-test) into an unbounded spawn storm
1940
+ # (observed 2026-08-03, iter-0089 canary).
1941
+ os.environ.pop("BENCH_WORKDIR", None)
1741
1942
  script_path = str(Path(__file__).resolve())
1943
+ with tempfile.TemporaryDirectory() as help_tmp:
1944
+ for help_flag in ("--help", "-h"):
1945
+ help_result = subprocess.run(
1946
+ [sys.executable, script_path, help_flag],
1947
+ cwd=help_tmp,
1948
+ capture_output=True,
1949
+ text=True,
1950
+ )
1951
+ if help_result.returncode != 0 or "usage:" not in help_result.stdout:
1952
+ print(f"{help_flag} did not return usage successfully", file=sys.stderr)
1953
+ return 1
1954
+ if (Path(help_tmp) / ".devlyn").exists():
1955
+ print(f"{help_flag} mutated .devlyn", file=sys.stderr)
1956
+ return 1
1957
+ unknown_result = subprocess.run(
1958
+ [sys.executable, script_path, "--not-a-real-option"],
1959
+ cwd=help_tmp,
1960
+ capture_output=True,
1961
+ text=True,
1962
+ )
1963
+ if unknown_result.returncode != 2 or "unknown argument" not in unknown_result.stderr:
1964
+ print("unknown option did not fail closed", file=sys.stderr)
1965
+ return 1
1966
+ if (Path(help_tmp) / ".devlyn").exists():
1967
+ print("unknown option mutated .devlyn", file=sys.stderr)
1968
+ return 1
1969
+ runner = process_evidence_module()
1970
+ try:
1971
+ mechanical_evidence_identity({"version": "3.0", "phases": {}}, runner)
1972
+ except runner.EvidenceError as exc:
1973
+ assert "schema-v3 MECHANICAL evidence identity is invalid" in str(exc)
1974
+ else:
1975
+ raise AssertionError("schema-v3 MECHANICAL evidence accepted missing run identity")
1742
1976
  with tempfile.TemporaryDirectory() as td:
1743
1977
  work = Path(td)
1744
1978
  devlyn = work / ".devlyn"
1745
1979
  devlyn.mkdir()
1980
+ assert path_matches_surface("src/a/example.py", ["src/{a,b}/**"])
1981
+ assert path_matches_surface("src/b/example.py", ["src/{a,b}/**"])
1982
+ assert not path_matches_surface("src/c/example.py", ["src/{a,b}/**"])
1983
+ for entry in ("src/{a,b", "src/{a,}/**", "src/{a,{b,c}}", "src/{a,**}"):
1984
+ try:
1985
+ path_matches_surface("src/a/example.py", [entry])
1986
+ except ValueError as exc:
1987
+ assert entry in str(exc) and "supported form" in str(exc)
1988
+ else:
1989
+ raise AssertionError(f"malformed brace glob accepted: {entry}")
1746
1990
  spec_md = work / "spec.md"
1747
1991
  spec_md.write_text("# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n- probe must pass visible marker.\n")
1748
1992
  (devlyn / "pipeline.state.json").write_text(json.dumps({
@@ -1762,6 +2006,7 @@ def run_self_test() -> int:
1762
2006
  "derived_from": "probe must pass visible marker.",
1763
2007
  "cmd": "python3 .devlyn/probes/P1.py",
1764
2008
  "exit_code": 0,
2009
+ "timeout_sec": 5,
1765
2010
  "stdout_contains": ["probe-ok"],
1766
2011
  "stdout_not_contains": [],
1767
2012
  "tags": ["shape_contract"],
@@ -1774,6 +2019,264 @@ def run_self_test() -> int:
1774
2019
  },
1775
2020
  }
1776
2021
  (devlyn / "risk-probes.jsonl").write_text(json.dumps(risk_probe_payload) + "\n")
2022
+ loaded_probes, loaded_probe_error = load_risk_probes(
2023
+ devlyn, spec_md, require_present=True
2024
+ )
2025
+ if loaded_probe_error or loaded_probes[0].get("timeout_sec") != 5:
2026
+ print("risk-probe timeout_sec was not preserved", file=sys.stderr)
2027
+ print(loaded_probe_error, file=sys.stderr)
2028
+ return 1
2029
+
2030
+ if verification_timeout_sec({"cmd": "printf default"}) != DEFAULT_TIMEOUT_SEC:
2031
+ print("absent timeout_sec did not resolve to DEFAULT_TIMEOUT_SEC", file=sys.stderr)
2032
+ return 1
2033
+
2034
+ inline_timeout_spec = work / "inline-timeout-spec.md"
2035
+ inline_timeout_devlyn = work / "inline-timeout-devlyn"
2036
+ for invalid_timeout in (True, 0, -1, 601, "60"):
2037
+ inline_timeout_spec.write_text(
2038
+ "# Timeout validation\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n"
2039
+ + json.dumps({
2040
+ "verification_commands": [
2041
+ {"cmd": "printf ok", "timeout_sec": invalid_timeout}
2042
+ ]
2043
+ })
2044
+ + "\n```\n",
2045
+ encoding="utf-8",
2046
+ )
2047
+ staged, inline_error = stage_from_source(
2048
+ inline_timeout_spec, inline_timeout_devlyn
2049
+ )
2050
+ if staged or not inline_error or "timeout_sec" not in inline_error:
2051
+ print(
2052
+ f"inline carrier accepted invalid timeout_sec={invalid_timeout!r}",
2053
+ file=sys.stderr,
2054
+ )
2055
+ return 1
2056
+
2057
+ inline_timeout_spec.write_text(
2058
+ "# Timeout preservation\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n"
2059
+ + json.dumps({
2060
+ "verification_commands": [
2061
+ {"cmd": "printf ok", "timeout_sec": 5}
2062
+ ]
2063
+ })
2064
+ + "\n```\n",
2065
+ encoding="utf-8",
2066
+ )
2067
+ staged, inline_error = stage_from_source(
2068
+ inline_timeout_spec, inline_timeout_devlyn
2069
+ )
2070
+ inline_staged = loads_strict_json(
2071
+ (inline_timeout_devlyn / "spec-verify.json").read_text()
2072
+ )
2073
+ if (
2074
+ not staged
2075
+ or inline_error
2076
+ or inline_staged["verification_commands"][0].get("timeout_sec") != 5
2077
+ ):
2078
+ print("inline carrier did not preserve timeout_sec", file=sys.stderr)
2079
+ return 1
2080
+
2081
+ sibling_timeout_root = work / "sibling-timeout"
2082
+ sibling_timeout_root.mkdir()
2083
+ sibling_timeout_devlyn = sibling_timeout_root / ".devlyn"
2084
+ sibling_timeout_spec = sibling_timeout_root / "spec.md"
2085
+ sibling_timeout_spec.write_text("# Sibling timeout\n", encoding="utf-8")
2086
+ sibling_timeout_expected = sibling_timeout_root / "spec.expected.json"
2087
+ for invalid_timeout in (True, 601):
2088
+ sibling_timeout_expected.write_text(json.dumps({
2089
+ "verification_commands": [
2090
+ {"cmd": "printf ok", "timeout_sec": invalid_timeout}
2091
+ ]
2092
+ }) + "\n")
2093
+ found, staged, sibling_error, _path, _data = stage_from_expected(
2094
+ sibling_timeout_spec, sibling_timeout_devlyn
2095
+ )
2096
+ if not found or staged or not sibling_error or "timeout_sec" not in sibling_error:
2097
+ print(
2098
+ f"sibling carrier accepted invalid timeout_sec={invalid_timeout!r}",
2099
+ file=sys.stderr,
2100
+ )
2101
+ return 1
2102
+
2103
+ sibling_timeout_expected.write_text(json.dumps({
2104
+ "verification_commands": [
2105
+ {"cmd": "printf ok", "timeout_sec": 5}
2106
+ ]
2107
+ }) + "\n")
2108
+ found, staged, sibling_error, _path, _data = stage_from_expected(
2109
+ sibling_timeout_spec, sibling_timeout_devlyn
2110
+ )
2111
+ sibling_staged = loads_strict_json(
2112
+ (sibling_timeout_devlyn / "spec-verify.json").read_text()
2113
+ )
2114
+ if (
2115
+ not found
2116
+ or not staged
2117
+ or sibling_error
2118
+ or sibling_staged["verification_commands"][0].get("timeout_sec") != 5
2119
+ ):
2120
+ print("sibling carrier did not preserve timeout_sec", file=sys.stderr)
2121
+ return 1
2122
+
2123
+ timeout_run_root = work / "timeout-run"
2124
+ timeout_run_root.mkdir()
2125
+ timeout_run_devlyn = timeout_run_root / ".devlyn"
2126
+ timeout_run_devlyn.mkdir()
2127
+ timeout_run_spec = timeout_run_root / "spec.md"
2128
+ timeout_run_spec.write_text(
2129
+ "# Timeout execution\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n"
2130
+ + json.dumps({
2131
+ "verification_commands": [
2132
+ {
2133
+ "cmd": "python3 -c \"import time; time.sleep(2)\"",
2134
+ "timeout_sec": 1,
2135
+ },
2136
+ {
2137
+ "cmd": "python3 -c \"import time; time.sleep(1)\"",
2138
+ "timeout_sec": 5,
2139
+ },
2140
+ {
2141
+ "cmd": "printf 'Operation not permitted' >&2; exit 1",
2142
+ },
2143
+ ]
2144
+ })
2145
+ + "\n```\n",
2146
+ encoding="utf-8",
2147
+ )
2148
+ (timeout_run_devlyn / "pipeline.state.json").write_text(json.dumps({
2149
+ "run_id": "rs-timeout-run",
2150
+ "source": {"type": "spec", "spec_path": str(timeout_run_spec)},
2151
+ "phases": {"build_gate": {"round": 2}},
2152
+ }))
2153
+ timeout_run = subprocess.run(
2154
+ [sys.executable, script_path],
2155
+ cwd=timeout_run_root,
2156
+ capture_output=True,
2157
+ text=True,
2158
+ )
2159
+ if timeout_run.returncode == 0:
2160
+ print("declared one-second timeout did not fail", file=sys.stderr)
2161
+ return 1
2162
+ timeout_document = loads_strict_json(
2163
+ (timeout_run_devlyn / "spec-verify.results.json").read_text()
2164
+ )
2165
+ timeout_results = timeout_document["commands"]
2166
+ timeout_findings = [
2167
+ loads_strict_json(line)
2168
+ for line in (timeout_run_devlyn / output_findings_name()).read_text().splitlines()
2169
+ if line.strip()
2170
+ ]
2171
+ if (
2172
+ timeout_results[0].get("reason") != "timeout"
2173
+ or timeout_results[0].get("timeout_sec") != 1
2174
+ or not timeout_results[1].get("pass")
2175
+ or timeout_results[2].get("reason") != "exit"
2176
+ or timeout_results[2].get("classification") != {
2177
+ "kind": "product_result", "operation": None,
2178
+ }
2179
+ ):
2180
+ print("declared timeout budgets were not honored", file=sys.stderr)
2181
+ print(timeout_results, file=sys.stderr)
2182
+ return 1
2183
+ timeout_carrier = timeout_document.get("process_evidence") or {}
2184
+ timeout_manifest_path = (
2185
+ ".devlyn/process-evidence/rs-timeout-run/build_gate/round-2/manifest.json"
2186
+ )
2187
+ timeout_manifest = loads_strict_json(
2188
+ (timeout_run_root / timeout_manifest_path).read_text()
2189
+ )
2190
+ if (
2191
+ timeout_carrier.get("manifest", {}).get("path") != timeout_manifest_path
2192
+ or [entry.get("id") for entry in timeout_manifest.get("entries", [])] != [
2193
+ "verification-command-0001",
2194
+ "verification-command-0002",
2195
+ "verification-command-0003",
2196
+ ]
2197
+ or any(
2198
+ not (timeout_run_root / entry[stream]["path"]).is_file()
2199
+ for entry in timeout_manifest.get("entries", [])
2200
+ for stream in ("stdout", "stderr")
2201
+ )
2202
+ ):
2203
+ print("literal commands did not emit sealed BUILD_GATE evidence", file=sys.stderr)
2204
+ print(timeout_document, file=sys.stderr)
2205
+ return 1
2206
+ timeout_finding = timeout_findings[0] if timeout_findings else {}
2207
+ if (
2208
+ timeout_finding.get("rule_id") != "correctness.verification-timeout"
2209
+ or "after 1s" not in timeout_finding.get("message", "")
2210
+ or "timeout_sec" not in timeout_finding.get("message", "")
2211
+ or "600" not in timeout_finding.get("fix_hint", "")
2212
+ ):
2213
+ print("timeout finding did not carry the distinct budget contract", file=sys.stderr)
2214
+ print(timeout_finding, file=sys.stderr)
2215
+ return 1
2216
+ if (
2217
+ not any(
2218
+ finding.get("rule_id") == "correctness.spec-literal-mismatch"
2219
+ for finding in timeout_findings
2220
+ )
2221
+ or "BLOCKED:build-env-underprovisioned" in timeout_run.stderr
2222
+ ):
2223
+ print("ordinary restricted-looking stderr did not remain a product finding", file=sys.stderr)
2224
+ print(timeout_findings, file=sys.stderr)
2225
+ print(timeout_run.stderr, file=sys.stderr)
2226
+ return 1
2227
+
2228
+ runner_env_root = work / "runner-env-isolation"
2229
+ runner_env_root.mkdir()
2230
+ runner_env_devlyn = runner_env_root / ".devlyn"
2231
+ runner_env_devlyn.mkdir()
2232
+ runner_env_spec = runner_env_root / "spec.md"
2233
+ runner_env_spec.write_text(
2234
+ "# Runner environment isolation\n\n<!-- devlyn:verification -->\n"
2235
+ "## Verification\n\n```json\n"
2236
+ + json.dumps({
2237
+ "verification_commands": [{
2238
+ "cmd": (
2239
+ "python3 -c \"import os, sys; sys.exit(any(name in os.environ "
2240
+ "for name in ('SPEC_VERIFY_PHASE', 'SPEC_VERIFY_FINDINGS_FILE', "
2241
+ "'SPEC_VERIFY_FINDING_PREFIX')))\""
2242
+ )
2243
+ }]
2244
+ })
2245
+ + "\n```\n",
2246
+ encoding="utf-8",
2247
+ )
2248
+ (runner_env_devlyn / "pipeline.state.json").write_text(json.dumps({
2249
+ "run_id": "rs-runner-env",
2250
+ "source": {"type": "spec", "spec_path": str(runner_env_spec)},
2251
+ "phases": {"verify": {"round": 3}},
2252
+ }))
2253
+ runner_env = os.environ.copy()
2254
+ runner_env.update({
2255
+ "SPEC_VERIFY_PHASE": "verify_mechanical",
2256
+ "SPEC_VERIFY_FINDINGS_FILE": "runner-env.findings.jsonl",
2257
+ "SPEC_VERIFY_FINDING_PREFIX": "RUNNER-ENV",
2258
+ })
2259
+ runner_env_run = subprocess.run(
2260
+ [sys.executable, script_path],
2261
+ cwd=runner_env_root,
2262
+ env=runner_env,
2263
+ capture_output=True,
2264
+ text=True,
2265
+ )
2266
+ runner_env_document = loads_strict_json(
2267
+ (runner_env_devlyn / "spec-verify.results.json").read_text()
2268
+ )
2269
+ runner_env_results = runner_env_document["commands"]
2270
+ if runner_env_run.returncode != 0 or not runner_env_results[0].get("pass"):
2271
+ print("runner-directed environment leaked into a verification command", file=sys.stderr)
2272
+ print(runner_env_run.stderr, file=sys.stderr)
2273
+ return 1
2274
+ if runner_env_document.get("process_evidence", {}).get("manifest", {}).get("path") != (
2275
+ ".devlyn/process-evidence/rs-runner-env/verify/round-3/manifest.json"
2276
+ ):
2277
+ print("VERIFY MECHANICAL evidence used the wrong phase identity", file=sys.stderr)
2278
+ print(runner_env_document, file=sys.stderr)
2279
+ return 1
1777
2280
  env = os.environ.copy()
1778
2281
  env["BENCH_WORKDIR"] = str(work)
1779
2282
  validate_without_digest = subprocess.run(
@@ -1804,9 +2307,11 @@ def run_self_test() -> int:
1804
2307
  print(repr(digest_run.stdout), file=sys.stderr)
1805
2308
  return 1
1806
2309
  (devlyn / "pipeline.state.json").write_text(json.dumps({
2310
+ "run_id": "rs-risk-probes",
1807
2311
  "source": {"type": "spec", "spec_path": str(spec_md)},
1808
2312
  "risk_profile": {"risk_probes_enabled": True},
1809
2313
  "risk_probes_digest": risk_digest,
2314
+ "phases": {"build_gate": {"round": 4}},
1810
2315
  }))
1811
2316
  good = subprocess.run(
1812
2317
  [sys.executable, script_path, "--include-risk-probes"],
@@ -1818,6 +2323,24 @@ def run_self_test() -> int:
1818
2323
  if good.returncode != 0:
1819
2324
  print(good.stderr, file=sys.stderr)
1820
2325
  return 1
2326
+ good_document = loads_strict_json(
2327
+ (devlyn / "spec-verify.results.json").read_text()
2328
+ )
2329
+ good_manifest_path = good_document.get("process_evidence", {}).get("manifest", {}).get("path")
2330
+ good_manifest = (
2331
+ loads_strict_json((work / good_manifest_path).read_text())
2332
+ if isinstance(good_manifest_path, str)
2333
+ else {}
2334
+ )
2335
+ if (
2336
+ good_manifest_path != ".devlyn/process-evidence/rs-risk-probes/build_gate/round-4/manifest.json"
2337
+ or [entry.get("id") for entry in good_manifest.get("entries", [])] != [
2338
+ "verification-command-0001", "risk-probe-0002",
2339
+ ]
2340
+ ):
2341
+ print("literal command and risk probe did not share the sealed manifest", file=sys.stderr)
2342
+ print(good_document, file=sys.stderr)
2343
+ return 1
1821
2344
 
1822
2345
  probe_script.write_text("print('mutated-probe')\n", encoding="utf-8")
1823
2346
  mutated_script = subprocess.run(
@@ -1830,7 +2353,7 @@ def run_self_test() -> int:
1830
2353
  if mutated_script.returncode == 0:
1831
2354
  print("--include-risk-probes accepted mutated probe script bytes", file=sys.stderr)
1832
2355
  return 1
1833
- integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
2356
+ integrity_findings = (devlyn / output_findings_name()).read_text(encoding="utf-8")
1834
2357
  if "correctness.risk-probe-integrity" not in integrity_findings:
1835
2358
  print("mutated probe script did not emit correctness.risk-probe-integrity", file=sys.stderr)
1836
2359
  print(integrity_findings, file=sys.stderr)
@@ -1850,7 +2373,7 @@ def run_self_test() -> int:
1850
2373
  if mutated_jsonl.returncode == 0:
1851
2374
  print("--include-risk-probes accepted mutated risk-probes.jsonl bytes", file=sys.stderr)
1852
2375
  return 1
1853
- integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
2376
+ integrity_findings = (devlyn / output_findings_name()).read_text(encoding="utf-8")
1854
2377
  if "correctness.risk-probe-integrity" not in integrity_findings:
1855
2378
  print("mutated risk-probes.jsonl did not emit correctness.risk-probe-integrity", file=sys.stderr)
1856
2379
  print(integrity_findings, file=sys.stderr)
@@ -1871,7 +2394,7 @@ def run_self_test() -> int:
1871
2394
  if missing_digest.returncode == 0:
1872
2395
  print("--include-risk-probes accepted enabled risk probes with missing digest", file=sys.stderr)
1873
2396
  return 1
1874
- integrity_findings = (devlyn / "spec-verify-findings.jsonl").read_text(encoding="utf-8")
2397
+ integrity_findings = (devlyn / output_findings_name()).read_text(encoding="utf-8")
1875
2398
  if "correctness.risk-probe-integrity" not in integrity_findings:
1876
2399
  print("missing risk_probes_digest did not emit correctness.risk-probe-integrity", file=sys.stderr)
1877
2400
  print(integrity_findings, file=sys.stderr)
@@ -2196,22 +2719,13 @@ def run_self_test() -> int:
2196
2719
  probes_dir = devlyn / "probes"
2197
2720
  probes_dir.mkdir(exist_ok=True)
2198
2721
  (probes_dir / "Pscript.py").write_text("print('script-ok')\n", encoding="utf-8")
2199
- (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2722
+ script_probe_payload = {
2723
+ **risk_probe_payload,
2200
2724
  "id": "Pscript",
2201
- "derived_from": "probe must pass visible marker.",
2202
2725
  "cmd": "python3 .devlyn/probes/Pscript.py",
2203
- "exit_code": 0,
2204
2726
  "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")
2727
+ }
2728
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps(script_probe_payload) + "\n")
2215
2729
  good_script_probe = subprocess.run(
2216
2730
  [sys.executable, script_path, "--validate-risk-probes"],
2217
2731
  cwd=work,
@@ -2224,10 +2738,47 @@ def run_self_test() -> int:
2224
2738
  print(good_script_probe.stderr, file=sys.stderr)
2225
2739
  return 1
2226
2740
 
2741
+ mixed_script_payload = {
2742
+ **script_probe_payload,
2743
+ "id": "Pmixed",
2744
+ "cmd": "python3 .devlyn/probes/Pscript.py && python3 ./.devlyn/probes/P1.py",
2745
+ }
2746
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps(mixed_script_payload) + "\n")
2747
+ mixed_script_probe = subprocess.run(
2748
+ [sys.executable, script_path, "--validate-risk-probes"],
2749
+ cwd=work,
2750
+ env=env,
2751
+ capture_output=True,
2752
+ text=True,
2753
+ )
2754
+ if mixed_script_probe.returncode != 0:
2755
+ print("canonical and ./-alias probe script references were rejected", file=sys.stderr)
2756
+ print(mixed_script_probe.stderr, file=sys.stderr)
2757
+ return 1
2758
+ mixed_digest, mixed_digest_error = risk_probes_digest(devlyn)
2759
+ if mixed_digest_error:
2760
+ print(mixed_digest_error, file=sys.stderr)
2761
+ return 1
2762
+ (probes_dir / "Pscript.py").write_text("print('script-mutated')\n", encoding="utf-8")
2763
+ canonical_mutated_digest, canonical_mutated_error = risk_probes_digest(devlyn)
2764
+ (probes_dir / "Pscript.py").write_text("print('script-ok')\n", encoding="utf-8")
2765
+ if canonical_mutated_error:
2766
+ print(canonical_mutated_error, file=sys.stderr)
2767
+ return 1
2768
+ probe_script.write_text("print('probe-mutated')\n", encoding="utf-8")
2769
+ alias_mutated_digest, alias_mutated_error = risk_probes_digest(devlyn)
2770
+ probe_script.write_text("print('probe-ok')\n", encoding="utf-8")
2771
+ if alias_mutated_error:
2772
+ print(alias_mutated_error, file=sys.stderr)
2773
+ return 1
2774
+ if len({mixed_digest, canonical_mutated_digest, alias_mutated_digest}) != 3:
2775
+ print("probe digest did not change for both canonical and ./-alias scripts", file=sys.stderr)
2776
+ return 1
2777
+
2227
2778
  (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2228
2779
  "id": "Pmissing",
2229
2780
  "derived_from": "probe must pass visible marker.",
2230
- "cmd": "python3 .devlyn/probes/missing.py",
2781
+ "cmd": "python3 ./.devlyn/probes/missing.py",
2231
2782
  "exit_code": 0,
2232
2783
  }) + "\n")
2233
2784
  missing_script_probe = subprocess.run(
@@ -2260,6 +2811,45 @@ def run_self_test() -> int:
2260
2811
  print(missing_script_digest.stderr, file=sys.stderr)
2261
2812
  return 1
2262
2813
 
2814
+ for bad_form in (
2815
+ "../.devlyn/probes/Pscript.py",
2816
+ "/tmp/.devlyn/probes/Pscript.py",
2817
+ "$PWD/.devlyn/probes/Pscript.py",
2818
+ "././.devlyn/probes/Pscript.py",
2819
+ ):
2820
+ (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2821
+ "id": "Pbadref",
2822
+ "derived_from": "probe must pass visible marker.",
2823
+ "cmd": f"python3 {bad_form}",
2824
+ "exit_code": 0,
2825
+ }) + "\n")
2826
+ bad_ref_probe = subprocess.run(
2827
+ [sys.executable, script_path, "--validate-risk-probes"],
2828
+ cwd=work,
2829
+ env=env,
2830
+ capture_output=True,
2831
+ text=True,
2832
+ )
2833
+ bad_ref_digest = subprocess.run(
2834
+ [sys.executable, script_path, "--print-risk-probes-digest"],
2835
+ cwd=work,
2836
+ env=env,
2837
+ capture_output=True,
2838
+ text=True,
2839
+ )
2840
+ unrecognized = unrecognized_risk_probe_reference(f"python3 {bad_form}")
2841
+ expected_error = f"risk-probes[0].cmd has {unrecognized}"
2842
+ if (
2843
+ bad_ref_probe.returncode == 0
2844
+ or expected_error not in bad_ref_probe.stderr
2845
+ or bad_ref_digest.returncode == 0
2846
+ or expected_error not in bad_ref_digest.stderr
2847
+ ):
2848
+ print(f"bad probe script reference was not rejected: {bad_form}", file=sys.stderr)
2849
+ print(bad_ref_probe.stderr, file=sys.stderr)
2850
+ print(bad_ref_digest.stderr, file=sys.stderr)
2851
+ return 1
2852
+
2263
2853
  (probes_dir / "Phidden.py").write_text("print('benchmark/auto-resolve/fixtures')\n", encoding="utf-8")
2264
2854
  (devlyn / "risk-probes.jsonl").write_text(json.dumps({
2265
2855
  "id": "Phidden",
@@ -2810,6 +3400,103 @@ def run_self_test() -> int:
2810
3400
  print(expected_nan_json.stderr, file=sys.stderr)
2811
3401
  return 1
2812
3402
 
3403
+ external_diff_root = work / "external-diff-mode-authority"
3404
+ external_diff_root.mkdir()
3405
+ external_diff_devlyn = external_diff_root / ".devlyn"
3406
+ external_diff_devlyn.mkdir()
3407
+ external_diff_spec = external_diff_root / "spec.md"
3408
+ external_diff_spec.write_text(
3409
+ "# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n"
3410
+ "- external diff remains verify-only.\n",
3411
+ encoding="utf-8",
3412
+ )
3413
+ (external_diff_root / "spec.expected.json").write_text(json.dumps({
3414
+ "verification_commands": [
3415
+ {"cmd": "printf external-diff-ok", "stdout_contains": ["external-diff-ok"]}
3416
+ ]
3417
+ }) + "\n", encoding="utf-8")
3418
+ (external_diff_root / "external-only.txt").write_text("base\n", encoding="utf-8")
3419
+ (external_diff_root / "outside.txt").write_text("base\n", encoding="utf-8")
3420
+ subprocess.run(["git", "init", "-q"], cwd=external_diff_root, check=True)
3421
+ subprocess.run(["git", "add", "-A"], cwd=external_diff_root, check=True)
3422
+ subprocess.run(
3423
+ ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "base"],
3424
+ cwd=external_diff_root,
3425
+ check=True,
3426
+ )
3427
+ external_diff_base_sha = subprocess.check_output(
3428
+ ["git", "rev-parse", "HEAD"], cwd=external_diff_root, text=True,
3429
+ ).strip()
3430
+ (external_diff_root / "outside.txt").write_text("worktree-only\n", encoding="utf-8")
3431
+ (external_diff_devlyn / "external-diff.patch").write_text(
3432
+ "diff --git a/external-only.txt b/external-only.txt\n"
3433
+ "--- a/external-only.txt\n"
3434
+ "+++ b/external-only.txt\n"
3435
+ "@@ -1 +1 @@\n"
3436
+ "-base\n"
3437
+ "+external\n",
3438
+ encoding="utf-8",
3439
+ )
3440
+ (external_diff_devlyn / "plan.md").write_text(
3441
+ "<!-- devlyn:authorized-surface -->\n## Files to touch\n\n"
3442
+ "```json\n{\"authorized_surface\": [\"external-only.txt\"]}\n```\n",
3443
+ encoding="utf-8",
3444
+ )
3445
+ (external_diff_devlyn / "untracked.baseline").write_text("", encoding="utf-8")
3446
+ external_diff_state = {
3447
+ "mode": "free-form",
3448
+ "source": {"type": "spec", "spec_path": str(external_diff_spec)},
3449
+ "base_ref": {"sha": external_diff_base_sha},
3450
+ }
3451
+ external_diff_state_path = external_diff_devlyn / "pipeline.state.json"
3452
+ external_diff_state_path.write_text(json.dumps(external_diff_state) + "\n")
3453
+ external_diff_free_form = subprocess.run(
3454
+ [sys.executable, script_path],
3455
+ cwd=external_diff_root,
3456
+ capture_output=True,
3457
+ text=True,
3458
+ )
3459
+ external_diff_findings_path = external_diff_devlyn / output_findings_name()
3460
+ external_diff_free_form_findings = (
3461
+ external_diff_findings_path.read_text()
3462
+ if external_diff_findings_path.is_file()
3463
+ else ""
3464
+ )
3465
+ external_diff_state["mode"] = "verify-only"
3466
+ external_diff_state_path.write_text(json.dumps(external_diff_state) + "\n")
3467
+ external_diff_verify_only = subprocess.run(
3468
+ [sys.executable, script_path],
3469
+ cwd=external_diff_root,
3470
+ capture_output=True,
3471
+ text=True,
3472
+ )
3473
+ if external_diff_free_form.returncode != 1:
3474
+ print("non-verify-only mode accepted .devlyn/external-diff.patch", file=sys.stderr)
3475
+ print(external_diff_free_form.stderr, file=sys.stderr)
3476
+ return 1
3477
+ if (
3478
+ '"rule_id": "correctness.spec-verify-malformed"'
3479
+ not in external_diff_free_form_findings
3480
+ or '"severity": "CRITICAL"' not in external_diff_free_form_findings
3481
+ or ".devlyn/external-diff.patch" not in external_diff_free_form_findings
3482
+ or "free-form" not in external_diff_free_form_findings
3483
+ or "verify-only" not in external_diff_free_form_findings
3484
+ or "Remove `.devlyn/external-diff.patch` for ordinary runs"
3485
+ not in external_diff_free_form_findings
3486
+ or "only when intentionally verifying an external patch"
3487
+ not in external_diff_free_form_findings
3488
+ ):
3489
+ print(
3490
+ "non-verify-only external diff did not emit the named CRITICAL finding and remediation",
3491
+ file=sys.stderr,
3492
+ )
3493
+ print(external_diff_free_form_findings, file=sys.stderr)
3494
+ return 1
3495
+ if external_diff_verify_only.returncode != 0:
3496
+ print("verify-only external diff was rejected or not consumed", file=sys.stderr)
3497
+ print(external_diff_verify_only.stderr, file=sys.stderr)
3498
+ return 1
3499
+
2813
3500
  spec_integrity = work / "spec-integrity"
2814
3501
  spec_integrity.mkdir()
2815
3502
  spec_integrity_devlyn = spec_integrity / ".devlyn"
@@ -3132,7 +3819,7 @@ def run_self_test() -> int:
3132
3819
  if contract_run.returncode == 0:
3133
3820
  print("expected contract violations were accepted", file=sys.stderr)
3134
3821
  return 1
3135
- findings_text = (contract_devlyn / "spec-verify-findings.jsonl").read_text()
3822
+ findings_text = (contract_devlyn / output_findings_name()).read_text()
3136
3823
  for rule_id in (
3137
3824
  "correctness.forbidden-pattern",
3138
3825
  "correctness.required-file-missing",
@@ -3169,7 +3856,8 @@ def run_self_test() -> int:
3169
3856
  error_spec = error_root / "spec.md"
3170
3857
  error_spec.write_text(
3171
3858
  "# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n"
3172
- "- Invalid input must print a JSON error object to stderr and exit 2.\n"
3859
+ "- Invalid input must print JSON error object `{ \"error\": \"bad_input\" }` to stderr and exit 2.\n"
3860
+ "- Malformed input must exit 2 and print stderr JSON with keys `code` and `detail`; values are implementation-defined.\n"
3173
3861
  )
3174
3862
  (error_devlyn / "pipeline.state.json").write_text(json.dumps({
3175
3863
  "source": {"type": "spec", "spec_path": str(error_spec)}
@@ -3221,7 +3909,7 @@ def run_self_test() -> int:
3221
3909
 
3222
3910
  (error_devlyn / "risk-probes.jsonl").write_text(json.dumps({
3223
3911
  "id": "P7c",
3224
- "derived_from": "Invalid input must print a JSON error object to stderr and exit 2.",
3912
+ "derived_from": "Invalid input must print JSON error object `{ \"error\": \"bad_input\" }` to stderr and exit 2.",
3225
3913
  "cmd": "printf json-error-shape-contract-missing-exact",
3226
3914
  "exit_code": 2,
3227
3915
  "tags": ["stdout_stderr_contract", "error_contract", "shape_contract"],
@@ -3235,6 +3923,7 @@ def run_self_test() -> int:
3235
3923
  "uses_visible_input_key_names",
3236
3924
  "asserts_visible_output_key_names",
3237
3925
  "asserts_no_unexpected_output_keys",
3926
+ "visible_text_names_exact_json_error_object",
3238
3927
  ],
3239
3928
  },
3240
3929
  }) + "\n")
@@ -3246,7 +3935,7 @@ def run_self_test() -> int:
3246
3935
  )
3247
3936
  if missing_exact_error_object.returncode == 0:
3248
3937
  print(
3249
- "shape_contract co-occurring with error_contract without "
3938
+ "shape_contract claiming visible exact error object without "
3250
3939
  "asserts_exact_error_object was accepted",
3251
3940
  file=sys.stderr,
3252
3941
  )
@@ -3254,7 +3943,7 @@ def run_self_test() -> int:
3254
3943
 
3255
3944
  (error_devlyn / "risk-probes.jsonl").write_text(json.dumps({
3256
3945
  "id": "P7d",
3257
- "derived_from": "Invalid input must print a JSON error object to stderr and exit 2.",
3946
+ "derived_from": "Invalid input must print JSON error object `{ \"error\": \"bad_input\" }` to stderr and exit 2.",
3258
3947
  "cmd": "printf json-error-shape-contract",
3259
3948
  "exit_code": 2,
3260
3949
  "tags": ["stdout_stderr_contract", "error_contract", "shape_contract"],
@@ -3268,6 +3957,7 @@ def run_self_test() -> int:
3268
3957
  "uses_visible_input_key_names",
3269
3958
  "asserts_visible_output_key_names",
3270
3959
  "asserts_no_unexpected_output_keys",
3960
+ "visible_text_names_exact_json_error_object",
3271
3961
  "asserts_exact_error_object",
3272
3962
  ],
3273
3963
  },
@@ -3283,6 +3973,38 @@ def run_self_test() -> int:
3283
3973
  print(strong_json_error_shape_contract.stderr, file=sys.stderr)
3284
3974
  return 1
3285
3975
 
3976
+ (error_devlyn / "risk-probes.jsonl").write_text(json.dumps({
3977
+ "id": "P7e",
3978
+ "derived_from": (
3979
+ "Malformed input must exit 2 and print stderr JSON with keys `code` and `detail`; "
3980
+ "values are implementation-defined."
3981
+ ),
3982
+ "cmd": "printf error-exit-shape-contract",
3983
+ "exit_code": 2,
3984
+ "tags": ["error_contract", "shape_contract"],
3985
+ "tag_evidence": {
3986
+ "error_contract": [
3987
+ "asserts_error_payload_or_stderr",
3988
+ "asserts_nonzero_or_exit_2",
3989
+ ],
3990
+ "shape_contract": [
3991
+ "uses_visible_input_key_names",
3992
+ "asserts_visible_output_key_names",
3993
+ "asserts_no_unexpected_output_keys",
3994
+ ],
3995
+ },
3996
+ }) + "\n")
3997
+ error_exit_shape_contract = subprocess.run(
3998
+ [sys.executable, script_path, "--validate-risk-probes"],
3999
+ cwd=error_root,
4000
+ capture_output=True,
4001
+ text=True,
4002
+ )
4003
+ if error_exit_shape_contract.returncode != 0:
4004
+ print("error-exit shape_contract without visible exact error object was rejected", file=sys.stderr)
4005
+ print(error_exit_shape_contract.stderr, file=sys.stderr)
4006
+ return 1
4007
+
3286
4008
  http_error_root = work / "http-error-contract-risk-probe"
3287
4009
  http_error_root.mkdir()
3288
4010
  http_error_devlyn = http_error_root / ".devlyn"
@@ -3336,6 +4058,7 @@ def run_self_test() -> int:
3336
4058
  "uses_visible_input_key_names",
3337
4059
  "asserts_visible_output_key_names",
3338
4060
  "asserts_no_unexpected_output_keys",
4061
+ "visible_text_names_exact_json_error_object",
3339
4062
  ],
3340
4063
  },
3341
4064
  }) + "\n")
@@ -3367,6 +4090,7 @@ def run_self_test() -> int:
3367
4090
  "uses_visible_input_key_names",
3368
4091
  "asserts_visible_output_key_names",
3369
4092
  "asserts_no_unexpected_output_keys",
4093
+ "visible_text_names_exact_json_error_object",
3370
4094
  "asserts_exact_error_object",
3371
4095
  ],
3372
4096
  },
@@ -3670,12 +4394,15 @@ def run_self_test() -> int:
3670
4394
  print("--write-untracked-baseline wrote wrong content", file=sys.stderr)
3671
4395
  print(repr(baseline_lines), file=sys.stderr)
3672
4396
  return 1
3673
- scope_findings_path = scope_devlyn / "spec-verify-findings.jsonl"
4397
+ scope_findings_path = scope_devlyn / output_findings_name()
4398
+ scope_build_gate_env = os.environ.copy()
4399
+ scope_build_gate_env["SPEC_VERIFY_PHASE"] = "build_gate"
3674
4400
 
3675
4401
  # Test 1: no .devlyn/plan.md at all -> fail-closed CRITICAL, not a no-op.
3676
4402
  (scope_root / "bin" / "cli.js").write_text("module.exports = { ok: true };\n")
3677
4403
  missing_plan_run = subprocess.run(
3678
- [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
4404
+ [sys.executable, script_path], cwd=scope_root, env=scope_build_gate_env,
4405
+ capture_output=True, text=True,
3679
4406
  )
3680
4407
  if missing_plan_run.returncode == 0:
3681
4408
  print("BUILD_GATE accepted a run with no plan.md", file=sys.stderr)
@@ -3689,7 +4416,8 @@ def run_self_test() -> int:
3689
4416
  "# PLAN\n\n<!-- devlyn:authorized-surface -->\n## 1. Files to touch\n\n- `bin/cli.js` (edit): ship the fix.\n"
3690
4417
  )
3691
4418
  malformed_block_run = subprocess.run(
3692
- [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
4419
+ [sys.executable, script_path], cwd=scope_root, env=scope_build_gate_env,
4420
+ capture_output=True, text=True,
3693
4421
  )
3694
4422
  if malformed_block_run.returncode == 0:
3695
4423
  print("BUILD_GATE accepted plan.md with no authorized_surface block", file=sys.stderr)
@@ -3706,6 +4434,32 @@ def run_self_test() -> int:
3706
4434
  print("--print-authorized-surface accepted malformed plan.md", file=sys.stderr)
3707
4435
  return 1
3708
4436
 
4437
+ for entry in ("src/{a,b", "src/{a,}/**", "src/{a,{b,c}}", "src/{a,**}"):
4438
+ (scope_devlyn / "plan.md").write_text(
4439
+ "# PLAN\n\n<!-- devlyn:authorized-surface -->\n## 1. Files to touch\n\n"
4440
+ "```json\n"
4441
+ + json.dumps({"authorized_surface": [entry]}) + "\n```\n"
4442
+ )
4443
+ brace_build_gate = subprocess.run(
4444
+ [sys.executable, script_path], cwd=scope_root, env=scope_build_gate_env,
4445
+ capture_output=True, text=True,
4446
+ )
4447
+ brace_print_surface = subprocess.run(
4448
+ [sys.executable, script_path, "--print-authorized-surface"], cwd=scope_root,
4449
+ capture_output=True, text=True,
4450
+ )
4451
+ findings_text = scope_findings_path.read_text(encoding="utf-8")
4452
+ if (
4453
+ brace_build_gate.returncode == 0
4454
+ or brace_print_surface.returncode == 0
4455
+ or "scope.authorized-surface-malformed" not in findings_text
4456
+ or "supported form" not in findings_text
4457
+ or "supported form" not in brace_print_surface.stderr
4458
+ or "Traceback" in brace_build_gate.stderr + brace_print_surface.stderr
4459
+ ):
4460
+ print(f"malformed brace glob escaped the authorized-surface carrier: {entry}", file=sys.stderr)
4461
+ return 1
4462
+
3709
4463
  # Test 3: valid surface, in-scope-only diff -> no scope findings, exit 0.
3710
4464
  (scope_devlyn / "plan.md").write_text(
3711
4465
  "# PLAN\n\n<!-- devlyn:authorized-surface -->\n## 1. Files to touch\n\n"
@@ -3734,7 +4488,8 @@ def run_self_test() -> int:
3734
4488
  return 1
3735
4489
  (scope_devlyn / "untracked.baseline").unlink()
3736
4490
  missing_baseline_run = subprocess.run(
3737
- [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
4491
+ [sys.executable, script_path], cwd=scope_root, env=scope_build_gate_env,
4492
+ capture_output=True, text=True,
3738
4493
  )
3739
4494
  if missing_baseline_run.returncode == 0:
3740
4495
  print("BUILD_GATE accepted missing .devlyn/untracked.baseline", file=sys.stderr)
@@ -3750,7 +4505,8 @@ def run_self_test() -> int:
3750
4505
  print("--write-untracked-baseline re-run failed", file=sys.stderr)
3751
4506
  return 1
3752
4507
  in_scope_run = subprocess.run(
3753
- [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
4508
+ [sys.executable, script_path], cwd=scope_root, env=scope_build_gate_env,
4509
+ capture_output=True, text=True,
3754
4510
  )
3755
4511
  if in_scope_run.returncode != 0:
3756
4512
  print("in-scope-only diff was rejected", file=sys.stderr)
@@ -3767,7 +4523,8 @@ def run_self_test() -> int:
3767
4523
  (scope_root / "data" / "usage-stats.json").write_text('{"leaked": true}\n')
3768
4524
  (scope_root / "data" / "scratch.json").write_text('{"untracked": true}\n')
3769
4525
  out_of_scope_run = subprocess.run(
3770
- [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
4526
+ [sys.executable, script_path], cwd=scope_root, env=scope_build_gate_env,
4527
+ capture_output=True, text=True,
3771
4528
  )
3772
4529
  if out_of_scope_run.returncode == 0:
3773
4530
  print("out-of-scope file was accepted", file=sys.stderr)
@@ -3803,7 +4560,8 @@ def run_self_test() -> int:
3803
4560
  # (directory-prefix boundary, not a bare string-prefix match).
3804
4561
  (scope_root / "lib2" / "keep.js").write_text("module.exports = { touched: true };\n")
3805
4562
  subprocess.run(
3806
- [sys.executable, script_path], cwd=scope_root, capture_output=True, text=True,
4563
+ [sys.executable, script_path], cwd=scope_root, env=scope_build_gate_env,
4564
+ capture_output=True, text=True,
3807
4565
  )
3808
4566
  boundary_flagged = {
3809
4567
  loads_strict_json(line)["file"]
@@ -3834,12 +4592,16 @@ def run_self_test() -> int:
3834
4592
  print(verify_mech_findings, file=sys.stderr)
3835
4593
  return 1
3836
4594
 
3837
- # Test 7: shape validation rejects absolute paths, `..`, and duplicates.
4595
+ # Test 7: shape validation rejects absolute paths, `..`, duplicates, and malformed braces.
3838
4596
  for bad_surface in (
3839
4597
  {"authorized_surface": ["/etc/passwd"]},
3840
4598
  {"authorized_surface": ["bin/../etc/passwd"]},
3841
4599
  {"authorized_surface": ["bin/cli.js", "bin/cli.js"]},
3842
4600
  {"authorized_surface": []},
4601
+ {"authorized_surface": ["src/{a,b"]},
4602
+ {"authorized_surface": ["src/{a,}/**"]},
4603
+ {"authorized_surface": ["src/{a,{b,c}}"]},
4604
+ {"authorized_surface": ["src/{a,**}"]},
3843
4605
  ):
3844
4606
  err = validate_authorized_surface_shape(bad_surface)
3845
4607
  if err is None:
@@ -3893,6 +4655,14 @@ def run_self_test() -> int:
3893
4655
 
3894
4656
 
3895
4657
  def main() -> int:
4658
+ if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]:
4659
+ print(
4660
+ "usage: spec-verify-check.py [-h | --help | --include-risk-probes | "
4661
+ "--validate-risk-probes | --print-risk-probes-digest | "
4662
+ "--print-authorized-surface | --write-untracked-baseline | "
4663
+ "--check <markdown-path> | --check-expected <json-path> | --self-test]"
4664
+ )
4665
+ return 0
3896
4666
  include_risk_probes = False
3897
4667
  validate_risk_probes_only = False
3898
4668
  print_risk_probes_digest = False
@@ -3929,6 +4699,10 @@ def main() -> int:
3929
4699
  return 2
3930
4700
  return run_check_expected_mode(Path(sys.argv[2]))
3931
4701
 
4702
+ if len(sys.argv) != 1:
4703
+ print(f"unknown argument(s): {' '.join(sys.argv[1:])}", file=sys.stderr)
4704
+ return 2
4705
+
3932
4706
  bench_mode = "BENCH_WORKDIR" in os.environ
3933
4707
  work = Path(os.environ.get("BENCH_WORKDIR") or os.getcwd())
3934
4708
  devlyn_dir = work / ".devlyn"
@@ -3981,6 +4755,24 @@ def main() -> int:
3981
4755
  trust_bench_staged = bench_mode and pre_staged
3982
4756
  src_type, source_md = read_source(work, devlyn_dir)
3983
4757
  state = read_state(devlyn_dir)
4758
+ external_diff = devlyn_dir / "external-diff.patch"
4759
+ if external_diff.is_file() and state.get("mode") != "verify-only":
4760
+ error = (
4761
+ ".devlyn/external-diff.patch requires pipeline.state.json mode "
4762
+ f"'verify-only'; actual mode is {state.get('mode')!r}"
4763
+ )
4764
+ print(f"[spec-verify] carrier malformed: {error}", file=sys.stderr)
4765
+ write_malformed_finding(
4766
+ devlyn_dir,
4767
+ error,
4768
+ external_diff,
4769
+ fix_hint=(
4770
+ "Remove `.devlyn/external-diff.patch` for ordinary runs, or use "
4771
+ "`/devlyn:resolve --verify-only <diff-or-PR-ref> --spec <path>` only when "
4772
+ "intentionally verifying an external patch."
4773
+ ),
4774
+ )
4775
+ return 1
3984
4776
  integrity_error = source_integrity_error(src_type, state, source_md)
3985
4777
  if integrity_error:
3986
4778
  print(f"[spec-verify] carrier malformed: {integrity_error}", file=sys.stderr)
@@ -4099,12 +4891,17 @@ def main() -> int:
4099
4891
  results_path = devlyn_dir / "spec-verify.results.json"
4100
4892
  findings_path = devlyn_dir / output_findings_name()
4101
4893
 
4102
- verify_env = os.environ.copy()
4103
- verify_env["BENCH_WORKDIR"] = str(work)
4104
-
4105
4894
  results: list[dict] = []
4106
4895
  findings: list[dict] = []
4107
4896
  finding_seq = 1
4897
+ runner = process_evidence_module()
4898
+ evidence_phase, evidence_run_id, evidence_round, manifest_relative = (
4899
+ mechanical_evidence_identity(state, runner)
4900
+ )
4901
+ manifest_path = work / manifest_relative
4902
+ obligations: list[dict] = []
4903
+ capability_denials: list[dict] = []
4904
+ evidence_error: str | None = None
4108
4905
 
4109
4906
  for idx, vc in enumerate(commands):
4110
4907
  cmd = vc.get("cmd")
@@ -4117,180 +4914,186 @@ def main() -> int:
4117
4914
  expected_exit = vc.get("exit_code", 0)
4118
4915
  stdout_contains = vc.get("stdout_contains", []) or []
4119
4916
  stdout_not_contains = vc.get("stdout_not_contains", []) or []
4917
+ timeout_sec = verification_timeout_sec(vc)
4918
+ obligation = mechanical_obligation(vc, idx, evidence_phase)
4919
+ obligations.append(obligation)
4920
+ criterion_ref = (
4921
+ f"risk-probe:{vc.get('id')}"
4922
+ if is_risk_probe
4923
+ else f"spec-verify://verification_commands/{idx}"
4924
+ )
4925
+ file_ref = (
4926
+ ".devlyn/risk-probes.jsonl"
4927
+ if is_risk_probe
4928
+ else ".devlyn/spec-verify.json"
4929
+ )
4120
4930
 
4121
4931
  try:
4122
- proc = subprocess.run(
4123
- cmd,
4124
- cwd=str(work),
4125
- shell=True,
4126
- env=verify_env,
4127
- capture_output=True,
4128
- text=True,
4129
- timeout=60,
4932
+ entry = capture_mechanical_command(
4933
+ runner, work, manifest_path, evidence_run_id, evidence_phase,
4934
+ evidence_round, obligation,
4130
4935
  )
4131
- # Mirror run-fixture.sh post-run verifier: combined stdout+stderr.
4132
- out = (proc.stdout or "") + (proc.stderr or "")
4133
- ok_exit = proc.returncode == expected_exit
4134
- ok_contains = all(s in out for s in stdout_contains)
4135
- ok_not = not any(s in out for s in stdout_not_contains)
4136
- passed = bool(ok_exit and ok_contains and ok_not)
4137
-
4138
- if passed:
4139
- reason = None
4140
- elif not ok_exit:
4141
- reason = "exit"
4142
- elif not ok_contains:
4143
- reason = "missing_contains"
4144
- else:
4145
- reason = "unexpected_text"
4146
-
4936
+ stdout = (work / entry["stdout"]["path"]).read_bytes()
4937
+ stderr = (work / entry["stderr"]["path"]).read_bytes()
4938
+ except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
4939
+ evidence_error = str(exc)
4147
4940
  results.append({
4148
4941
  "index": idx,
4149
4942
  "cmd": cmd,
4150
- "expected_exit": expected_exit,
4151
- "actual_exit": proc.returncode,
4152
- "stdout_contains": stdout_contains,
4153
- "stdout_not_contains": stdout_not_contains,
4154
- "pass": passed,
4155
- "reason": reason,
4156
- "stdout_tail": out[-500:],
4943
+ "pass": False,
4944
+ "reason": "process_evidence_invalid",
4157
4945
  })
4158
-
4159
- if not passed:
4160
- # Construct fine-grained message naming the specific failure.
4161
- if not ok_exit:
4162
- msg = (
4163
- f"Verification command #{idx + 1} failed: expected exit "
4164
- f"{expected_exit}, got {proc.returncode}."
4165
- )
4166
- elif not ok_contains:
4167
- missing = [s for s in stdout_contains if s not in out]
4168
- msg = (
4169
- f"Verification command #{idx + 1} failed: expected "
4170
- f"output to contain {missing!r}."
4171
- )
4172
- else:
4173
- forbidden = [s for s in stdout_not_contains if s in out]
4174
- msg = (
4175
- f"Verification command #{idx + 1} failed: output "
4176
- f"contained forbidden literal(s) {forbidden!r}."
4177
- )
4178
-
4179
- fix_hint = (
4180
- f"See .devlyn/spec-verify.results.json for the captured "
4181
- f"output. Update implementation so `{cmd}` matches the "
4182
- f"contract (exit_code={expected_exit}, "
4183
- f"contains={stdout_contains}, not_contains={stdout_not_contains})."
4184
- )
4185
-
4186
- rule_id = (
4187
- "correctness.risk-probe-failed"
4188
- if is_risk_probe
4189
- else "correctness.spec-literal-mismatch"
4190
- )
4191
- criterion_ref = (
4192
- f"risk-probe:{vc.get('id')}"
4193
- if is_risk_probe
4194
- else f"spec-verify://verification_commands/{idx}"
4195
- )
4196
- file_ref = (
4197
- ".devlyn/risk-probes.jsonl"
4198
- if is_risk_probe
4199
- else ".devlyn/spec-verify.json"
4200
- )
4201
- if is_risk_probe:
4202
- fix_hint = (
4203
- f"Risk probe `{vc.get('id')}` derived from "
4204
- f"{vc.get('derived_from')!r} failed. See "
4205
- ".devlyn/spec-verify.results.json for captured output "
4206
- "and update the implementation to satisfy the visible "
4207
- "verification bullet."
4208
- )
4209
-
4210
- findings.append({
4211
- "id": f"{output_finding_prefix()}-{finding_seq:04d}",
4212
- "rule_id": rule_id,
4213
- "level": "error",
4214
- "severity": "CRITICAL",
4215
- "confidence": 1.0,
4216
- "message": msg,
4217
- "file": file_ref,
4218
- "line": 1,
4219
- "phase": output_phase(),
4220
- "criterion_ref": criterion_ref,
4221
- "fix_hint": fix_hint,
4222
- "blocking": True,
4223
- "status": "open",
4224
- })
4225
- finding_seq += 1
4226
-
4227
- except subprocess.TimeoutExpired:
4228
- results.append({"index": idx, "cmd": cmd, "pass": False,
4229
- "reason": "timeout"})
4230
- rule_id = (
4231
- "correctness.risk-probe-failed"
4232
- if vc.get("_risk_probe")
4233
- else "correctness.spec-literal-mismatch"
4234
- )
4235
4946
  findings.append({
4236
4947
  "id": f"{output_finding_prefix()}-{finding_seq:04d}",
4237
- "rule_id": rule_id,
4948
+ "rule_id": "invariant.process-evidence-invalid",
4238
4949
  "level": "error",
4239
4950
  "severity": "CRITICAL",
4240
4951
  "confidence": 1.0,
4241
- "message": (
4242
- f"Verification command #{idx + 1} timed out after 60s."
4243
- ),
4244
- "file": ".devlyn/risk-probes.jsonl" if vc.get("_risk_probe") else ".devlyn/spec-verify.json",
4952
+ "message": f"MECHANICAL process evidence is invalid: {exc}.",
4953
+ "file": manifest_relative,
4245
4954
  "line": 1,
4246
4955
  "phase": output_phase(),
4247
- "criterion_ref": (
4248
- f"risk-probe:{vc.get('id')}"
4249
- if vc.get("_risk_probe")
4250
- else f"spec-verify://verification_commands/{idx}"
4251
- ),
4956
+ "criterion_ref": "process-evidence://mechanical",
4252
4957
  "fix_hint": (
4253
- f"Command `{cmd}` exceeded 60s. Reduce work or fix a "
4254
- f"hang in the implementation."
4958
+ "Preserve the existing manifest and raw streams, then fix the "
4959
+ "runner identity or evidence mutation before rerunning this phase."
4255
4960
  ),
4256
4961
  "blocking": True,
4257
4962
  "status": "open",
4258
4963
  })
4259
4964
  finding_seq += 1
4260
- except Exception as e: # noqa: BLE001 — surface any harness error explicitly
4261
- results.append({"index": idx, "cmd": cmd, "pass": False,
4262
- "reason": f"error:{e.__class__.__name__}:{e}"})
4263
- rule_id = (
4264
- "correctness.risk-probe-failed"
4265
- if vc.get("_risk_probe")
4266
- else "correctness.spec-literal-mismatch"
4267
- )
4965
+ break
4966
+
4967
+ outcome = entry["outcome"]
4968
+ classification = entry["classification"]
4969
+ combined = stdout + stderr
4970
+ actual_exit = outcome["exit_code"] if outcome["kind"] == "exit" else None
4971
+ ok_exit = outcome["kind"] == "exit" and actual_exit == expected_exit
4972
+ ok_contains = all(s.encode("utf-8") in combined for s in stdout_contains)
4973
+ ok_not = not any(s.encode("utf-8") in combined for s in stdout_not_contains)
4974
+ passed = entry["expectation_met"]
4975
+ if classification["kind"] == "capability_denied":
4976
+ reason = "capability_denied"
4977
+ capability_denials.append({
4978
+ "cmd": cmd,
4979
+ "operation": classification["operation"],
4980
+ "evidence_id": entry["id"],
4981
+ })
4982
+ elif passed:
4983
+ reason = None
4984
+ elif outcome["kind"] == "timeout":
4985
+ reason = "timeout"
4986
+ elif not ok_exit:
4987
+ reason = "exit"
4988
+ elif not ok_contains:
4989
+ reason = "missing_contains"
4990
+ else:
4991
+ reason = "unexpected_text"
4992
+
4993
+ results.append({
4994
+ "index": idx,
4995
+ "cmd": cmd,
4996
+ "expected_exit": expected_exit,
4997
+ "actual_exit": actual_exit,
4998
+ "timeout_sec": timeout_sec,
4999
+ "stdout_contains": stdout_contains,
5000
+ "stdout_not_contains": stdout_not_contains,
5001
+ "pass": passed,
5002
+ "reason": reason,
5003
+ "evidence_id": entry["id"],
5004
+ "outcome": outcome,
5005
+ "classification": classification,
5006
+ "stdout": entry["stdout"],
5007
+ "stderr": entry["stderr"],
5008
+ })
5009
+
5010
+ if classification["kind"] == "capability_denied" or passed:
5011
+ continue
5012
+
5013
+ if outcome["kind"] == "timeout":
4268
5014
  findings.append({
4269
5015
  "id": f"{output_finding_prefix()}-{finding_seq:04d}",
4270
- "rule_id": rule_id,
5016
+ "rule_id": "correctness.verification-timeout",
4271
5017
  "level": "error",
4272
5018
  "severity": "CRITICAL",
4273
5019
  "confidence": 1.0,
4274
5020
  "message": (
4275
- f"Verification command #{idx + 1} raised "
4276
- f"{e.__class__.__name__}: {e}."
5021
+ f"Verification command #{idx + 1} timed out after {timeout_sec}s "
5022
+ f"(timeout_sec={timeout_sec}, maximum 600)."
4277
5023
  ),
4278
- "file": ".devlyn/risk-probes.jsonl" if vc.get("_risk_probe") else ".devlyn/spec-verify.json",
5024
+ "file": file_ref,
4279
5025
  "line": 1,
4280
5026
  "phase": output_phase(),
4281
- "criterion_ref": (
4282
- f"risk-probe:{vc.get('id')}"
4283
- if vc.get("_risk_probe")
4284
- else f"spec-verify://verification_commands/{idx}"
4285
- ),
5027
+ "criterion_ref": criterion_ref,
4286
5028
  "fix_hint": (
4287
- f"Command `{cmd}` could not be executed. Check the work-dir "
4288
- f"state and any environment setup the command requires."
5029
+ f"Command `{cmd}` exceeded its {timeout_sec}s timeout_sec budget. "
5030
+ "Increase timeout_sec up to 600 when the verification legitimately "
5031
+ "needs more time, or fix a hang in the implementation."
4289
5032
  ),
4290
5033
  "blocking": True,
4291
5034
  "status": "open",
4292
5035
  })
4293
5036
  finding_seq += 1
5037
+ continue
5038
+
5039
+ if not ok_exit:
5040
+ actual = (
5041
+ str(actual_exit)
5042
+ if outcome["kind"] == "exit"
5043
+ else f"{outcome['kind']}"
5044
+ + (f" signal {outcome['signal']}" if outcome["kind"] == "signal" else "")
5045
+ )
5046
+ msg = (
5047
+ f"Verification command #{idx + 1} failed: expected exit "
5048
+ f"{expected_exit}, got {actual}."
5049
+ )
5050
+ elif not ok_contains:
5051
+ missing = [s for s in stdout_contains if s.encode("utf-8") not in combined]
5052
+ msg = (
5053
+ f"Verification command #{idx + 1} failed: expected "
5054
+ f"output to contain {missing!r}."
5055
+ )
5056
+ else:
5057
+ forbidden = [s for s in stdout_not_contains if s.encode("utf-8") in combined]
5058
+ msg = (
5059
+ f"Verification command #{idx + 1} failed: output "
5060
+ f"contained forbidden literal(s) {forbidden!r}."
5061
+ )
5062
+
5063
+ rule_id = (
5064
+ "correctness.risk-probe-failed"
5065
+ if is_risk_probe
5066
+ else "correctness.spec-literal-mismatch"
5067
+ )
5068
+ fix_hint = (
5069
+ f"Inspect `{manifest_relative}` entry `{entry['id']}` and its sealed raw "
5070
+ f"streams. Update implementation so `{cmd}` matches the contract "
5071
+ f"(exit_code={expected_exit}, contains={stdout_contains}, "
5072
+ f"not_contains={stdout_not_contains})."
5073
+ )
5074
+ if is_risk_probe:
5075
+ fix_hint = (
5076
+ f"Risk probe `{vc.get('id')}` derived from {vc.get('derived_from')!r} "
5077
+ f"failed. Inspect `{manifest_relative}` entry `{entry['id']}` and "
5078
+ "update the implementation to satisfy the visible verification bullet."
5079
+ )
5080
+
5081
+ findings.append({
5082
+ "id": f"{output_finding_prefix()}-{finding_seq:04d}",
5083
+ "rule_id": rule_id,
5084
+ "level": "error",
5085
+ "severity": "CRITICAL",
5086
+ "confidence": 1.0,
5087
+ "message": msg,
5088
+ "file": file_ref,
5089
+ "line": 1,
5090
+ "phase": output_phase(),
5091
+ "criterion_ref": criterion_ref,
5092
+ "fix_hint": fix_hint,
5093
+ "blocking": True,
5094
+ "status": "open",
5095
+ })
5096
+ finding_seq += 1
4294
5097
 
4295
5098
  expected_findings, finding_seq = expected_contract_findings(
4296
5099
  expected_data,
@@ -4309,7 +5112,40 @@ def main() -> int:
4309
5112
  )
4310
5113
  findings.extend(surface_findings)
4311
5114
 
4312
- results_path.write_text(json.dumps({"commands": results}, indent=2) + "\n")
5115
+ evidence_carrier = None
5116
+ if evidence_error is None and obligations:
5117
+ try:
5118
+ evidence_carrier = runner.validate_manifest(
5119
+ work, manifest_relative, evidence_run_id, evidence_phase,
5120
+ evidence_round, obligations, require_expectations=False,
5121
+ )
5122
+ results = runner.bound_carrier_summary_commands(work, evidence_carrier)
5123
+ except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
5124
+ evidence_error = str(exc)
5125
+ findings.append({
5126
+ "id": f"{output_finding_prefix()}-{finding_seq:04d}",
5127
+ "rule_id": "invariant.process-evidence-invalid",
5128
+ "level": "error",
5129
+ "severity": "CRITICAL",
5130
+ "confidence": 1.0,
5131
+ "message": f"MECHANICAL process evidence validation failed: {exc}.",
5132
+ "file": manifest_relative,
5133
+ "line": 1,
5134
+ "phase": output_phase(),
5135
+ "criterion_ref": "process-evidence://mechanical",
5136
+ "fix_hint": (
5137
+ "Preserve the manifest and raw streams, then correct the "
5138
+ "missing, altered, or path-escaping evidence before rerunning."
5139
+ ),
5140
+ "blocking": True,
5141
+ "status": "open",
5142
+ })
5143
+ finding_seq += 1
5144
+
5145
+ results_path.write_text(json.dumps({
5146
+ "commands": results,
5147
+ "process_evidence": evidence_carrier,
5148
+ }, indent=2) + "\n")
4313
5149
 
4314
5150
  # Append findings (jsonl). BUILD_GATE merge step concatenates this onto
4315
5151
  # build_gate.findings.jsonl; never overwrite the orchestrator's own gate
@@ -4320,6 +5156,15 @@ def main() -> int:
4320
5156
 
4321
5157
  failed = [r for r in results if r.get("pass") is False]
4322
5158
  blocking_findings = [f for f in findings if f.get("severity") in {"CRITICAL", "HIGH"}]
5159
+ if capability_denials:
5160
+ denial = capability_denials[0]
5161
+ print(
5162
+ "BLOCKED:build-env-underprovisioned: "
5163
+ f"{denial['operation']} denied for `{denial['cmd']}`; "
5164
+ f"evidence {manifest_relative}#{denial['evidence_id']}",
5165
+ file=sys.stderr,
5166
+ )
5167
+ return 2
4323
5168
  if failed or blocking_findings:
4324
5169
  print(
4325
5170
  f"[spec-verify] {len(failed)}/{len(results)} command(s) failed; "