devlyn-cli 2.10.1 → 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 (75) 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/bin/devlyn.js +6 -1
  41. package/config/skills/_shared/.ruff.toml +2 -0
  42. package/config/skills/_shared/adapters/claude.md +9 -1
  43. package/config/skills/_shared/adapters/codex.md +5 -0
  44. package/config/skills/_shared/adapters/grok.md +4 -2
  45. package/config/skills/_shared/archive_run.py +738 -56
  46. package/config/skills/_shared/codex-config.md +19 -4
  47. package/config/skills/_shared/codex-monitored.sh +81 -12
  48. package/config/skills/_shared/collect-codex-findings.py +311 -128
  49. package/config/skills/_shared/engine-preflight.md +8 -7
  50. package/config/skills/_shared/expected.schema.json +61 -0
  51. package/config/skills/_shared/finish-gate.py +12 -11
  52. package/config/skills/_shared/grok-anchor-guard.py +18 -1
  53. package/config/skills/_shared/invocation-receipt.py +1041 -0
  54. package/config/skills/_shared/judge-output-parser.py +200 -0
  55. package/config/skills/_shared/judge-role-evidence.py +273 -0
  56. package/config/skills/_shared/process-evidence.py +909 -0
  57. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  58. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  59. package/config/skills/_shared/role-config.py +552 -0
  60. package/config/skills/_shared/run-bounded.py +1 -1
  61. package/config/skills/_shared/spec-verify-check.py +749 -181
  62. package/config/skills/_shared/state-phase-write.py +1838 -58
  63. package/config/skills/_shared/terminal-claim-check.py +15 -0
  64. package/config/skills/_shared/verify-merge-findings.py +958 -51
  65. package/config/skills/devlyn:engines/SKILL.md +8 -16
  66. package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
  67. package/config/skills/devlyn:queue/SKILL.md +7 -4
  68. package/config/skills/devlyn:resolve/SKILL.md +91 -33
  69. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  70. package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
  71. package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
  72. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
  73. package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
  74. package/package.json +5 -1
  75. package/scripts/lint-skills.sh +314 -285
@@ -0,0 +1,909 @@
1
+ #!/usr/bin/env python3
2
+ """Capture and validate run-scoped process evidence without third-party dependencies."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import re
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+
17
+
18
+ SCHEMA_VERSION = "1.0"
19
+ PHASES = {"implement", "build_gate", "verify"}
20
+ CAPABILITIES = {"filesystem", "subprocess", "loopback", "pty", "network"}
21
+ ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
22
+ SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
23
+ OBLIGATION_KEYS = {
24
+ "id", "phase", "cmd", "argv", "exit_code", "timeout_sec",
25
+ "stdout_contains", "stdout_not_contains",
26
+ }
27
+
28
+
29
+ class EvidenceError(ValueError):
30
+ pass
31
+
32
+
33
+ def _reject_constant(token: str) -> None:
34
+ raise ValueError(f"invalid JSON numeric constant: {token}")
35
+
36
+
37
+ def _unique_object(pairs: list[tuple[str, object]]) -> dict:
38
+ result = {}
39
+ for key, value in pairs:
40
+ if key in result:
41
+ raise ValueError(f"duplicate JSON key: {key}")
42
+ result[key] = value
43
+ return result
44
+
45
+
46
+ def loads_strict_json(text: str):
47
+ return json.loads(
48
+ text, parse_constant=_reject_constant, object_pairs_hook=_unique_object,
49
+ )
50
+
51
+
52
+ def _read_json(path: pathlib.Path):
53
+ try:
54
+ return loads_strict_json(path.read_text(encoding="utf-8"))
55
+ except (OSError, UnicodeError, ValueError) as exc:
56
+ raise EvidenceError(f"{path} is not valid UTF-8 JSON: {exc}") from exc
57
+
58
+
59
+ def _sha256(raw: bytes) -> str:
60
+ return hashlib.sha256(raw).hexdigest()
61
+
62
+
63
+ def _atomic_write(path: pathlib.Path, raw: bytes) -> None:
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".tmp.")
66
+ try:
67
+ with os.fdopen(fd, "wb") as handle:
68
+ handle.write(raw)
69
+ pathlib.Path(temporary).replace(path)
70
+ except BaseException:
71
+ pathlib.Path(temporary).unlink(missing_ok=True)
72
+ raise
73
+
74
+
75
+ def _json_bytes(value: object) -> bytes:
76
+ return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
77
+
78
+
79
+ def _string_list(value: object, label: str) -> list[str]:
80
+ if not isinstance(value, list) or any(
81
+ not isinstance(item, str) or not item for item in value
82
+ ):
83
+ raise EvidenceError(f"{label} must be an array of non-empty strings")
84
+ return list(value)
85
+
86
+
87
+ def normalize_obligation(value: object, phase: str | None = None) -> dict:
88
+ if not isinstance(value, dict):
89
+ raise EvidenceError("process_evidence entry must be an object")
90
+ unknown = sorted(set(value) - OBLIGATION_KEYS)
91
+ if unknown:
92
+ raise EvidenceError(f"process_evidence entry has unknown key(s): {','.join(unknown)}")
93
+ evidence_id = value.get("id")
94
+ if not isinstance(evidence_id, str) or ID_RE.fullmatch(evidence_id) is None:
95
+ raise EvidenceError("process_evidence id must be a safe non-empty path component")
96
+ declared_phase = value.get("phase")
97
+ if declared_phase not in PHASES:
98
+ raise EvidenceError(f"process_evidence[{evidence_id}].phase is invalid")
99
+ if phase is not None and declared_phase != phase:
100
+ raise EvidenceError(
101
+ f"process_evidence[{evidence_id}].phase expected {phase}, got {declared_phase}"
102
+ )
103
+ has_command = "cmd" in value
104
+ has_argv = "argv" in value
105
+ if has_command == has_argv:
106
+ raise EvidenceError(
107
+ f"process_evidence[{evidence_id}] requires exactly one of cmd or argv"
108
+ )
109
+ command = value.get("cmd") if has_command else None
110
+ argv = value.get("argv") if has_argv else None
111
+ if has_command and (not isinstance(command, str) or not command):
112
+ raise EvidenceError(f"process_evidence[{evidence_id}].cmd must be non-empty")
113
+ if has_argv and (
114
+ not isinstance(argv, list) or not argv or not isinstance(argv[0], str)
115
+ or not argv[0] or any(not isinstance(arg, str) for arg in argv)
116
+ ):
117
+ raise EvidenceError(
118
+ f"process_evidence[{evidence_id}].argv must be a string array with non-empty argv[0]"
119
+ )
120
+ exit_code = value.get("exit_code", 0)
121
+ timeout_sec = value.get("timeout_sec", 60)
122
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int) or exit_code < 0:
123
+ raise EvidenceError(f"process_evidence[{evidence_id}].exit_code must be non-negative int")
124
+ if (
125
+ isinstance(timeout_sec, bool) or not isinstance(timeout_sec, int)
126
+ or timeout_sec < 1 or timeout_sec > 600
127
+ ):
128
+ raise EvidenceError(
129
+ f"process_evidence[{evidence_id}].timeout_sec must be int from 1 to 600"
130
+ )
131
+ normalized = {
132
+ "id": evidence_id,
133
+ "phase": declared_phase,
134
+ "exit_code": exit_code,
135
+ "timeout_sec": timeout_sec,
136
+ "stdout_contains": _string_list(
137
+ value.get("stdout_contains", []),
138
+ f"process_evidence[{evidence_id}].stdout_contains",
139
+ ),
140
+ "stdout_not_contains": _string_list(
141
+ value.get("stdout_not_contains", []),
142
+ f"process_evidence[{evidence_id}].stdout_not_contains",
143
+ ),
144
+ }
145
+ normalized["cmd" if has_command else "argv"] = command if has_command else list(argv)
146
+ return normalized
147
+
148
+
149
+ def _safe_relative_path(value: str, label: str) -> pathlib.PurePosixPath:
150
+ if not isinstance(value, str) or not value or "\\" in value:
151
+ raise EvidenceError(f"{label} must be a non-empty POSIX relative path")
152
+ relative = pathlib.PurePosixPath(value)
153
+ if relative.is_absolute() or value.startswith("./") or ".." in relative.parts:
154
+ raise EvidenceError(f"{label} escapes the worktree: {value!r}")
155
+ return relative
156
+
157
+
158
+ def _checked_file(work: pathlib.Path, relative: str, label: str) -> pathlib.Path:
159
+ rel = _safe_relative_path(relative, label)
160
+ root = work.resolve()
161
+ candidate = work.joinpath(*rel.parts)
162
+ try:
163
+ resolved = candidate.resolve(strict=True)
164
+ resolved.relative_to(root)
165
+ except (OSError, ValueError) as exc:
166
+ raise EvidenceError(f"{label} is missing or escapes the worktree: {relative!r}") from exc
167
+ if not resolved.is_file():
168
+ raise EvidenceError(f"{label} is not a regular file: {relative!r}")
169
+ return resolved
170
+
171
+
172
+ def _relative_to_work(work: pathlib.Path, path: pathlib.Path, label: str) -> str:
173
+ try:
174
+ return path.resolve().relative_to(work.resolve()).as_posix()
175
+ except (OSError, ValueError) as exc:
176
+ raise EvidenceError(f"{label} escapes the worktree: {path}") from exc
177
+
178
+
179
+ def _source_expected_path(work: pathlib.Path, state: dict) -> pathlib.Path | None:
180
+ source = state.get("source")
181
+ if not isinstance(source, dict) or source.get("type") != "spec":
182
+ return None
183
+ spec_path = source.get("spec_path")
184
+ if not isinstance(spec_path, str) or not spec_path:
185
+ raise EvidenceError("state.source.spec_path is missing")
186
+ spec_rel = _safe_relative_path(spec_path, "state.source.spec_path")
187
+ expected = work.joinpath(*spec_rel.parts).with_suffix(".expected.json")
188
+ try:
189
+ expected.resolve().relative_to(work.resolve())
190
+ except (OSError, ValueError) as exc:
191
+ raise EvidenceError("source expected contract escapes the worktree") from exc
192
+ return expected if expected.is_file() else None
193
+
194
+
195
+ def declared_obligations(work: pathlib.Path, state: dict, phase: str) -> list[dict]:
196
+ if phase not in PHASES:
197
+ raise EvidenceError(f"unsupported evidence phase: {phase}")
198
+ expected_path = _source_expected_path(work, state)
199
+ if expected_path is None:
200
+ return []
201
+ contract = _read_json(expected_path)
202
+ if not isinstance(contract, dict):
203
+ raise EvidenceError(f"{expected_path} must contain a JSON object")
204
+ raw = contract.get("process_evidence", [])
205
+ if not isinstance(raw, list):
206
+ raise EvidenceError("process_evidence must be an array")
207
+ obligations = [normalize_obligation(item) for item in raw]
208
+ ids: set[str] = set()
209
+ for item in obligations:
210
+ key = f"{item['phase']}:{item['id']}"
211
+ if key in ids:
212
+ raise EvidenceError(f"duplicate process_evidence id: {key}")
213
+ ids.add(key)
214
+ return [item for item in obligations if item["phase"] == phase]
215
+
216
+
217
+ def mechanical_evidence_required(work: pathlib.Path, state: dict) -> bool:
218
+ """Return whether BUILD_GATE/VERIFY has declared executable obligations."""
219
+ expected_path = _source_expected_path(work, state)
220
+ if expected_path is not None:
221
+ expected = _read_json(expected_path)
222
+ commands = expected.get("verification_commands") if isinstance(expected, dict) else None
223
+ if commands is not None and not isinstance(commands, list):
224
+ raise EvidenceError("verification_commands must be an array")
225
+ if commands:
226
+ return True
227
+ devlyn = work / ".devlyn"
228
+ staged_path = devlyn / "spec-verify.json"
229
+ if staged_path.is_file():
230
+ staged = _read_json(staged_path)
231
+ commands = staged.get("verification_commands") if isinstance(staged, dict) else None
232
+ if commands is not None and not isinstance(commands, list):
233
+ raise EvidenceError("staged verification_commands must be an array")
234
+ if commands:
235
+ return True
236
+ probes = devlyn / "risk-probes.jsonl"
237
+ return probes.is_file() and bool(probes.read_text(encoding="utf-8").strip())
238
+
239
+
240
+ def phase_round(state: dict, phase: str) -> int:
241
+ entry = (state.get("phases") or {}).get(phase)
242
+ round_ = entry.get("round") if isinstance(entry, dict) else None
243
+ if isinstance(round_, bool) or not isinstance(round_, int) or round_ < 0:
244
+ raise EvidenceError(f"state.phases.{phase}.round must be a non-negative integer")
245
+ return round_
246
+
247
+
248
+ def manifest_relative_path(state: dict, phase: str) -> str:
249
+ run_id = state.get("run_id")
250
+ if not isinstance(run_id, str) or ID_RE.fullmatch(run_id) is None:
251
+ raise EvidenceError("state.run_id must be a safe non-empty path component")
252
+ round_ = phase_round(state, phase)
253
+ return f".devlyn/process-evidence/{run_id}/{phase}/round-{round_}/manifest.json"
254
+
255
+
256
+ def _expectation(obligation: dict) -> dict:
257
+ return {
258
+ "exit_code": obligation["exit_code"],
259
+ "timeout_sec": obligation["timeout_sec"],
260
+ "stdout_contains": obligation["stdout_contains"],
261
+ "stdout_not_contains": obligation["stdout_not_contains"],
262
+ }
263
+
264
+
265
+ def _execution(obligation: dict) -> dict:
266
+ return {
267
+ "command": obligation.get("cmd"),
268
+ "argv": obligation.get("argv"),
269
+ }
270
+
271
+
272
+ def _expectation_met(expectation: dict, outcome: dict, stdout: bytes, stderr: bytes) -> bool:
273
+ combined = stdout + stderr
274
+ return (
275
+ outcome.get("kind") == "exit"
276
+ and outcome.get("exit_code") == expectation["exit_code"]
277
+ and all(value.encode("utf-8") in combined for value in expectation["stdout_contains"])
278
+ and all(value.encode("utf-8") not in combined for value in expectation["stdout_not_contains"])
279
+ )
280
+
281
+
282
+ def _stream_record(work: pathlib.Path, path: pathlib.Path, raw: bytes) -> dict:
283
+ return {
284
+ "path": _relative_to_work(work, path, "process evidence stream"),
285
+ "sha256": _sha256(raw),
286
+ "bytes": len(raw),
287
+ }
288
+
289
+
290
+ def _load_manifest(path: pathlib.Path, run_id: str, phase: str, round_: int) -> dict:
291
+ if not path.exists():
292
+ return {
293
+ "schema_version": SCHEMA_VERSION,
294
+ "run_id": run_id,
295
+ "phase": phase,
296
+ "round": round_,
297
+ "entries": [],
298
+ }
299
+ manifest = _read_json(path)
300
+ if not isinstance(manifest, dict) or set(manifest) != {
301
+ "schema_version", "run_id", "phase", "round", "entries",
302
+ }:
303
+ raise EvidenceError("process evidence manifest has an invalid top-level shape")
304
+ if (
305
+ manifest["schema_version"] != SCHEMA_VERSION or manifest["run_id"] != run_id
306
+ or manifest["phase"] != phase or manifest["round"] != round_
307
+ or not isinstance(manifest["entries"], list)
308
+ ):
309
+ raise EvidenceError("process evidence manifest identity does not match the current run")
310
+ ids = []
311
+ for entry in manifest["entries"]:
312
+ evidence_id = entry.get("id") if isinstance(entry, dict) else None
313
+ if not isinstance(evidence_id, str) or ID_RE.fullmatch(evidence_id) is None:
314
+ raise EvidenceError("process evidence manifest contains a malformed id")
315
+ if evidence_id in ids:
316
+ raise EvidenceError("process evidence manifest contains a duplicate id")
317
+ ids.append(evidence_id)
318
+ return manifest
319
+
320
+
321
+ def _append_entry(
322
+ work: pathlib.Path, manifest_path: pathlib.Path, run_id: str, phase: str,
323
+ round_: int, evidence_id: str, execution: dict, expectation: dict,
324
+ outcome: dict, classification: dict, stdout: bytes, stderr: bytes,
325
+ duration_ms: int,
326
+ ) -> dict:
327
+ _relative_to_work(work, manifest_path, "process evidence manifest")
328
+ manifest = _load_manifest(manifest_path, run_id, phase, round_)
329
+ if any(item["id"] == evidence_id for item in manifest["entries"]):
330
+ raise EvidenceError(f"duplicate process evidence id: {phase}:{evidence_id}")
331
+ stdout_path = manifest_path.parent / f"{evidence_id}.stdout"
332
+ stderr_path = manifest_path.parent / f"{evidence_id}.stderr"
333
+ if stdout_path.exists() or stderr_path.exists():
334
+ raise EvidenceError(f"unattributed process evidence stream already exists: {evidence_id}")
335
+ _atomic_write(stdout_path, stdout)
336
+ _atomic_write(stderr_path, stderr)
337
+ entry = {
338
+ "id": evidence_id,
339
+ "execution": execution,
340
+ "expectation": expectation,
341
+ "outcome": outcome,
342
+ "classification": classification,
343
+ "stdout": _stream_record(work, stdout_path, stdout),
344
+ "stderr": _stream_record(work, stderr_path, stderr),
345
+ "duration_ms": duration_ms,
346
+ "expectation_met": _expectation_met(expectation, outcome, stdout, stderr),
347
+ }
348
+ manifest["entries"].append(entry)
349
+ _atomic_write(manifest_path, _json_bytes(manifest))
350
+ return entry
351
+
352
+
353
+ def capture_process(
354
+ work: pathlib.Path, manifest_path: pathlib.Path, run_id: str, phase: str,
355
+ round_: int, obligation: dict,
356
+ ) -> dict:
357
+ item = normalize_obligation(obligation, phase)
358
+ started = time.monotonic()
359
+ try:
360
+ proc = subprocess.run(
361
+ item.get("cmd") if "cmd" in item else item["argv"],
362
+ cwd=work, shell="cmd" in item, capture_output=True,
363
+ timeout=item["timeout_sec"], check=False,
364
+ )
365
+ stdout, stderr = proc.stdout, proc.stderr
366
+ outcome = (
367
+ {"kind": "exit", "exit_code": proc.returncode, "signal": None}
368
+ if proc.returncode >= 0 else
369
+ {"kind": "signal", "exit_code": None, "signal": -proc.returncode}
370
+ )
371
+ except subprocess.TimeoutExpired as exc:
372
+ stdout = exc.stdout or b""
373
+ stderr = exc.stderr or b""
374
+ outcome = {"kind": "timeout", "exit_code": None, "signal": None}
375
+ except OSError as exc:
376
+ stdout = b""
377
+ stderr = os.fsencode(str(exc))
378
+ outcome = {"kind": "spawn_error", "exit_code": None, "signal": None}
379
+ return _append_entry(
380
+ work, manifest_path, run_id, phase, round_, item["id"], _execution(item),
381
+ _expectation(item), outcome,
382
+ {"kind": "product_result", "operation": None}, stdout, stderr,
383
+ max(0, round((time.monotonic() - started) * 1000)),
384
+ )
385
+
386
+
387
+ def record_capability_denial(
388
+ work: pathlib.Path, manifest_path: pathlib.Path, run_id: str, phase: str,
389
+ round_: int, obligation: dict, operation: str, detail: bytes,
390
+ ) -> dict:
391
+ item = normalize_obligation(obligation, phase)
392
+ if operation not in CAPABILITIES:
393
+ raise EvidenceError(f"unsupported denied capability: {operation}")
394
+ return _append_entry(
395
+ work, manifest_path, run_id, phase, round_, item["id"], _execution(item),
396
+ _expectation(item), {"kind": "not_run", "exit_code": None, "signal": None},
397
+ {"kind": "capability_denied", "operation": operation}, b"", detail, 0,
398
+ )
399
+
400
+
401
+ def _validate_stream(work: pathlib.Path, record: object, expected_path: str, label: str) -> dict:
402
+ if not isinstance(record, dict) or set(record) != {"path", "sha256", "bytes"}:
403
+ raise EvidenceError(f"{label} has an invalid stream record")
404
+ if record["path"] != expected_path:
405
+ raise EvidenceError(f"{label} path mismatch: {record['path']!r}")
406
+ if not isinstance(record["sha256"], str) or SHA256_RE.fullmatch(record["sha256"]) is None:
407
+ raise EvidenceError(f"{label} sha256 is invalid")
408
+ if isinstance(record["bytes"], bool) or not isinstance(record["bytes"], int) or record["bytes"] < 0:
409
+ raise EvidenceError(f"{label} byte count is invalid")
410
+ raw = _checked_file(work, record["path"], label).read_bytes()
411
+ if len(raw) != record["bytes"] or _sha256(raw) != record["sha256"]:
412
+ raise EvidenceError(f"{label} digest or byte count mismatch")
413
+ return {"path": record["path"], "sha256": record["sha256"]}
414
+
415
+
416
+ def validate_manifest(
417
+ work: pathlib.Path, manifest_relative: str, run_id: str, phase: str, round_: int,
418
+ obligations: list[dict] | None = None, *, require_expectations: bool = True,
419
+ ) -> dict:
420
+ expected_relative = (
421
+ f".devlyn/process-evidence/{run_id}/{phase}/round-{round_}/manifest.json"
422
+ )
423
+ if manifest_relative != expected_relative:
424
+ raise EvidenceError("process evidence manifest path does not match run identity")
425
+ manifest_path = _checked_file(work, manifest_relative, "process evidence manifest")
426
+ manifest_raw = manifest_path.read_bytes()
427
+ manifest = _load_manifest(manifest_path, run_id, phase, round_)
428
+ declared = {}
429
+ for raw in obligations or []:
430
+ item = normalize_obligation(raw, phase)
431
+ if item["id"] in declared:
432
+ raise EvidenceError(f"duplicate process evidence obligation id: {phase}:{item['id']}")
433
+ declared[item["id"]] = item
434
+ seen: set[str] = set()
435
+ streams = []
436
+ for index, entry in enumerate(manifest["entries"]):
437
+ label = f"process evidence entry {index}"
438
+ if not isinstance(entry, dict) or set(entry) != {
439
+ "id", "execution", "expectation", "outcome", "classification",
440
+ "stdout", "stderr", "duration_ms", "expectation_met",
441
+ }:
442
+ raise EvidenceError(f"{label} has an invalid shape")
443
+ evidence_id = entry["id"]
444
+ if not isinstance(evidence_id, str) or ID_RE.fullmatch(evidence_id) is None:
445
+ raise EvidenceError(f"{label} id is invalid")
446
+ if evidence_id in seen:
447
+ raise EvidenceError(f"duplicate process evidence id: {phase}:{evidence_id}")
448
+ seen.add(evidence_id)
449
+ execution = entry["execution"]
450
+ expectation = entry["expectation"]
451
+ if not isinstance(execution, dict) or set(execution) != {"command", "argv"}:
452
+ raise EvidenceError(f"{label} execution is invalid")
453
+ normalized = normalize_obligation({
454
+ "id": evidence_id, "phase": phase,
455
+ **({"cmd": execution["command"]} if execution["command"] is not None else
456
+ {"argv": execution["argv"]}),
457
+ **(expectation if isinstance(expectation, dict) else {}),
458
+ }, phase)
459
+ if execution != _execution(normalized) or expectation != _expectation(normalized):
460
+ raise EvidenceError(f"{label} execution or expectation is non-canonical")
461
+ if evidence_id in declared and normalized != declared[evidence_id]:
462
+ raise EvidenceError(f"declared process evidence mismatch: {phase}:{evidence_id}")
463
+ outcome = entry["outcome"]
464
+ classification = entry["classification"]
465
+ if not isinstance(outcome, dict) or set(outcome) != {"kind", "exit_code", "signal"}:
466
+ raise EvidenceError(f"{label} outcome is invalid")
467
+ if not isinstance(classification, dict) or set(classification) != {"kind", "operation"}:
468
+ raise EvidenceError(f"{label} classification is invalid")
469
+ outcome_kind = outcome["kind"]
470
+ if outcome_kind not in {"exit", "signal", "timeout", "spawn_error", "not_run"}:
471
+ raise EvidenceError(f"{label} outcome kind is invalid")
472
+ exit_code = outcome["exit_code"]
473
+ signal_number = outcome["signal"]
474
+ if outcome_kind == "exit":
475
+ if (
476
+ isinstance(exit_code, bool) or not isinstance(exit_code, int)
477
+ or exit_code < 0 or signal_number is not None
478
+ ):
479
+ raise EvidenceError(f"{label} exit outcome is invalid")
480
+ elif outcome_kind == "signal":
481
+ if (
482
+ exit_code is not None or isinstance(signal_number, bool)
483
+ or not isinstance(signal_number, int) or signal_number < 1
484
+ ):
485
+ raise EvidenceError(f"{label} signal outcome is invalid")
486
+ elif exit_code is not None or signal_number is not None:
487
+ raise EvidenceError(f"{label} non-exit outcome is invalid")
488
+ if classification["kind"] == "capability_denied":
489
+ if classification["operation"] not in CAPABILITIES or outcome_kind != "not_run":
490
+ raise EvidenceError(f"{label} capability classification is invalid")
491
+ elif classification != {"kind": "product_result", "operation": None} or outcome_kind == "not_run":
492
+ raise EvidenceError(f"{label} product classification is invalid")
493
+ parent = pathlib.PurePosixPath(manifest_relative).parent
494
+ stdout_record = _validate_stream(
495
+ work, entry["stdout"], (parent / f"{evidence_id}.stdout").as_posix(),
496
+ f"{label} stdout",
497
+ )
498
+ stderr_record = _validate_stream(
499
+ work, entry["stderr"], (parent / f"{evidence_id}.stderr").as_posix(),
500
+ f"{label} stderr",
501
+ )
502
+ stdout = _checked_file(work, stdout_record["path"], f"{label} stdout").read_bytes()
503
+ stderr = _checked_file(work, stderr_record["path"], f"{label} stderr").read_bytes()
504
+ met = _expectation_met(expectation, outcome, stdout, stderr)
505
+ if not isinstance(entry["expectation_met"], bool) or entry["expectation_met"] != met:
506
+ raise EvidenceError(f"{label} expectation result mismatch")
507
+ if isinstance(entry["duration_ms"], bool) or not isinstance(entry["duration_ms"], int) or entry["duration_ms"] < 0:
508
+ raise EvidenceError(f"{label} duration is invalid")
509
+ if require_expectations and not met:
510
+ raise EvidenceError(f"process evidence expectation mismatch: {phase}:{evidence_id}")
511
+ streams.append({"id": evidence_id, "stdout": stdout_record, "stderr": stderr_record})
512
+ missing = sorted(set(declared) - seen)
513
+ if missing:
514
+ raise EvidenceError(f"missing process evidence id(s): {','.join(missing)}")
515
+ return {
516
+ "phase": phase,
517
+ "round": round_,
518
+ "manifest": {"path": manifest_relative, "sha256": _sha256(manifest_raw)},
519
+ "streams": streams,
520
+ }
521
+
522
+
523
+ def validate_bound_carrier(work: pathlib.Path, carrier: object) -> None:
524
+ if not isinstance(carrier, dict) or set(carrier) != {"phase", "round", "manifest", "streams"}:
525
+ raise EvidenceError("state process-evidence carrier has an invalid shape")
526
+ manifest = carrier["manifest"]
527
+ if not isinstance(manifest, dict) or set(manifest) != {"path", "sha256"}:
528
+ raise EvidenceError("state process-evidence manifest binding is invalid")
529
+ raw = _checked_file(work, manifest["path"], "bound process evidence manifest").read_bytes()
530
+ if not isinstance(manifest["sha256"], str) or _sha256(raw) != manifest["sha256"]:
531
+ raise EvidenceError("bound process evidence manifest digest mismatch")
532
+ document = _read_json(_checked_file(work, manifest["path"], "bound process evidence manifest"))
533
+ if not isinstance(document, dict) or not isinstance(document.get("run_id"), str):
534
+ raise EvidenceError("bound process evidence manifest identity is invalid")
535
+ validated = validate_manifest(
536
+ work, manifest["path"], document["run_id"],
537
+ carrier["phase"], carrier["round"], require_expectations=False,
538
+ )
539
+ if validated["streams"] != carrier["streams"]:
540
+ raise EvidenceError("bound process evidence stream binding mismatch")
541
+
542
+
543
+ def bound_carrier_outcome(work: pathlib.Path, carrier: object) -> dict:
544
+ """Derive the verdict floor from the bytes already authenticated by carrier."""
545
+ validate_bound_carrier(work, carrier)
546
+ manifest = carrier["manifest"]
547
+ document = _read_json(
548
+ _checked_file(work, manifest["path"], "bound process evidence manifest")
549
+ )
550
+ failed_ids = []
551
+ capability_denials = []
552
+ for entry in document["entries"]:
553
+ if entry["classification"]["kind"] == "capability_denied":
554
+ capability_denials.append({
555
+ "id": entry["id"],
556
+ "operation": entry["classification"]["operation"],
557
+ "execution": entry["execution"],
558
+ })
559
+ elif not entry["expectation_met"]:
560
+ failed_ids.append(entry["id"])
561
+ verdict = (
562
+ "BLOCKED" if capability_denials else
563
+ "NEEDS_WORK" if failed_ids else
564
+ "PASS"
565
+ )
566
+ return {
567
+ "verdict": verdict,
568
+ "failed_ids": failed_ids,
569
+ "capability_denials": capability_denials,
570
+ }
571
+
572
+
573
+ def bound_carrier_summary_commands(work: pathlib.Path, carrier: object) -> list[dict]:
574
+ """Derive the canonical MECHANICAL summary from sealed manifest bytes."""
575
+ validate_bound_carrier(work, carrier)
576
+ manifest = carrier["manifest"]
577
+ document = _read_json(
578
+ _checked_file(work, manifest["path"], "bound process evidence manifest")
579
+ )
580
+ summaries = []
581
+ for index, entry in enumerate(document["entries"]):
582
+ stdout = _checked_file(
583
+ work, entry["stdout"]["path"], f"process evidence entry {index} stdout",
584
+ ).read_bytes()
585
+ stderr = _checked_file(
586
+ work, entry["stderr"]["path"], f"process evidence entry {index} stderr",
587
+ ).read_bytes()
588
+ expectation = entry["expectation"]
589
+ outcome = entry["outcome"]
590
+ classification = entry["classification"]
591
+ combined = stdout + stderr
592
+ actual_exit = outcome["exit_code"] if outcome["kind"] == "exit" else None
593
+ if classification["kind"] == "capability_denied":
594
+ reason = "capability_denied"
595
+ elif entry["expectation_met"]:
596
+ reason = None
597
+ elif outcome["kind"] == "timeout":
598
+ reason = "timeout"
599
+ elif outcome["kind"] != "exit" or actual_exit != expectation["exit_code"]:
600
+ reason = "exit"
601
+ elif not all(
602
+ marker.encode("utf-8") in combined
603
+ for marker in expectation["stdout_contains"]
604
+ ):
605
+ reason = "missing_contains"
606
+ else:
607
+ reason = "unexpected_text"
608
+ summaries.append({
609
+ "index": index,
610
+ "cmd": entry["execution"]["command"],
611
+ "expected_exit": expectation["exit_code"],
612
+ "actual_exit": actual_exit,
613
+ "timeout_sec": expectation["timeout_sec"],
614
+ "stdout_contains": expectation["stdout_contains"],
615
+ "stdout_not_contains": expectation["stdout_not_contains"],
616
+ "pass": entry["expectation_met"],
617
+ "reason": reason,
618
+ "evidence_id": entry["id"],
619
+ "outcome": outcome,
620
+ "classification": classification,
621
+ "stdout": entry["stdout"],
622
+ "stderr": entry["stderr"],
623
+ })
624
+ return summaries
625
+
626
+
627
+ def validate_summary_commands(
628
+ work: pathlib.Path, commands: object, carrier: object,
629
+ ) -> dict:
630
+ """Cross-check the mutable MECHANICAL summary against sealed manifest bytes."""
631
+ if not isinstance(commands, list):
632
+ raise EvidenceError("spec-verify.results.json commands must be an array")
633
+ expected_commands = bound_carrier_summary_commands(work, carrier)
634
+ if len(commands) != len(expected_commands):
635
+ raise EvidenceError(
636
+ "spec-verify.results.json command count disagrees with sealed process evidence"
637
+ )
638
+ for index, (summary, expected) in enumerate(zip(commands, expected_commands)):
639
+ if not isinstance(summary, dict):
640
+ raise EvidenceError(f"spec-verify.results.json command {index} is not an object")
641
+ if summary != expected:
642
+ raise EvidenceError(
643
+ f"spec-verify.results.json command {index} disagrees with sealed process evidence"
644
+ )
645
+ return bound_carrier_outcome(work, carrier)
646
+
647
+
648
+ def _load_state(devlyn: pathlib.Path) -> dict:
649
+ state = _read_json(devlyn / "pipeline.state.json")
650
+ if not isinstance(state, dict):
651
+ raise EvidenceError("pipeline.state.json must contain an object")
652
+ return state
653
+
654
+
655
+ def _declared_by_id(work: pathlib.Path, state: dict, phase: str, evidence_id: str) -> dict:
656
+ matches = [item for item in declared_obligations(work, state, phase) if item["id"] == evidence_id]
657
+ if len(matches) != 1:
658
+ raise EvidenceError(f"undeclared process evidence id: {phase}:{evidence_id}")
659
+ return matches[0]
660
+
661
+
662
+ def self_test() -> int:
663
+ try:
664
+ loads_strict_json('{"run_id":"a","run_id":"b"}')
665
+ except ValueError as exc:
666
+ assert "duplicate JSON key" in str(exc)
667
+ else:
668
+ raise AssertionError("duplicate process-evidence JSON key was accepted")
669
+ with tempfile.TemporaryDirectory() as raw_tmp:
670
+ work = pathlib.Path(raw_tmp)
671
+ devlyn = work / ".devlyn"
672
+ devlyn.mkdir()
673
+ state = {
674
+ "run_id": "rs-self-test",
675
+ "phases": {"implement": {"round": 0}},
676
+ }
677
+ manifest_rel = manifest_relative_path(state, "implement")
678
+ manifest = work / manifest_rel
679
+ obligation = normalize_obligation({
680
+ "id": "red-first", "phase": "implement",
681
+ "cmd": "printf raw-out; printf raw-err >&2; exit 7",
682
+ "exit_code": 7, "stdout_contains": ["raw-outraw-err"],
683
+ })
684
+ entry = capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
685
+ assert entry["expectation_met"]
686
+ assert (manifest.parent / "red-first.stdout").read_bytes() == b"raw-out"
687
+ assert (manifest.parent / "red-first.stderr").read_bytes() == b"raw-err"
688
+ empty = normalize_obligation({
689
+ "id": "empty-streams", "phase": "implement",
690
+ "argv": [sys.executable, "-c", "pass"],
691
+ })
692
+ empty_entry = capture_process(
693
+ work, manifest, state["run_id"], "implement", 0, empty,
694
+ )
695
+ assert empty_entry["expectation_met"]
696
+ assert empty_entry["stdout"]["bytes"] == empty_entry["stderr"]["bytes"] == 0
697
+ assert (manifest.parent / "empty-streams.stdout").is_file()
698
+ assert (manifest.parent / "empty-streams.stderr").is_file()
699
+ carrier = validate_manifest(
700
+ work, manifest_rel, state["run_id"], "implement", 0, [obligation, empty],
701
+ )
702
+ assert carrier["manifest"]["sha256"] == _sha256(manifest.read_bytes())
703
+ summaries = bound_carrier_summary_commands(work, carrier)
704
+ assert validate_summary_commands(work, summaries, carrier)["verdict"] == "PASS"
705
+ summaries[0]["pass"] = False
706
+ try:
707
+ validate_summary_commands(work, summaries, carrier)
708
+ except EvidenceError as exc:
709
+ assert "command 0 disagrees" in str(exc)
710
+ else:
711
+ raise AssertionError("mutable command summary contradicted sealed process evidence")
712
+ try:
713
+ capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
714
+ except EvidenceError as exc:
715
+ assert "duplicate process evidence id" in str(exc)
716
+ else:
717
+ raise AssertionError("duplicate evidence id was accepted")
718
+ print("PASS process evidence raw streams, digest binding, and duplicate-id guard")
719
+
720
+ stderr_path = manifest.parent / "red-first.stderr"
721
+ stderr_path.write_bytes(b"altered")
722
+ try:
723
+ validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [obligation])
724
+ except EvidenceError as exc:
725
+ assert "digest or byte count mismatch" in str(exc)
726
+ else:
727
+ raise AssertionError("altered raw stream was accepted")
728
+ stderr_path.write_bytes(b"raw-err")
729
+ validate_bound_carrier(work, carrier)
730
+ manifest.write_bytes(manifest.read_bytes() + b" ")
731
+ try:
732
+ validate_bound_carrier(work, carrier)
733
+ except EvidenceError as exc:
734
+ assert "manifest digest mismatch" in str(exc)
735
+ else:
736
+ raise AssertionError("altered bound manifest was accepted")
737
+ print("PASS process evidence raw and manifest mutation detection")
738
+
739
+ with tempfile.TemporaryDirectory() as raw_tmp:
740
+ work = pathlib.Path(raw_tmp)
741
+ state = {"run_id": "rs-path-test", "phases": {"implement": {"round": 0}}}
742
+ manifest_rel = manifest_relative_path(state, "implement")
743
+ manifest = work / manifest_rel
744
+ obligation = normalize_obligation({
745
+ "id": "path-test", "phase": "implement", "argv": [sys.executable, "-c", "pass"],
746
+ })
747
+ capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
748
+ document = _read_json(manifest)
749
+ document["entries"][0]["stdout"]["path"] = "../escape"
750
+ _atomic_write(manifest, _json_bytes(document))
751
+ try:
752
+ validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [obligation])
753
+ except EvidenceError as exc:
754
+ assert "path mismatch" in str(exc)
755
+ else:
756
+ raise AssertionError("path-escaping evidence was accepted")
757
+ document["entries"][0]["stdout"]["path"] = (
758
+ pathlib.PurePosixPath(manifest_rel).parent / "path-test.stdout"
759
+ ).as_posix()
760
+ document["entries"].append(document["entries"][0])
761
+ _atomic_write(manifest, _json_bytes(document))
762
+ try:
763
+ validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [obligation])
764
+ except EvidenceError as exc:
765
+ assert "duplicate" in str(exc)
766
+ else:
767
+ raise AssertionError("duplicate manifest id was accepted")
768
+ print("PASS process evidence path and manifest duplicate-id rejection")
769
+
770
+ with tempfile.TemporaryDirectory() as raw_tmp:
771
+ work = pathlib.Path(raw_tmp)
772
+ state = {"run_id": "rs-expect-test", "phases": {"implement": {"round": 0}}}
773
+ manifest_rel = manifest_relative_path(state, "implement")
774
+ manifest = work / manifest_rel
775
+ mismatch = normalize_obligation({
776
+ "id": "mismatch", "phase": "implement", "cmd": "exit 0", "exit_code": 9,
777
+ })
778
+ entry = capture_process(work, manifest, state["run_id"], "implement", 0, mismatch)
779
+ assert not entry["expectation_met"]
780
+ try:
781
+ validate_manifest(work, manifest_rel, state["run_id"], "implement", 0, [mismatch])
782
+ except EvidenceError as exc:
783
+ assert "expectation mismatch" in str(exc)
784
+ else:
785
+ raise AssertionError("expectation-mismatched evidence was accepted")
786
+ mismatch_carrier = validate_manifest(
787
+ work, manifest_rel, state["run_id"], "implement", 0,
788
+ require_expectations=False,
789
+ )
790
+ assert bound_carrier_outcome(work, mismatch_carrier) == {
791
+ "verdict": "NEEDS_WORK",
792
+ "failed_ids": ["mismatch"],
793
+ "capability_denials": [],
794
+ }
795
+ signaled = normalize_obligation({
796
+ "id": "signaled", "phase": "implement",
797
+ "argv": [
798
+ sys.executable, "-c",
799
+ "import os, signal; os.kill(os.getpid(), signal.SIGTERM)",
800
+ ],
801
+ })
802
+ signal_entry = capture_process(
803
+ work, manifest, state["run_id"], "implement", 0, signaled,
804
+ )
805
+ assert signal_entry["outcome"] == {
806
+ "kind": "signal", "exit_code": None, "signal": signal.SIGTERM,
807
+ }
808
+ validate_manifest(
809
+ work, manifest_rel, state["run_id"], "implement", 0,
810
+ require_expectations=False,
811
+ )
812
+ print("PASS process evidence exit expectation rejection and signal capture")
813
+
814
+ with tempfile.TemporaryDirectory() as raw_tmp:
815
+ work = pathlib.Path(raw_tmp)
816
+ state = {"run_id": "rs-cap-test", "phases": {"build_gate": {"round": 0}}}
817
+ manifest_rel = manifest_relative_path(state, "build_gate")
818
+ manifest = work / manifest_rel
819
+ product = normalize_obligation({
820
+ "id": "product", "phase": "build_gate",
821
+ "cmd": "printf 'Operation not permitted' >&2; exit 1",
822
+ })
823
+ denied = normalize_obligation({
824
+ "id": "denied", "phase": "build_gate", "cmd": "python3 -m pytest",
825
+ })
826
+ product_entry = capture_process(
827
+ work, manifest, state["run_id"], "build_gate", 0, product,
828
+ )
829
+ denied_entry = record_capability_denial(
830
+ work, manifest, state["run_id"], "build_gate", 0, denied,
831
+ "subprocess", b"parent route denied subprocess creation",
832
+ )
833
+ assert product_entry["classification"] == {"kind": "product_result", "operation": None}
834
+ assert denied_entry["classification"] == {
835
+ "kind": "capability_denied", "operation": "subprocess",
836
+ }
837
+ denied_carrier = validate_manifest(
838
+ work, manifest_rel, state["run_id"], "build_gate", 0,
839
+ require_expectations=False,
840
+ )
841
+ denied_outcome = bound_carrier_outcome(work, denied_carrier)
842
+ assert denied_outcome["verdict"] == "BLOCKED"
843
+ assert denied_outcome["capability_denials"] == [{
844
+ "id": "denied",
845
+ "operation": "subprocess",
846
+ "execution": {"command": "python3 -m pytest", "argv": None},
847
+ }]
848
+ print("PASS process evidence explicit capability classification without stderr heuristics")
849
+ return 0
850
+
851
+
852
+ def main() -> int:
853
+ parser = argparse.ArgumentParser(description=__doc__)
854
+ parser.add_argument("--workdir", default=".")
855
+ parser.add_argument("--devlyn-dir", default=".devlyn")
856
+ parser.add_argument("--self-test", action="store_true")
857
+ subparsers = parser.add_subparsers(dest="action")
858
+ run_parser = subparsers.add_parser("run")
859
+ run_parser.add_argument("--phase", choices=sorted(PHASES), required=True)
860
+ run_parser.add_argument("--id", required=True)
861
+ deny_parser = subparsers.add_parser("record-capability-denial")
862
+ deny_parser.add_argument("--phase", choices=sorted(PHASES), required=True)
863
+ deny_parser.add_argument("--id", required=True)
864
+ deny_parser.add_argument("--operation", choices=sorted(CAPABILITIES), required=True)
865
+ deny_parser.add_argument("--detail", required=True)
866
+ validate_parser = subparsers.add_parser("validate")
867
+ validate_parser.add_argument("--phase", choices=sorted(PHASES), required=True)
868
+ args = parser.parse_args()
869
+ if args.self_test:
870
+ return self_test()
871
+ if args.action is None:
872
+ parser.error("an action is required unless --self-test")
873
+ try:
874
+ work = pathlib.Path(args.workdir).resolve()
875
+ devlyn_arg = pathlib.Path(args.devlyn_dir)
876
+ devlyn = devlyn_arg.resolve() if devlyn_arg.is_absolute() else (work / devlyn_arg).resolve()
877
+ devlyn.relative_to(work)
878
+ state = _load_state(devlyn)
879
+ phase = args.phase
880
+ round_ = phase_round(state, phase)
881
+ manifest_rel = manifest_relative_path(state, phase)
882
+ manifest = work / manifest_rel
883
+ obligations = declared_obligations(work, state, phase)
884
+ if args.action == "validate":
885
+ carrier = validate_manifest(
886
+ work, manifest_rel, state["run_id"], phase, round_, obligations,
887
+ )
888
+ sys.stdout.write(json.dumps(carrier, sort_keys=True) + "\n")
889
+ return 0
890
+ obligation = _declared_by_id(work, state, phase, args.id)
891
+ if args.action == "run":
892
+ entry = capture_process(work, manifest, state["run_id"], phase, round_, obligation)
893
+ else:
894
+ entry = record_capability_denial(
895
+ work, manifest, state["run_id"], phase, round_, obligation,
896
+ args.operation, args.detail.encode("utf-8"),
897
+ )
898
+ sys.stdout.write(json.dumps({
899
+ "id": entry["id"], "expectation_met": entry["expectation_met"],
900
+ "manifest_path": manifest_rel, "classification": entry["classification"],
901
+ }, sort_keys=True) + "\n")
902
+ return 0 if entry["expectation_met"] else 1
903
+ except (EvidenceError, OSError, UnicodeError, ValueError) as exc:
904
+ sys.stderr.write(f"BLOCKED:process-evidence-invalid: {exc}\n")
905
+ return 2
906
+
907
+
908
+ if __name__ == "__main__":
909
+ raise SystemExit(main())