devlyn-cli 4.1.0 → 4.2.1

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