devlyn-cli 2.9.2 → 2.10.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.
- package/AGENTS.md +3 -3
- package/CLAUDE.md +4 -4
- package/README.md +6 -6
- package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
- package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
- package/bin/devlyn.js +68 -7
- package/config/skills/_shared/adapters/README.md +2 -2
- package/config/skills/_shared/adapters/grok.md +77 -0
- package/config/skills/_shared/archive_run.py +35 -1
- package/config/skills/_shared/collect-codex-findings.py +15 -10
- package/config/skills/_shared/engine-doctor.sh +6 -3
- package/config/skills/_shared/engine-preflight.md +12 -5
- package/config/skills/_shared/resolve-bootstrap.py +630 -0
- package/config/skills/_shared/resolve-stop-hook.py +325 -0
- package/config/skills/_shared/state-phase-write.py +2284 -41
- package/config/skills/_shared/terminal-claim-check.py +537 -0
- package/config/skills/_shared/verify-merge-findings.py +363 -16
- package/config/skills/devlyn:engines/SKILL.md +1 -1
- package/config/skills/devlyn:queue/SKILL.md +12 -0
- package/config/skills/devlyn:resolve/SKILL.md +55 -73
- package/config/skills/devlyn:resolve/references/free-form-mode.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +65 -96
- package/config/skills/devlyn:resolve/references/state-schema.md +29 -22
- package/optional-skills/devlyn:reap/SKILL.md +1 -1
- package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
- package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
- package/package.json +1 -1
- package/scripts/lint-skills.sh +131 -67
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Classify active or invocation-set devlyn terminal claims."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TextIO
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
INCOMPLETE_EXIT = 79
|
|
19
|
+
VALID_VERIFY_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED"}
|
|
20
|
+
SAFE_RUN_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
21
|
+
ENGINE_UNAVAILABLE_RE = re.compile(r"^[A-Za-z0-9_.-]+-unavailable$")
|
|
22
|
+
PHASE_ORDER = (
|
|
23
|
+
"plan",
|
|
24
|
+
"probe_derive",
|
|
25
|
+
"implement",
|
|
26
|
+
"surface_close",
|
|
27
|
+
"build_gate",
|
|
28
|
+
"cleanup",
|
|
29
|
+
"verify",
|
|
30
|
+
"final_report",
|
|
31
|
+
)
|
|
32
|
+
WORK_PHASE_ORDER = PHASE_ORDER[:-1]
|
|
33
|
+
HALT_WITNESS_PHASES = {
|
|
34
|
+
"plan-empty": "plan",
|
|
35
|
+
"risk-halt": "plan",
|
|
36
|
+
"implement-empty": "implement",
|
|
37
|
+
"build-gate-exhausted": "build_gate",
|
|
38
|
+
"verify-exhausted": "verify",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Classification:
|
|
44
|
+
status: str
|
|
45
|
+
phase: str | None
|
|
46
|
+
reason: str
|
|
47
|
+
run_id: str | None
|
|
48
|
+
incomplete: bool
|
|
49
|
+
|
|
50
|
+
def receipt(self) -> dict[str, str | None]:
|
|
51
|
+
return {
|
|
52
|
+
"status": self.status,
|
|
53
|
+
"phase": self.phase,
|
|
54
|
+
"reason": self.reason,
|
|
55
|
+
"run_id": self.run_id,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def reject_json_constant(token: str) -> None:
|
|
60
|
+
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def malformed(reason: str, run_id: str | None = None) -> Classification:
|
|
64
|
+
return Classification("MALFORMED", None, reason, run_id, True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def incomplete(phase: str, reason: str, run_id: str) -> Classification:
|
|
68
|
+
return Classification(f"INCOMPLETE:{phase}", phase, reason, run_id, True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def phase_names(phases: dict[str, object]) -> list[str]:
|
|
72
|
+
known = [name for name in PHASE_ORDER if name in phases]
|
|
73
|
+
return known + sorted(name for name in phases if name not in PHASE_ORDER)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_lifecycle(
|
|
77
|
+
name: str, phase: dict[str, object], run_id: str
|
|
78
|
+
) -> Classification | None:
|
|
79
|
+
started = phase.get("started_at")
|
|
80
|
+
completed = phase.get("completed_at")
|
|
81
|
+
if started is not None and (not isinstance(started, str) or not started):
|
|
82
|
+
return malformed(f"phase {name} has invalid started_at", run_id)
|
|
83
|
+
if completed is not None and (not isinstance(completed, str) or not completed):
|
|
84
|
+
return malformed(f"phase {name} has invalid completed_at", run_id)
|
|
85
|
+
if completed is not None and started is None:
|
|
86
|
+
return malformed(f"phase {name} completed without started_at", run_id)
|
|
87
|
+
if started is not None and completed is None:
|
|
88
|
+
return incomplete(name, "phase started but not completed", run_id)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def valid_final_verdict(value: object) -> bool:
|
|
93
|
+
return isinstance(value, str) and (
|
|
94
|
+
value in VALID_VERIFY_VERDICTS
|
|
95
|
+
or (value.startswith("BLOCKED:") and len(value) > 8)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def terminal_halt_witness(phases: dict[str, object]) -> tuple[str, str] | None:
|
|
100
|
+
final_report = phases.get("final_report")
|
|
101
|
+
if not isinstance(final_report, dict):
|
|
102
|
+
return None
|
|
103
|
+
verdict = final_report.get("verdict")
|
|
104
|
+
if not isinstance(verdict, str) or not verdict.startswith("BLOCKED:"):
|
|
105
|
+
return None
|
|
106
|
+
reason = verdict.removeprefix("BLOCKED:")
|
|
107
|
+
target = HALT_WITNESS_PHASES.get(reason)
|
|
108
|
+
if target is None and (
|
|
109
|
+
reason == "fresh-context-unavailable" or ENGINE_UNAVAILABLE_RE.fullmatch(reason)
|
|
110
|
+
):
|
|
111
|
+
reached = [name for name in WORK_PHASE_ORDER if phases.get(name) is not None]
|
|
112
|
+
target = reached[-1] if reached else None
|
|
113
|
+
if target is None:
|
|
114
|
+
return None
|
|
115
|
+
phase = phases.get(target)
|
|
116
|
+
if not isinstance(phase, dict) or phase.get("completed_at") is None:
|
|
117
|
+
return None
|
|
118
|
+
target_index = WORK_PHASE_ORDER.index(target)
|
|
119
|
+
if any(phases.get(name) is not None for name in WORK_PHASE_ORDER[target_index + 1:]):
|
|
120
|
+
return None
|
|
121
|
+
return target, reason
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def classify_state_bytes(
|
|
125
|
+
root: pathlib.Path,
|
|
126
|
+
state_path: pathlib.Path,
|
|
127
|
+
state_bytes: bytes,
|
|
128
|
+
*,
|
|
129
|
+
archived: bool,
|
|
130
|
+
) -> tuple[Classification, dict[str, object] | None]:
|
|
131
|
+
try:
|
|
132
|
+
state = json.loads(
|
|
133
|
+
state_bytes.decode("utf-8"),
|
|
134
|
+
parse_constant=reject_json_constant,
|
|
135
|
+
)
|
|
136
|
+
except (UnicodeError, ValueError):
|
|
137
|
+
return malformed(f"run state unreadable or invalid: {state_path}"), None
|
|
138
|
+
if not isinstance(state, dict):
|
|
139
|
+
return malformed("run state must be a JSON object"), None
|
|
140
|
+
|
|
141
|
+
run_id = state.get("run_id")
|
|
142
|
+
if not isinstance(run_id, str) or not SAFE_RUN_ID_RE.fullmatch(run_id):
|
|
143
|
+
return malformed("run state has invalid run_id"), state
|
|
144
|
+
phases = state.get("phases")
|
|
145
|
+
if not isinstance(phases, dict):
|
|
146
|
+
return malformed("run state phases must be a JSON object", run_id), state
|
|
147
|
+
|
|
148
|
+
open_span: Classification | None = None
|
|
149
|
+
for name in phase_names(phases):
|
|
150
|
+
phase = phases[name]
|
|
151
|
+
if phase is None:
|
|
152
|
+
continue
|
|
153
|
+
if not isinstance(phase, dict):
|
|
154
|
+
return malformed(f"phase {name} must be a JSON object or null", run_id), state
|
|
155
|
+
lifecycle = validate_lifecycle(name, phase, run_id)
|
|
156
|
+
if lifecycle is not None:
|
|
157
|
+
if lifecycle.status == "MALFORMED":
|
|
158
|
+
return lifecycle, state
|
|
159
|
+
open_span = open_span or lifecycle
|
|
160
|
+
history = phase.get("history")
|
|
161
|
+
if history is None:
|
|
162
|
+
continue
|
|
163
|
+
if not isinstance(history, list):
|
|
164
|
+
return malformed(f"phase {name} history must be an array", run_id), state
|
|
165
|
+
for index, prior in enumerate(history):
|
|
166
|
+
if not isinstance(prior, dict):
|
|
167
|
+
return malformed(f"phase {name} history[{index}] must be an object", run_id), state
|
|
168
|
+
lifecycle = validate_lifecycle(name, prior, run_id)
|
|
169
|
+
if lifecycle is not None:
|
|
170
|
+
if lifecycle.status == "MALFORMED":
|
|
171
|
+
return lifecycle, state
|
|
172
|
+
open_span = open_span or incomplete(
|
|
173
|
+
name, f"phase {name} history[{index}] started but not completed", run_id
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
final_report = phases.get("final_report")
|
|
177
|
+
final_completed = (
|
|
178
|
+
isinstance(final_report, dict) and final_report.get("completed_at") is not None
|
|
179
|
+
)
|
|
180
|
+
if final_completed and not valid_final_verdict(final_report.get("verdict")):
|
|
181
|
+
return malformed("final_report completed with null or invalid verdict", run_id), state
|
|
182
|
+
if open_span is not None:
|
|
183
|
+
return open_span, state
|
|
184
|
+
|
|
185
|
+
verify = phases.get("verify")
|
|
186
|
+
if isinstance(verify, dict) and verify.get("completed_at") is not None:
|
|
187
|
+
verdict = verify.get("verdict")
|
|
188
|
+
if verdict is None:
|
|
189
|
+
return incomplete("verify", "verify completed without verdict", run_id), state
|
|
190
|
+
if verdict not in VALID_VERIFY_VERDICTS:
|
|
191
|
+
return malformed("verify has invalid verdict", run_id), state
|
|
192
|
+
else:
|
|
193
|
+
verdict = None
|
|
194
|
+
|
|
195
|
+
archive_state = root / ".devlyn" / "runs" / run_id / "pipeline.state.json"
|
|
196
|
+
archive_valid = (
|
|
197
|
+
archived and state_path.parent.name == run_id
|
|
198
|
+
) or archive_state.is_file()
|
|
199
|
+
if verdict is not None:
|
|
200
|
+
if not final_completed:
|
|
201
|
+
return incomplete(
|
|
202
|
+
"final_report", "verify completed but final report not completed", run_id,
|
|
203
|
+
), state
|
|
204
|
+
if not archive_valid:
|
|
205
|
+
return incomplete(
|
|
206
|
+
"archive", "final report completed but run state not archived", run_id,
|
|
207
|
+
), state
|
|
208
|
+
return Classification(
|
|
209
|
+
"CLEAN", None, "verify and terminal archive complete", run_id, False
|
|
210
|
+
), state
|
|
211
|
+
|
|
212
|
+
witness = terminal_halt_witness(phases)
|
|
213
|
+
if witness is not None:
|
|
214
|
+
phase, reason = witness
|
|
215
|
+
if not archive_valid:
|
|
216
|
+
return incomplete("archive", "terminal halt witness not archived", run_id), state
|
|
217
|
+
return Classification(
|
|
218
|
+
"CLEAN", None, f"terminal halt witnessed at {phase}: {reason}", run_id, False,
|
|
219
|
+
), state
|
|
220
|
+
|
|
221
|
+
return incomplete("verify", "verify did not complete with a valid verdict", run_id), state
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def classify_active_state(
|
|
225
|
+
root: pathlib.Path, state_bytes: bytes,
|
|
226
|
+
) -> tuple[Classification, dict[str, object] | None]:
|
|
227
|
+
"""Classify only the supplied active-state snapshot and return its parsed object."""
|
|
228
|
+
state_path = root / ".devlyn" / "pipeline.state.json"
|
|
229
|
+
return classify_state_bytes(root, state_path, state_bytes, archived=False)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def classify_state(
|
|
233
|
+
root: pathlib.Path, state_path: pathlib.Path, *, archived: bool,
|
|
234
|
+
) -> Classification:
|
|
235
|
+
try:
|
|
236
|
+
state_path.lstat()
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
return malformed(f"run state absent: {state_path}")
|
|
239
|
+
except OSError:
|
|
240
|
+
return malformed(f"run state unreadable or invalid: {state_path}")
|
|
241
|
+
if not state_path.is_file():
|
|
242
|
+
return malformed(f"run state is not a file: {state_path}")
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
state_bytes = state_path.read_bytes()
|
|
246
|
+
except OSError:
|
|
247
|
+
return malformed(f"run state unreadable or invalid: {state_path}")
|
|
248
|
+
result, _ = classify_state_bytes(
|
|
249
|
+
root, state_path, state_bytes, archived=archived,
|
|
250
|
+
)
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def invocation_members(
|
|
255
|
+
root: pathlib.Path, before_run_ids: set[str],
|
|
256
|
+
) -> list[tuple[pathlib.Path, bool]]:
|
|
257
|
+
members: list[tuple[pathlib.Path, bool]] = []
|
|
258
|
+
runs = root / ".devlyn" / "runs"
|
|
259
|
+
try:
|
|
260
|
+
children = list(runs.iterdir())
|
|
261
|
+
except FileNotFoundError:
|
|
262
|
+
children = []
|
|
263
|
+
except OSError:
|
|
264
|
+
return [(runs, True)]
|
|
265
|
+
for child in children:
|
|
266
|
+
if child.name in before_run_ids:
|
|
267
|
+
continue
|
|
268
|
+
state_path = child / "pipeline.state.json"
|
|
269
|
+
try:
|
|
270
|
+
state_path.lstat()
|
|
271
|
+
except FileNotFoundError:
|
|
272
|
+
continue
|
|
273
|
+
except OSError:
|
|
274
|
+
pass
|
|
275
|
+
members.append((state_path, True))
|
|
276
|
+
active = root / ".devlyn" / "pipeline.state.json"
|
|
277
|
+
try:
|
|
278
|
+
active.lstat()
|
|
279
|
+
except FileNotFoundError:
|
|
280
|
+
pass
|
|
281
|
+
except OSError:
|
|
282
|
+
members.append((active, False))
|
|
283
|
+
else:
|
|
284
|
+
members.append((active, False))
|
|
285
|
+
return members
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def classify(root: pathlib.Path, before_run_ids: set[str] | None = None) -> Classification:
|
|
289
|
+
members = invocation_members(root, before_run_ids or set())
|
|
290
|
+
if not members:
|
|
291
|
+
return Classification(
|
|
292
|
+
"NOT_APPLICABLE", None, "no new archived or active run state", None, False
|
|
293
|
+
)
|
|
294
|
+
results = [
|
|
295
|
+
classify_state(root, state_path, archived=archived)
|
|
296
|
+
for state_path, archived in members
|
|
297
|
+
]
|
|
298
|
+
failures = [result for result in results if result.incomplete]
|
|
299
|
+
if not failures:
|
|
300
|
+
return Classification(
|
|
301
|
+
"CLEAN", None, f"all {len(results)} invocation run states complete", None, False
|
|
302
|
+
)
|
|
303
|
+
if len(results) == 1:
|
|
304
|
+
return failures[0]
|
|
305
|
+
status = (
|
|
306
|
+
"MALFORMED"
|
|
307
|
+
if any(result.status == "MALFORMED" for result in failures)
|
|
308
|
+
else "INCOMPLETE:set"
|
|
309
|
+
)
|
|
310
|
+
phase = None if status == "MALFORMED" else "set"
|
|
311
|
+
reason = "invocation run-set incomplete: " + json.dumps([
|
|
312
|
+
{"run_id": result.run_id, "status": result.status, "reason": result.reason}
|
|
313
|
+
for result in failures
|
|
314
|
+
], separators=(",", ":"))
|
|
315
|
+
return Classification(status, phase, reason, None, True)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def run_check(
|
|
319
|
+
root: pathlib.Path, output: TextIO, before_run_ids: set[str] | None = None,
|
|
320
|
+
) -> int:
|
|
321
|
+
result = classify(root, before_run_ids)
|
|
322
|
+
if not result.incomplete:
|
|
323
|
+
return 0
|
|
324
|
+
output.write(json.dumps(result.receipt(), separators=(",", ":")) + "\n")
|
|
325
|
+
return INCOMPLETE_EXIT
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def bound_exit(arm_exit: int, classifier_exit: int) -> int:
|
|
329
|
+
if arm_exit == 0 and classifier_exit == INCOMPLETE_EXIT:
|
|
330
|
+
return INCOMPLETE_EXIT
|
|
331
|
+
return arm_exit
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def write_state(root: pathlib.Path, state: object) -> None:
|
|
335
|
+
devlyn = root / ".devlyn"
|
|
336
|
+
devlyn.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
(devlyn / "pipeline.state.json").write_text(
|
|
338
|
+
json.dumps(state) + "\n", encoding="utf-8"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def write_archived_state(root: pathlib.Path, state: dict[str, object]) -> None:
|
|
343
|
+
archive = root / ".devlyn" / "runs" / str(state["run_id"])
|
|
344
|
+
archive.mkdir(parents=True, exist_ok=True)
|
|
345
|
+
(archive / "pipeline.state.json").write_text(
|
|
346
|
+
json.dumps(state) + "\n", encoding="utf-8"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def self_test() -> int:
|
|
351
|
+
tests = 0
|
|
352
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
353
|
+
base = pathlib.Path(tmp)
|
|
354
|
+
|
|
355
|
+
f23_state = {
|
|
356
|
+
"version": "3.0",
|
|
357
|
+
"run_id": "rs-20260720T133550Z-df45c8e5de59",
|
|
358
|
+
"phases": {
|
|
359
|
+
"plan": {"started_at": "2026-07-20T13:37:22Z", "completed_at": "2026-07-20T13:42:08Z", "verdict": "PASS"},
|
|
360
|
+
"probe_derive": None,
|
|
361
|
+
"implement": {"started_at": "2026-07-20T13:42:46Z", "completed_at": "2026-07-20T13:54:01Z", "verdict": "PASS"},
|
|
362
|
+
"surface_close": {"started_at": "2026-07-20T14:01:49Z", "completed_at": "2026-07-20T14:04:11Z", "verdict": "BLOCKED"},
|
|
363
|
+
"build_gate": None,
|
|
364
|
+
"cleanup": None,
|
|
365
|
+
"verify": None,
|
|
366
|
+
"final_report": {"started_at": "2026-07-20T14:04:40Z", "completed_at": "2026-07-20T14:05:05Z", "verdict": "BLOCKED:surface-close-adjudication-malformed"},
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
fs1_state = {
|
|
370
|
+
"version": "3.0",
|
|
371
|
+
"run_id": "rs-20260720T140815Z-068baf0da60c",
|
|
372
|
+
"phases": {
|
|
373
|
+
"plan": {"started_at": "2026-07-20T14:11:37Z", "completed_at": "2026-07-20T14:15:58Z", "verdict": "PASS"},
|
|
374
|
+
"probe_derive": None,
|
|
375
|
+
"implement": {"started_at": "2026-07-20T14:16:12Z", "completed_at": "2026-07-20T14:21:53Z", "verdict": "PASS"},
|
|
376
|
+
"surface_close": {"started_at": "2026-07-20T14:40:32Z", "completed_at": "2026-07-20T14:43:53Z", "verdict": "BLOCKED"},
|
|
377
|
+
"build_gate": {"started_at": "2026-07-20T14:25:44Z", "completed_at": "2026-07-20T14:25:44Z", "verdict": "PASS"},
|
|
378
|
+
"cleanup": {"started_at": "2026-07-20T14:25:57Z", "completed_at": "2026-07-20T14:27:10Z", "verdict": "PASS"},
|
|
379
|
+
"verify": {"started_at": "2026-07-20T14:27:42Z", "completed_at": "2026-07-20T14:44:56Z", "verdict": "BLOCKED"},
|
|
380
|
+
"final_report": {"started_at": "2026-07-20T14:45:10Z", "completed_at": "2026-07-20T14:45:24Z", "verdict": "BLOCKED:surface-close-adjudication-malformed"},
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
root = base / "set-quantification"
|
|
385
|
+
write_archived_state(root, f23_state)
|
|
386
|
+
write_archived_state(root, fs1_state)
|
|
387
|
+
output = io.StringIO()
|
|
388
|
+
assert run_check(root, output) == INCOMPLETE_EXIT
|
|
389
|
+
receipt = json.loads(output.getvalue())
|
|
390
|
+
assert receipt["status"] == "INCOMPLETE:set"
|
|
391
|
+
assert f23_state["run_id"] in receipt["reason"]
|
|
392
|
+
tests += 1
|
|
393
|
+
|
|
394
|
+
root = base / "f25-open-history"
|
|
395
|
+
f25_state = {
|
|
396
|
+
"version": "3.0",
|
|
397
|
+
"run_id": "rs-20260720T102955Z-6246a50033bb",
|
|
398
|
+
"phases": {
|
|
399
|
+
"build_gate": {
|
|
400
|
+
"started_at": "2026-07-20T10:57:53Z",
|
|
401
|
+
"completed_at": "2026-07-20T11:00:13Z",
|
|
402
|
+
"verdict": "PASS",
|
|
403
|
+
"history": [{"started_at": "2026-07-20T10:51:37Z", "completed_at": None, "verdict": None}],
|
|
404
|
+
},
|
|
405
|
+
"verify": {
|
|
406
|
+
"started_at": "2026-07-20T11:02:49Z",
|
|
407
|
+
"completed_at": None,
|
|
408
|
+
"verdict": None,
|
|
409
|
+
},
|
|
410
|
+
"final_report": None,
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
write_state(root, f25_state)
|
|
414
|
+
output = io.StringIO()
|
|
415
|
+
assert run_check(root, output) == INCOMPLETE_EXIT
|
|
416
|
+
receipt = json.loads(output.getvalue())
|
|
417
|
+
assert receipt["status"] == "INCOMPLETE:build_gate"
|
|
418
|
+
assert "history[0]" in receipt["reason"]
|
|
419
|
+
tests += 1
|
|
420
|
+
|
|
421
|
+
root = base / "fs1-clean"
|
|
422
|
+
write_archived_state(root, fs1_state)
|
|
423
|
+
output = io.StringIO()
|
|
424
|
+
assert run_check(root, output) == 0
|
|
425
|
+
assert output.getvalue() == ""
|
|
426
|
+
tests += 1
|
|
427
|
+
|
|
428
|
+
root = base / "clean-full-run"
|
|
429
|
+
clean_state = {
|
|
430
|
+
"run_id": "clean-run",
|
|
431
|
+
"phases": {
|
|
432
|
+
"verify": {"started_at": "2026-07-19T02:00:00Z", "completed_at": "2026-07-19T02:01:00Z", "verdict": "PASS"},
|
|
433
|
+
"final_report": {"started_at": "2026-07-19T02:01:00Z", "completed_at": "2026-07-19T02:02:00Z", "verdict": "PASS"},
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
write_archived_state(root, clean_state)
|
|
437
|
+
output = io.StringIO()
|
|
438
|
+
assert run_check(root, output) == 0
|
|
439
|
+
assert output.getvalue() == ""
|
|
440
|
+
tests += 1
|
|
441
|
+
|
|
442
|
+
witness_rows = (
|
|
443
|
+
("plan-empty", "plan", "PASS"),
|
|
444
|
+
("risk-halt", "plan", "PASS"),
|
|
445
|
+
("implement-empty", "implement", "PASS"),
|
|
446
|
+
("build-gate-exhausted", "build_gate", "FAIL"),
|
|
447
|
+
("verify-exhausted", "verify", "NEEDS_WORK"),
|
|
448
|
+
("fresh-context-unavailable", "implement", "BLOCKED"),
|
|
449
|
+
("codex-unavailable", "plan", "BLOCKED"),
|
|
450
|
+
)
|
|
451
|
+
for reason, halt_phase, phase_verdict in witness_rows:
|
|
452
|
+
root = base / f"witness-{reason}"
|
|
453
|
+
phases: dict[str, object] = {name: None for name in PHASE_ORDER}
|
|
454
|
+
phases[halt_phase] = {
|
|
455
|
+
"started_at": "2026-07-20T00:00:00Z",
|
|
456
|
+
"completed_at": "2026-07-20T00:01:00Z",
|
|
457
|
+
"verdict": phase_verdict,
|
|
458
|
+
}
|
|
459
|
+
phases["final_report"] = {
|
|
460
|
+
"started_at": "2026-07-20T00:01:00Z",
|
|
461
|
+
"completed_at": "2026-07-20T00:02:00Z",
|
|
462
|
+
"verdict": f"BLOCKED:{reason}",
|
|
463
|
+
}
|
|
464
|
+
state = {"run_id": f"witness-{reason}", "phases": phases}
|
|
465
|
+
assert terminal_halt_witness(phases) == (halt_phase, reason)
|
|
466
|
+
write_archived_state(root, state)
|
|
467
|
+
assert classify(root).status == "CLEAN"
|
|
468
|
+
tests += 1
|
|
469
|
+
|
|
470
|
+
root = base / "rolled-back-not-witness"
|
|
471
|
+
rolled_back = {
|
|
472
|
+
"run_id": "rolled-back",
|
|
473
|
+
"phases": {
|
|
474
|
+
"surface_close": {
|
|
475
|
+
"started_at": "2026-07-20T00:00:00Z",
|
|
476
|
+
"completed_at": "2026-07-20T00:01:00Z",
|
|
477
|
+
"verdict": None,
|
|
478
|
+
"skipped_reason": "surface_close_rolled_back_adjudication_malformed",
|
|
479
|
+
"continued_after_block": True,
|
|
480
|
+
},
|
|
481
|
+
"verify": None,
|
|
482
|
+
"final_report": {"started_at": "2026-07-20T00:01:00Z", "completed_at": "2026-07-20T00:02:00Z", "verdict": "BLOCKED:surface-close-adjudication-malformed"},
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
write_archived_state(root, rolled_back)
|
|
486
|
+
assert classify(root).status == "INCOMPLETE:verify"
|
|
487
|
+
tests += 1
|
|
488
|
+
|
|
489
|
+
root = base / "malformed-final"
|
|
490
|
+
malformed_final = {
|
|
491
|
+
"run_id": "malformed-final",
|
|
492
|
+
"phases": {
|
|
493
|
+
"final_report": {"started_at": "2026-07-20T00:00:00Z", "completed_at": "2026-07-20T00:01:00Z", "verdict": None},
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
write_archived_state(root, malformed_final)
|
|
497
|
+
output = io.StringIO()
|
|
498
|
+
assert run_check(root, output) == INCOMPLETE_EXIT
|
|
499
|
+
assert json.loads(output.getvalue())["status"] == "MALFORMED"
|
|
500
|
+
tests += 1
|
|
501
|
+
|
|
502
|
+
root = base / "not-applicable"
|
|
503
|
+
write_archived_state(root, clean_state)
|
|
504
|
+
assert classify(root, {"clean-run"}).status == "NOT_APPLICABLE"
|
|
505
|
+
tests += 1
|
|
506
|
+
|
|
507
|
+
assert bound_exit(0, INCOMPLETE_EXIT) == INCOMPLETE_EXIT
|
|
508
|
+
for protected in (86, 78, 124):
|
|
509
|
+
assert bound_exit(protected, INCOMPLETE_EXIT) == protected
|
|
510
|
+
tests += 1
|
|
511
|
+
|
|
512
|
+
print(f"terminal-claim-check self-test: PASS ({tests} tests)")
|
|
513
|
+
return 0
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
517
|
+
parser = argparse.ArgumentParser(
|
|
518
|
+
description="Classify a devlyn terminal claim from project-root state."
|
|
519
|
+
)
|
|
520
|
+
parser.add_argument("root", nargs="?", default=".", type=pathlib.Path)
|
|
521
|
+
parser.add_argument("--self-test", action="store_true")
|
|
522
|
+
return parser.parse_args(argv)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def main(argv: list[str] | None = None) -> int:
|
|
526
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
527
|
+
if args.self_test:
|
|
528
|
+
return self_test()
|
|
529
|
+
before_run_ids = {
|
|
530
|
+
run_id for run_id in os.environ.get("DEVLYN_RUN_IDS_BEFORE", "").splitlines()
|
|
531
|
+
if run_id
|
|
532
|
+
}
|
|
533
|
+
return run_check(args.root, sys.stdout, before_run_ids)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
if __name__ == "__main__":
|
|
537
|
+
raise SystemExit(main())
|