devlyn-cli 3.1.0 → 3.1.2

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 (32) hide show
  1. package/README.md +6 -1
  2. package/bin/devlyn.js +69 -23
  3. package/config/skills/_shared/adapters/claude.md +3 -2
  4. package/config/skills/_shared/adapters/codex.md +1 -1
  5. package/config/skills/_shared/archive_run.py +5 -3
  6. package/config/skills/_shared/codex-config.md +13 -8
  7. package/config/skills/_shared/codex-monitored.sh +12 -19
  8. package/config/skills/_shared/collect-codex-findings.py +1 -0
  9. package/config/skills/_shared/engine-preflight.md +2 -0
  10. package/config/skills/_shared/finish-gate.py +4 -1
  11. package/config/skills/_shared/invocation-receipt.py +194 -5
  12. package/config/skills/_shared/judge-role-evidence.py +33 -6
  13. package/config/skills/_shared/phase-prompt-render.py +2 -0
  14. package/config/skills/_shared/platform-support.py +370 -0
  15. package/config/skills/_shared/process-evidence.py +20 -3
  16. package/config/skills/_shared/resolve-bootstrap.py +131 -63
  17. package/config/skills/_shared/resolve-stop-hook.py +3 -1
  18. package/config/skills/_shared/role-config.py +7 -5
  19. package/config/skills/_shared/run-bounded.py +35 -21
  20. package/config/skills/_shared/spec-verify-check.py +393 -167
  21. package/config/skills/_shared/state-phase-write.py +26 -9
  22. package/config/skills/_shared/task-complete.py +337 -97
  23. package/config/skills/_shared/verify-merge-findings.py +9 -8
  24. package/config/skills/devlyn:ideate/references/spec-template.md +1 -1
  25. package/config/skills/devlyn:resolve/SKILL.md +10 -7
  26. package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
  27. package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
  28. package/config/skills/devlyn:resolve/references/phases/verify.md +14 -10
  29. package/config/skills/devlyn:resolve/references/state-schema.md +1 -1
  30. package/config/skills/devlyn:resolve/references/task-completion.md +28 -1
  31. package/package.json +1 -1
  32. package/scripts/lint-skills.sh +6 -5
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """Native lock, argv, process and UTF-8 boundaries shared by harness callers."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import errno
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import shutil
12
+ import signal
13
+ import stat
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+
19
+ def configure_utf8():
20
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
21
+ if hasattr(stream, "reconfigure"):
22
+ stream.reconfigure(encoding="utf-8", errors="strict")
23
+
24
+
25
+ def open_stdin(path):
26
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0))
27
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
28
+ os.close(fd)
29
+ raise ValueError(f"stdin file is not a regular file: {path}")
30
+ return os.fdopen(fd, "rb")
31
+
32
+
33
+ @contextlib.contextmanager
34
+ def file_lock(path, *, blocking=False):
35
+ with Path(path).open("a+b") as handle:
36
+ if os.name == "nt":
37
+ import msvcrt
38
+ while True:
39
+ handle.seek(0)
40
+ try:
41
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
42
+ break
43
+ except OSError as exc:
44
+ # CRT maps ERROR_LOCK_VIOLATION to EACCES (winerror may be absent).
45
+ contended = exc.errno == errno.EACCES and getattr(exc, "winerror", None) in (None, 33)
46
+ if not contended:
47
+ raise
48
+ if not blocking:
49
+ raise BlockingIOError(errno.EAGAIN, "lock occupied", str(path)) from exc
50
+ time.sleep(0.05)
51
+ try:
52
+ yield
53
+ finally:
54
+ handle.seek(0)
55
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
56
+ else:
57
+ import fcntl
58
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
59
+ try:
60
+ yield
61
+ finally:
62
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
63
+
64
+
65
+ def native_argv(argv):
66
+ """Resolve supported npm engine shims to their package's Node bin, never cmd /c."""
67
+ if os.name != "nt":
68
+ return list(argv)
69
+ binary = shutil.which(argv[0])
70
+ if binary is None:
71
+ raise FileNotFoundError(f"executable unavailable: {argv[0]}")
72
+ shim = Path(binary)
73
+ if shim.suffix.lower() not in {".cmd", ".bat"}:
74
+ return [binary, *argv[1:]]
75
+ packages = {"codex": "@openai/codex", "claude": "@anthropic-ai/claude-code"}
76
+ package = packages.get(shim.stem.lower())
77
+ if shim.suffix.lower() != ".cmd" or package is None:
78
+ raise OSError(f"unsupported native command shim: {shim}")
79
+ root = (shim.parent.parent if shim.parent.name == ".bin" else shim.parent / "node_modules") / package
80
+ try:
81
+ manifest = json.loads((root / "package.json").read_text(encoding="utf-8"))
82
+ except (ValueError, UnicodeError) as exc:
83
+ raise OSError(f"invalid npm engine manifest: {shim}") from exc
84
+ if not isinstance(manifest, dict):
85
+ raise OSError(f"invalid npm engine manifest: {shim}")
86
+ entry = manifest.get("bin")
87
+ entry = entry.get(shim.stem.lower()) if isinstance(entry, dict) else entry
88
+ if manifest.get("name") != package or not isinstance(entry, str):
89
+ raise OSError(f"invalid npm engine bin: {shim}")
90
+ target = (root / entry).resolve(strict=True)
91
+ if not target.is_relative_to(root.resolve()) or target.suffix not in {".js", ".cjs", ".mjs"}:
92
+ raise OSError(f"unsupported npm engine bin: {target}")
93
+ raw = shim.read_text(encoding="utf-8")
94
+ matches = re.findall(r'"%_prog%"\s+"%dp0%\\([^"\r\n]+)"\s+%\*', raw)
95
+ if len(matches) != 1 or (shim.parent / matches[0]).resolve() != target:
96
+ raise OSError(f"unrecognized npm engine shim: {shim}")
97
+ node = shim.parent / "node.exe"
98
+ binary = str(node) if node.is_file() else shutil.which("node.exe")
99
+ if binary is None:
100
+ raise FileNotFoundError(f"node.exe unavailable for {shim}")
101
+ return [binary, str(target), *argv[1:]]
102
+
103
+
104
+ def system_exit_code(code):
105
+ # CPython SystemExit uses signed C long (32 bits on Windows), unlike DWORD.
106
+ return code - 0x100000000 if os.name == "nt" and code >= 0x80000000 else code
107
+
108
+
109
+ if os.name == "nt":
110
+ import ctypes
111
+ from ctypes import wintypes
112
+ import msvcrt
113
+
114
+ class _BasicLimits(ctypes.Structure):
115
+ _fields_ = [("PerProcessUserTimeLimit", ctypes.c_longlong),
116
+ ("PerJobUserTimeLimit", ctypes.c_longlong),
117
+ ("LimitFlags", wintypes.DWORD),
118
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
119
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
120
+ ("ActiveProcessLimit", wintypes.DWORD),
121
+ ("Affinity", ctypes.c_size_t),
122
+ ("PriorityClass", wintypes.DWORD),
123
+ ("SchedulingClass", wintypes.DWORD)]
124
+
125
+ class _ExtendedLimits(ctypes.Structure):
126
+ _fields_ = [("BasicLimitInformation", _BasicLimits),
127
+ ("IoInfo", ctypes.c_ulonglong * 6),
128
+ ("ProcessMemoryLimit", ctypes.c_size_t),
129
+ ("JobMemoryLimit", ctypes.c_size_t),
130
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
131
+ ("PeakJobMemoryUsed", ctypes.c_size_t)]
132
+
133
+ class _JobProcessIds(ctypes.Structure):
134
+ _fields_ = [("NumberOfAssignedProcesses", wintypes.DWORD),
135
+ ("NumberOfProcessIdsInList", wintypes.DWORD),
136
+ ("ProcessIdList", ctypes.c_size_t * 1)]
137
+
138
+ def _checked(result, function, _args):
139
+ if not result:
140
+ error = ctypes.get_last_error()
141
+ raise ctypes.WinError(error, f"{function.__name__}: {ctypes.FormatError(error)}")
142
+ return result
143
+
144
+ _kernel = ctypes.WinDLL("kernel32", use_last_error=True)
145
+ for _name, _args, _result in (
146
+ ("CreateJobObjectW", [ctypes.c_void_p, wintypes.LPCWSTR], wintypes.HANDLE),
147
+ ("SetInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD], wintypes.BOOL),
148
+ ("QueryInformationJobObject", [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p], wintypes.BOOL),
149
+ ("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
150
+ ("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
151
+ ("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
152
+ ("IsProcessInJob", [wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL)], wintypes.BOOL),
153
+ ("GetCurrentProcess", [], wintypes.HANDLE),
154
+ ("DuplicateHandle", [wintypes.HANDLE, wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.BOOL),
155
+ ("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
156
+ ("PeekNamedPipe", [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p], wintypes.BOOL),
157
+ ):
158
+ _function = getattr(_kernel, _name)
159
+ _function.argtypes, _function.restype, _function.errcheck = _args, _result, _checked
160
+
161
+ _kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
162
+ _kernel.WaitForSingleObject.restype = wintypes.DWORD # WAIT_OBJECT_0 is successful zero, not BOOL.
163
+
164
+ def _read_control(fd, count):
165
+ deadline = time.monotonic() + 10
166
+ data = b""
167
+ while len(data) < count:
168
+ available = wintypes.DWORD()
169
+ _kernel.PeekNamedPipe(msvcrt.get_osfhandle(fd), None, 0, None, ctypes.byref(available), None)
170
+ if available.value:
171
+ data += os.read(fd, min(count - len(data), available.value))
172
+ elif time.monotonic() >= deadline:
173
+ raise OSError("Windows job launch handshake timed out")
174
+ else:
175
+ time.sleep(0.01)
176
+ return data
177
+
178
+ class _WindowsJob:
179
+ def __init__(self):
180
+ self.handle = None
181
+ self.child = None
182
+ self.creating = False
183
+ self.interrupted_signal = None
184
+
185
+ def start(self, argv, stdin):
186
+ self.handle = _kernel.CreateJobObjectW(None, None)
187
+ limits = _ExtendedLimits()
188
+ limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE; no breakaway.
189
+ _kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))
190
+ with contextlib.ExitStack() as controls, contextlib.ExitStack() as inherited_handles:
191
+ inherited_job = wintypes.HANDLE()
192
+ current = _kernel.GetCurrentProcess()
193
+ _kernel.DuplicateHandle(current, self.handle, current, ctypes.byref(inherited_job), 0, True, 2)
194
+ inherited_handles.callback(_kernel.CloseHandle, inherited_job)
195
+ ready_read, ready_write = os.pipe()
196
+ controls.callback(os.close, ready_read); inherited_handles.callback(os.close, ready_write)
197
+ allow_read, allow_write = os.pipe()
198
+ inherited_handles.callback(os.close, allow_read); controls.callback(os.close, allow_write)
199
+ inherited = [inherited_job.value, msvcrt.get_osfhandle(ready_write), msvcrt.get_osfhandle(allow_read)]
200
+ for handle in inherited[1:]:
201
+ os.set_handle_inheritable(handle, True)
202
+ startup = subprocess.STARTUPINFO()
203
+ startup.lpAttributeList = {"handle_list": inherited}
204
+ command = [sys.executable, "-I", "-S", str(Path(__file__).resolve()), "--job-bootstrap",
205
+ str(inherited[0]), str(inherited[2]), str(inherited[1]), *argv]
206
+ # Retain the Popen object even if __init__ raises after native creation.
207
+ self.child = subprocess.Popen.__new__(subprocess.Popen)
208
+ self.creating = True
209
+ try:
210
+ self.child.__init__(command, stdin=stdin, startupinfo=startup, close_fds=True)
211
+ finally:
212
+ self.creating = False
213
+ if self.interrupted_signal is not None:
214
+ raise SystemExit(128 + self.interrupted_signal)
215
+ inherited_handles.close()
216
+ if _read_control(ready_read, 1) != b"R":
217
+ raise OSError("Windows job enrollment did not report ready")
218
+ os.write(allow_write, b"G")
219
+ self.target_pid = int.from_bytes(_read_control(ready_read, 4), "little")
220
+ return self.child
221
+
222
+ def terminate(self):
223
+ try:
224
+ # Stop startup first so no new enrollment/dispatch can race job quiescence.
225
+ if self.child is not None and getattr(self.child, "_child_created", False):
226
+ try:
227
+ if self.child.poll() is None:
228
+ self.child.kill()
229
+ self.child.wait(timeout=5)
230
+ finally:
231
+ self.child._handle.Close()
232
+ finally:
233
+ if self.handle is not None:
234
+ with contextlib.ExitStack() as handles:
235
+ handles.callback(_kernel.CloseHandle, self.handle)
236
+ limits = _ExtendedLimits() # Zero ActiveProcessLimit: no freed slot admits a new child.
237
+ limits.BasicLimitInformation.LimitFlags = 0x2008 # KILL_ON_JOB_CLOSE | ACTIVE_PROCESS.
238
+ _kernel.SetInformationJobObject(self.handle, 9, ctypes.byref(limits), ctypes.sizeof(limits))
239
+ capacity = 1
240
+ while True:
241
+ buffer = ctypes.create_string_buffer(_JobProcessIds.ProcessIdList.offset + capacity * ctypes.sizeof(ctypes.c_size_t))
242
+ ids = _JobProcessIds.from_buffer(buffer)
243
+ try:
244
+ _kernel.QueryInformationJobObject(self.handle, 3, buffer, len(buffer), None)
245
+ break
246
+ except OSError as exc:
247
+ if exc.winerror != 234: # ERROR_MORE_DATA: never accept a partial list.
248
+ raise
249
+ capacity = max(capacity * 2, ids.NumberOfAssignedProcesses)
250
+ members = []
251
+ pids = (ctypes.c_size_t * ids.NumberOfProcessIdsInList).from_buffer(buffer, _JobProcessIds.ProcessIdList.offset)
252
+ for pid in pids:
253
+ try:
254
+ process = _kernel.OpenProcess(0x101000, False, pid) # SYNCHRONIZE | QUERY_LIMITED_INFORMATION.
255
+ except OSError as exc:
256
+ if exc.winerror == 87: # Already gone; other open failures remain visible.
257
+ continue
258
+ raise
259
+ handles.callback(_kernel.CloseHandle, process)
260
+ member = wintypes.BOOL()
261
+ _kernel.IsProcessInJob(process, self.handle, ctypes.byref(member))
262
+ if member.value: # A reused PID in another job is not our process identity.
263
+ members.append((pid, process))
264
+ # Natural exits before capture do not prove pending I/O or historical object finalization.
265
+ _kernel.TerminateJobObject(self.handle, 1)
266
+ deadline = time.monotonic() + 5
267
+ for pid, process in members:
268
+ remaining = max(0, int((deadline - time.monotonic()) * 1000))
269
+ result = _kernel.WaitForSingleObject(process, remaining)
270
+ if result == 258: # WAIT_TIMEOUT.
271
+ raise OSError(f"Windows job teardown timed out waiting for process {pid}")
272
+ if result == 0xFFFFFFFF: # WAIT_FAILED.
273
+ error = ctypes.get_last_error()
274
+ raise ctypes.WinError(error, f"WaitForSingleObject: {ctypes.FormatError(error)}")
275
+ if result != 0:
276
+ raise OSError(f"WaitForSingleObject: unexpected result {result} for process {pid}")
277
+
278
+ def _job_bootstrap(argv):
279
+ job, allow, ready = map(int, argv[:3])
280
+ with contextlib.ExitStack() as handles:
281
+ allow_fd = msvcrt.open_osfhandle(allow, os.O_RDONLY | os.O_BINARY)
282
+ handles.callback(os.close, allow_fd)
283
+ ready_fd = msvcrt.open_osfhandle(ready, os.O_WRONLY | os.O_BINARY)
284
+ handles.callback(os.close, ready_fd)
285
+ try:
286
+ _kernel.AssignProcessToJobObject(job, _kernel.GetCurrentProcess())
287
+ finally:
288
+ _kernel.CloseHandle(job)
289
+ os.write(ready_fd, b"R")
290
+ if _read_control(allow_fd, 1) != b"G":
291
+ raise OSError("Windows job launch was not authorized")
292
+ with subprocess.Popen(argv[3:], stdin=sys.stdin.buffer, close_fds=True) as target:
293
+ os.write(ready_fd, target.pid.to_bytes(4, "little"))
294
+ return target.wait()
295
+
296
+
297
+ def terminate_tree(child, job=None):
298
+ if job is not None:
299
+ job.terminate()
300
+ return
301
+ try:
302
+ os.killpg(child.pid, signal.SIGTERM)
303
+ except ProcessLookupError:
304
+ child.wait()
305
+ return
306
+ # Reap the leader and retain the grace period for its surviving descendants.
307
+ deadline = time.monotonic() + 5
308
+ try:
309
+ child.wait(timeout=5)
310
+ except subprocess.TimeoutExpired:
311
+ pass # The leader also needs the final group KILL below.
312
+ time.sleep(max(0, deadline - time.monotonic()))
313
+ try:
314
+ os.killpg(child.pid, signal.SIGKILL)
315
+ except ProcessLookupError:
316
+ pass # Every member already exited during the grace period.
317
+ child.wait()
318
+
319
+
320
+ def run_process(argv, stdin, seconds, *, heartbeat=0):
321
+ child = None
322
+ job = _WindowsJob() if os.name == "nt" else None
323
+ previous = {}
324
+
325
+ def interrupted(signum, _frame):
326
+ # CPython stores the native handle only after CreateProcess's finally block.
327
+ if job is not None and job.creating:
328
+ job.interrupted_signal = signum
329
+ return
330
+ raise SystemExit(128 + signum)
331
+
332
+ try:
333
+ for sig in (signal.SIGTERM, signal.SIGINT):
334
+ previous[sig] = signal.signal(sig, interrupted)
335
+ child = job.start(argv, stdin) if job is not None else subprocess.Popen(argv, stdin=stdin, start_new_session=True)
336
+ started = time.monotonic()
337
+ next_heartbeat = started + heartbeat
338
+ if heartbeat:
339
+ print(f"[codex-monitored] codex pid={job.target_pid if job is not None else child.pid}", file=sys.stderr, flush=True)
340
+ while True:
341
+ try:
342
+ return child.wait(timeout=0.1)
343
+ except subprocess.TimeoutExpired:
344
+ now = time.monotonic()
345
+ if seconds and now - started >= seconds:
346
+ if heartbeat:
347
+ print(f"[codex-monitored] timeout: elapsed={int(now-started)}s limit={seconds}s", file=sys.stderr, flush=True)
348
+ return 124
349
+ if heartbeat and now >= next_heartbeat:
350
+ print(f"[codex-monitored] heartbeat: elapsed={int(now-started)}s", file=sys.stderr, flush=True)
351
+ next_heartbeat = now + heartbeat
352
+ finally:
353
+ try:
354
+ if job is not None or child is not None:
355
+ terminate_tree(child, job)
356
+ finally:
357
+ for sig, handler in previous.items():
358
+ signal.signal(sig, handler)
359
+
360
+
361
+ if __name__ == "__main__" and os.name == "nt":
362
+ configure_utf8()
363
+ try:
364
+ if sys.argv[1:2] != ["--job-bootstrap"]:
365
+ raise ValueError("expected internal Windows job bootstrap")
366
+ code = _job_bootstrap(sys.argv[2:])
367
+ except (OSError, ValueError) as exc:
368
+ print(f"error: {exc}", file=sys.stderr)
369
+ code = 2
370
+ raise SystemExit(system_exit_code(code))
@@ -2,6 +2,7 @@
2
2
  """Capture and validate run-scoped process evidence without third-party dependencies."""
3
3
  from __future__ import annotations
4
4
 
5
+ import runpy
5
6
  import argparse
6
7
  import hashlib
7
8
  import json
@@ -184,7 +185,7 @@ def _source_expected_path(work: pathlib.Path, state: dict) -> pathlib.Path | Non
184
185
  if not isinstance(spec_path, str) or not spec_path:
185
186
  raise EvidenceError("state.source.spec_path is missing")
186
187
  spec_rel = _safe_relative_path(spec_path, "state.source.spec_path")
187
- expected = work.joinpath(*spec_rel.parts).with_suffix(".expected.json")
188
+ expected = work.joinpath(*spec_rel.parts).with_name("spec.expected.json")
188
189
  try:
189
190
  expected.resolve().relative_to(work.resolve())
190
191
  except (OSError, ValueError) as exc:
@@ -358,7 +359,7 @@ def capture_process(
358
359
  started = time.monotonic()
359
360
  try:
360
361
  proc = subprocess.run(
361
- item.get("cmd") if "cmd" in item else item["argv"],
362
+ item.get("cmd") if "cmd" in item else runpy.run_path(pathlib.Path(__file__).with_name("platform-support.py"))["native_argv"](item["argv"]),
362
363
  cwd=work, shell="cmd" in item, capture_output=True,
363
364
  timeout=item["timeout_sec"], check=False,
364
365
  )
@@ -374,7 +375,7 @@ def capture_process(
374
375
  outcome = {"kind": "timeout", "exit_code": None, "signal": None}
375
376
  except OSError as exc:
376
377
  stdout = b""
377
- stderr = os.fsencode(str(exc))
378
+ stderr = str(exc).encode("utf-8")
378
379
  outcome = {"kind": "spawn_error", "exit_code": None, "signal": None}
379
380
  return _append_entry(
380
381
  work, manifest_path, run_id, phase, round_, item["id"], _execution(item),
@@ -681,6 +682,21 @@ def self_test() -> int:
681
682
  "cmd": "printf raw-out; printf raw-err >&2; exit 7",
682
683
  "exit_code": 7, "stdout_contains": ["raw-outraw-err"],
683
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)
684
700
  entry = capture_process(work, manifest, state["run_id"], "implement", 0, obligation)
685
701
  assert entry["expectation_met"]
686
702
  assert (manifest.parent / "red-first.stdout").read_bytes() == b"raw-out"
@@ -906,4 +922,5 @@ def main() -> int:
906
922
 
907
923
 
908
924
  if __name__ == "__main__":
925
+ runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
909
926
  raise SystemExit(main())