devlyn-cli 2.7.0 → 2.8.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.
- package/AGENTS.md +9 -1
- package/CLAUDE.md +30 -11
- package/README.md +14 -2
- package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/task.ko.txt +8 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/NOTES.md +28 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/RETIRED.md +15 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/expected.json +57 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/setup.sh +50 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/spec.md +59 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/ordered-settlement.js +88 -0
- package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/reject-invalid.js +45 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/NOTES.md +30 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/RETIRED.md +16 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/expected.json +64 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/setup.sh +21 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/spec.md +58 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/dup-op.js +54 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/priority-apply.js +82 -0
- package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/rollback.js +77 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/NOTES.md +99 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/RETIRED.md +18 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/expected.json +64 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/setup.sh +4 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/spec.md +61 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/correctness-small.js +135 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/scale.js +100 -0
- package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/validation.js +75 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/NOTES.md +93 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/RETIRED.md +16 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/expected.json +65 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/metadata.json +10 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/setup.sh +18 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/spec.md +66 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/task.txt +9 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/correctness-small.js +84 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/scale.js +184 -0
- package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/validation.js +74 -0
- package/benchmark/auto-resolve/scripts/pair-rejected-fixtures.sh +12 -0
- package/benchmark/auto-resolve/scripts/run-fixture.sh +39 -1
- package/benchmark/auto-resolve/scripts/test-lint-fixtures.sh +1 -1
- package/bin/devlyn.js +42 -1
- package/config/skills/_shared/adapters/README.md +7 -0
- package/config/skills/_shared/adapters/claude.md +47 -0
- package/config/skills/_shared/adapters/omp.md +17 -0
- package/config/skills/_shared/archive_run.py +8 -2
- package/config/skills/_shared/codex-config.md +22 -6
- package/config/skills/_shared/engine-doctor.sh +107 -0
- package/config/skills/_shared/engine-preflight.md +18 -11
- package/config/skills/_shared/expected.schema.json +26 -0
- package/config/skills/_shared/runtime-principles.md +1 -1
- package/config/skills/_shared/spec-verify-check.py +773 -486
- package/config/skills/_shared/state-phase-write.py +374 -0
- package/config/skills/_shared/verify-merge-findings.py +177 -61
- package/config/skills/devlyn:engines/SKILL.md +47 -0
- package/config/skills/devlyn:ideate/SKILL.md +22 -3
- package/config/skills/devlyn:ideate/references/elicitation.md +2 -2
- package/config/skills/devlyn:ideate/references/from-spec-mode.md +2 -2
- package/config/skills/devlyn:ideate/references/project-mode.md +1 -1
- package/config/skills/devlyn:ideate/references/spec-template.md +15 -4
- package/config/skills/devlyn:queue/SKILL.md +26 -0
- package/config/skills/devlyn:resolve/SKILL.md +54 -24
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/cleanup.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/plan.md +3 -2
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +8 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +18 -9
- package/config/skills/devlyn:resolve/references/state-schema.md +10 -6
- package/optional-skills/devlyn:pencil-pull/SKILL.md +5 -0
- package/optional-skills/devlyn:pencil-push/SKILL.md +5 -0
- package/package.json +1 -1
- package/scripts/lint-skills.sh +48 -41
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic spawn/complete writer for phases.<name> in pipeline.state.json.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement spawn \
|
|
6
|
+
--round 1 --triggered-by verify [--pre-sha <sha>] [--engine claude] [--model <id>]
|
|
7
|
+
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement complete \
|
|
8
|
+
--verdict PASS [--findings-file <path>] [--log-file <path>] [--engine claude] [--model <id>]
|
|
9
|
+
|
|
10
|
+
references/state-schema.md#write-protocol is the contract this implements.
|
|
11
|
+
iter-0042 (autoresearch/iterations/0042-compliance-drift-probes.md) found a
|
|
12
|
+
hand-edited fix-loop respawn left `started_at` at its original round's value
|
|
13
|
+
while `completed_at`/`duration_ms`/`round`/`triggered_by` advanced to the new
|
|
14
|
+
round, producing an internally inconsistent phase timeline. `spawn` always
|
|
15
|
+
resets `started_at` fresh and nulls the completion fields; `complete` derives
|
|
16
|
+
`duration_ms` from the phase's own recorded `started_at`, so the two can never
|
|
17
|
+
drift apart again.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import datetime
|
|
23
|
+
import json
|
|
24
|
+
import pathlib
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
|
|
28
|
+
VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
|
|
29
|
+
VALID_TRIGGERS = {"build_gate", "verify"}
|
|
30
|
+
PHASE_NAMES = {"plan", "probe_derive", "implement", "build_gate", "cleanup", "verify", "final_report"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reject_json_constant(token: str) -> None:
|
|
34
|
+
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def loads_strict_json(text: str):
|
|
38
|
+
return json.loads(text, parse_constant=reject_json_constant)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def now_ms() -> datetime.datetime:
|
|
42
|
+
# Truncate to millisecond precision at capture time — now_iso()'s string
|
|
43
|
+
# representation is millisecond-precise, so any duration_ms computed from
|
|
44
|
+
# a not-yet-truncated `now` can be off by a sub-millisecond rounding
|
|
45
|
+
# remainder from what re-parsing the stored completed_at would give.
|
|
46
|
+
dt = datetime.datetime.now(datetime.timezone.utc)
|
|
47
|
+
return dt.replace(microsecond=(dt.microsecond // 1000) * 1000)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def now_iso(dt: datetime.datetime | None = None) -> str:
|
|
51
|
+
dt = dt or now_ms()
|
|
52
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_iso(value: str) -> datetime.datetime:
|
|
56
|
+
text = value[:-1] if value.endswith("Z") else value
|
|
57
|
+
if "." in text:
|
|
58
|
+
head, frac = text.split(".", 1)
|
|
59
|
+
text = f"{head}.{(frac + '000000')[:6]}"
|
|
60
|
+
return datetime.datetime.fromisoformat(text).replace(tzinfo=datetime.timezone.utc)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_state(state_path: pathlib.Path) -> dict:
|
|
64
|
+
if not state_path.is_file():
|
|
65
|
+
raise SystemExit(f"error: {state_path} not found")
|
|
66
|
+
try:
|
|
67
|
+
return loads_strict_json(state_path.read_text(encoding="utf-8"))
|
|
68
|
+
except ValueError as e:
|
|
69
|
+
raise SystemExit(f"error: {state_path} is not valid JSON: {e}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_state(state_path: pathlib.Path, state: dict) -> None:
|
|
73
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(state_path.parent), prefix=state_path.name + ".tmp.")
|
|
74
|
+
try:
|
|
75
|
+
with open(fd, "w", encoding="utf-8") as handle:
|
|
76
|
+
handle.write(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
|
77
|
+
pathlib.Path(tmp_name).replace(state_path)
|
|
78
|
+
except BaseException:
|
|
79
|
+
pathlib.Path(tmp_name).unlink(missing_ok=True)
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
84
|
+
# The per-round reset contract covers files, not just JSON fields: a
|
|
85
|
+
# VERIFY fix-loop respawn reuses the same .devlyn, so a prior round's
|
|
86
|
+
# findings/stdout would otherwise read as current-round spawn evidence in
|
|
87
|
+
# verify-merge-findings.py (iter-0060 R0 finding).
|
|
88
|
+
# verify*.jsonl (not just *.findings.jsonl): judge-specific files like
|
|
89
|
+
# verify.findings.judge-codex.jsonl end in .judge-<engine>.jsonl.
|
|
90
|
+
# *-judge.* covers every engine's stdout/stderr capture (codex-judge.*,
|
|
91
|
+
# claude-judge.* — adapters/claude.md ## Invocation).
|
|
92
|
+
for pattern in ("verify*.jsonl", "*-judge.*"):
|
|
93
|
+
for path in devlyn.glob(pattern):
|
|
94
|
+
path.unlink()
|
|
95
|
+
(devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
99
|
+
pre_sha: str | None, engine: str | None, model: str | None) -> None:
|
|
100
|
+
# Merge, don't replace: a phase-gated large run's `exec` progress (or any
|
|
101
|
+
# other field this script doesn't own) survives a fix-loop respawn.
|
|
102
|
+
phases = state.setdefault("phases", {})
|
|
103
|
+
entry = phases.get(phase)
|
|
104
|
+
if not isinstance(entry, dict):
|
|
105
|
+
entry = {}
|
|
106
|
+
phases[phase] = entry
|
|
107
|
+
entry["started_at"] = now_iso()
|
|
108
|
+
entry["completed_at"] = None
|
|
109
|
+
entry["duration_ms"] = None
|
|
110
|
+
entry["round"] = round_
|
|
111
|
+
entry["triggered_by"] = triggered_by
|
|
112
|
+
entry["verdict"] = None
|
|
113
|
+
entry["artifacts"] = {"findings_file": None, "log_file": None}
|
|
114
|
+
entry["sub_verdicts"] = None
|
|
115
|
+
if engine is not None:
|
|
116
|
+
entry["engine"] = engine
|
|
117
|
+
if model is not None:
|
|
118
|
+
entry["model"] = model
|
|
119
|
+
if pre_sha is not None:
|
|
120
|
+
entry["pre_sha"] = pre_sha
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def do_complete(state: dict, phase: str, verdict: str | None,
|
|
124
|
+
findings_file: str | None, log_file: str | None,
|
|
125
|
+
engine: str | None, model: str | None) -> None:
|
|
126
|
+
phases = state.setdefault("phases", {})
|
|
127
|
+
entry = phases.get(phase)
|
|
128
|
+
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
129
|
+
raise SystemExit(f"error: phases.{phase} was never spawned (no started_at) — cannot complete")
|
|
130
|
+
started = parse_iso(entry["started_at"])
|
|
131
|
+
now = now_ms()
|
|
132
|
+
entry["completed_at"] = now_iso(now)
|
|
133
|
+
entry["duration_ms"] = max(0, round((now - started).total_seconds() * 1000))
|
|
134
|
+
if phase == "verify":
|
|
135
|
+
if verdict is not None:
|
|
136
|
+
raise SystemExit(
|
|
137
|
+
"error: phases.verify.verdict is owned by verify-merge-findings.py "
|
|
138
|
+
"--write-state; do not pass --verdict to complete for this phase"
|
|
139
|
+
)
|
|
140
|
+
if entry.get("verdict") is None:
|
|
141
|
+
raise SystemExit(
|
|
142
|
+
"error: phases.verify.verdict is still null — run "
|
|
143
|
+
"verify-merge-findings.py --write-state before complete"
|
|
144
|
+
)
|
|
145
|
+
elif verdict is not None:
|
|
146
|
+
entry["verdict"] = verdict
|
|
147
|
+
else:
|
|
148
|
+
raise SystemExit(f"error: --verdict is required to complete phases.{phase}")
|
|
149
|
+
if findings_file is not None or log_file is not None:
|
|
150
|
+
artifacts = entry.setdefault("artifacts", {"findings_file": None, "log_file": None})
|
|
151
|
+
if findings_file is not None:
|
|
152
|
+
artifacts["findings_file"] = findings_file
|
|
153
|
+
if log_file is not None:
|
|
154
|
+
artifacts["log_file"] = log_file
|
|
155
|
+
if engine is not None:
|
|
156
|
+
entry["engine"] = engine
|
|
157
|
+
if model is not None:
|
|
158
|
+
entry["model"] = model
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def self_test() -> int:
|
|
162
|
+
import time
|
|
163
|
+
|
|
164
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
165
|
+
devlyn = pathlib.Path(tmp)
|
|
166
|
+
state_path = devlyn / "pipeline.state.json"
|
|
167
|
+
write_state(state_path, {"phases": {}})
|
|
168
|
+
|
|
169
|
+
# Round 0: spawn -> complete.
|
|
170
|
+
state = read_state(state_path)
|
|
171
|
+
do_spawn(state, "implement", 0, None, None, "claude", None)
|
|
172
|
+
write_state(state_path, state)
|
|
173
|
+
round0_started = read_state(state_path)["phases"]["implement"]["started_at"]
|
|
174
|
+
|
|
175
|
+
time.sleep(0.05)
|
|
176
|
+
state = read_state(state_path)
|
|
177
|
+
do_complete(state, "implement", "NEEDS_WORK", None, None, None, "test-model-id")
|
|
178
|
+
write_state(state_path, state)
|
|
179
|
+
entry = read_state(state_path)["phases"]["implement"]
|
|
180
|
+
assert entry["completed_at"] is not None
|
|
181
|
+
assert entry["duration_ms"] >= 0
|
|
182
|
+
assert parse_iso(entry["completed_at"]) >= parse_iso(entry["started_at"])
|
|
183
|
+
expected_ms = round((parse_iso(entry["completed_at"]) - parse_iso(entry["started_at"])).total_seconds() * 1000)
|
|
184
|
+
assert entry["duration_ms"] == expected_ms, (entry["duration_ms"], expected_ms)
|
|
185
|
+
|
|
186
|
+
# Round 1: fix-loop respawn — this is the literal iter-0042 regression.
|
|
187
|
+
time.sleep(0.05)
|
|
188
|
+
state = read_state(state_path)
|
|
189
|
+
do_spawn(state, "implement", 1, "verify", None, None, None)
|
|
190
|
+
write_state(state_path, state)
|
|
191
|
+
respawned = read_state(state_path)["phases"]["implement"]
|
|
192
|
+
assert respawned["started_at"] != round0_started, "started_at must refresh on respawn"
|
|
193
|
+
assert respawned["completed_at"] is None, "respawn must null stale completed_at"
|
|
194
|
+
assert respawned["duration_ms"] is None, "respawn must null stale duration_ms"
|
|
195
|
+
assert respawned["verdict"] is None, "respawn must null stale verdict"
|
|
196
|
+
assert respawned["engine"] == "claude", "engine preserved when not re-supplied"
|
|
197
|
+
assert respawned["round"] == 1
|
|
198
|
+
assert respawned["triggered_by"] == "verify"
|
|
199
|
+
|
|
200
|
+
time.sleep(0.05)
|
|
201
|
+
state = read_state(state_path)
|
|
202
|
+
do_complete(state, "implement", "PASS", ".devlyn/x.jsonl", None, None, None)
|
|
203
|
+
write_state(state_path, state)
|
|
204
|
+
final = read_state(state_path)["phases"]["implement"]
|
|
205
|
+
assert final["verdict"] == "PASS"
|
|
206
|
+
assert parse_iso(final["started_at"]) == parse_iso(respawned["started_at"])
|
|
207
|
+
assert parse_iso(final["completed_at"]) >= parse_iso(final["started_at"])
|
|
208
|
+
assert final["artifacts"]["findings_file"] == ".devlyn/x.jsonl"
|
|
209
|
+
|
|
210
|
+
# complete() before spawn() must fail loudly, not silently invent data.
|
|
211
|
+
write_state(state_path, {"phases": {}})
|
|
212
|
+
state = read_state(state_path)
|
|
213
|
+
try:
|
|
214
|
+
do_complete(state, "build_gate", "PASS", None, None, None, None)
|
|
215
|
+
except SystemExit as e:
|
|
216
|
+
assert "never spawned" in str(e)
|
|
217
|
+
else:
|
|
218
|
+
raise AssertionError("complete() without a prior spawn() must raise")
|
|
219
|
+
|
|
220
|
+
# VERIFY flow: verify-merge-findings.py already wrote verdict; complete()
|
|
221
|
+
# must preserve it when --verdict is omitted.
|
|
222
|
+
write_state(state_path, {"phases": {}})
|
|
223
|
+
state = read_state(state_path)
|
|
224
|
+
do_spawn(state, "verify", 0, None, None, "claude", None)
|
|
225
|
+
state["phases"]["verify"]["verdict"] = "PASS"
|
|
226
|
+
state["phases"]["verify"]["sub_verdicts"] = {"mechanical": "PASS", "judge": "PASS"}
|
|
227
|
+
write_state(state_path, state)
|
|
228
|
+
state = read_state(state_path)
|
|
229
|
+
do_complete(state, "verify", None, None, None, None, None)
|
|
230
|
+
write_state(state_path, state)
|
|
231
|
+
verify_entry = read_state(state_path)["phases"]["verify"]
|
|
232
|
+
assert verify_entry["verdict"] == "PASS", "complete() must preserve pre-set verdict when omitted"
|
|
233
|
+
assert verify_entry["sub_verdicts"] == {"mechanical": "PASS", "judge": "PASS"}
|
|
234
|
+
assert verify_entry["completed_at"] is not None
|
|
235
|
+
|
|
236
|
+
# An explicit --verdict for VERIFY must be rejected — its verdict is
|
|
237
|
+
# owned exclusively by verify-merge-findings.py --write-state.
|
|
238
|
+
state = read_state(state_path)
|
|
239
|
+
try:
|
|
240
|
+
do_complete(state, "verify", "PASS", None, None, None, None)
|
|
241
|
+
except SystemExit as e:
|
|
242
|
+
assert "owned by verify-merge-findings.py" in str(e)
|
|
243
|
+
else:
|
|
244
|
+
raise AssertionError("complete() must reject an explicit --verdict for VERIFY")
|
|
245
|
+
|
|
246
|
+
# Non-VERIFY phases require --verdict explicitly; complete() must not
|
|
247
|
+
# silently accept an unset verdict the way VERIFY's omit-to-preserve
|
|
248
|
+
# flow does.
|
|
249
|
+
write_state(state_path, {"phases": {}})
|
|
250
|
+
state = read_state(state_path)
|
|
251
|
+
do_spawn(state, "plan", 0, None, None, None, None)
|
|
252
|
+
write_state(state_path, state)
|
|
253
|
+
state = read_state(state_path)
|
|
254
|
+
try:
|
|
255
|
+
do_complete(state, "plan", None, None, None, None, None)
|
|
256
|
+
except SystemExit as e:
|
|
257
|
+
assert "is required" in str(e)
|
|
258
|
+
else:
|
|
259
|
+
raise AssertionError("complete() with no --verdict on a non-VERIFY phase must raise")
|
|
260
|
+
|
|
261
|
+
# VERIFY complete() before verify-merge-findings.py wrote a verdict
|
|
262
|
+
# must also fail loudly, not silently pass with a null verdict.
|
|
263
|
+
write_state(state_path, {"phases": {}})
|
|
264
|
+
state = read_state(state_path)
|
|
265
|
+
do_spawn(state, "verify", 0, None, None, None, None)
|
|
266
|
+
write_state(state_path, state)
|
|
267
|
+
state = read_state(state_path)
|
|
268
|
+
try:
|
|
269
|
+
do_complete(state, "verify", None, None, None, None, None)
|
|
270
|
+
except SystemExit as e:
|
|
271
|
+
assert "still null" in str(e)
|
|
272
|
+
else:
|
|
273
|
+
raise AssertionError("VERIFY complete() with no verdict anywhere must raise")
|
|
274
|
+
|
|
275
|
+
# VERIFY respawn must clear the prior round's on-disk artifacts too —
|
|
276
|
+
# a stale round-0 pair findings file would otherwise read as
|
|
277
|
+
# current-round spawn evidence in verify-merge-findings.py
|
|
278
|
+
# (iter-0060 R0 finding: current-round spawn evidence).
|
|
279
|
+
for name in (
|
|
280
|
+
"verify.findings.jsonl",
|
|
281
|
+
"verify.pair.findings.jsonl",
|
|
282
|
+
"verify.findings.judge-codex.jsonl",
|
|
283
|
+
"verify-merged.findings.jsonl",
|
|
284
|
+
"verify-merge.summary.json",
|
|
285
|
+
"codex-judge.stdout",
|
|
286
|
+
"claude-judge.stdout",
|
|
287
|
+
):
|
|
288
|
+
(devlyn / name).write_text("stale\n", encoding="utf-8")
|
|
289
|
+
(devlyn / "spec-verify.json").write_text("{}", encoding="utf-8")
|
|
290
|
+
clear_verify_round_artifacts(devlyn)
|
|
291
|
+
for name in (
|
|
292
|
+
"verify.findings.jsonl",
|
|
293
|
+
"verify.pair.findings.jsonl",
|
|
294
|
+
"verify.findings.judge-codex.jsonl",
|
|
295
|
+
"verify-merged.findings.jsonl",
|
|
296
|
+
"verify-merge.summary.json",
|
|
297
|
+
"codex-judge.stdout",
|
|
298
|
+
"claude-judge.stdout",
|
|
299
|
+
):
|
|
300
|
+
assert not (devlyn / name).exists(), f"{name} must be cleared on VERIFY spawn"
|
|
301
|
+
assert (devlyn / "spec-verify.json").exists(), "non-VERIFY-round files must survive"
|
|
302
|
+
|
|
303
|
+
# Fix-loop respawn of phase-gated IMPLEMENT must preserve `exec`
|
|
304
|
+
# (routing truth for large runs, state-schema.md line 55) — spawn
|
|
305
|
+
# merges into the existing entry rather than replacing it wholesale.
|
|
306
|
+
write_state(state_path, {"phases": {}})
|
|
307
|
+
state = read_state(state_path)
|
|
308
|
+
do_spawn(state, "implement", 0, None, None, "claude", None)
|
|
309
|
+
state["phases"]["implement"]["exec"] = {
|
|
310
|
+
"total": 3, "current": 3, "statuses": ["PASS", "PASS", "PASS"], "commits": ["a", "b", "c"],
|
|
311
|
+
}
|
|
312
|
+
write_state(state_path, state)
|
|
313
|
+
state = read_state(state_path)
|
|
314
|
+
do_complete(state, "implement", "PASS", None, None, None, None)
|
|
315
|
+
write_state(state_path, state)
|
|
316
|
+
state = read_state(state_path)
|
|
317
|
+
do_spawn(state, "implement", 1, "verify", None, None, None)
|
|
318
|
+
write_state(state_path, state)
|
|
319
|
+
respawned_exec = read_state(state_path)["phases"]["implement"]
|
|
320
|
+
assert respawned_exec["exec"]["current"] == 3, "spawn must not clobber unowned fields like exec"
|
|
321
|
+
assert respawned_exec["verdict"] is None, "respawn still nulls owned fields even with exec present"
|
|
322
|
+
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main() -> int:
|
|
327
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
328
|
+
ap.add_argument("--devlyn-dir", default=".devlyn")
|
|
329
|
+
ap.add_argument("--phase", choices=sorted(PHASE_NAMES))
|
|
330
|
+
ap.add_argument("--self-test", action="store_true")
|
|
331
|
+
sub = ap.add_subparsers(dest="event")
|
|
332
|
+
|
|
333
|
+
spawn_p = sub.add_parser("spawn")
|
|
334
|
+
spawn_p.add_argument("--round", type=int, required=True)
|
|
335
|
+
spawn_p.add_argument("--triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
|
|
336
|
+
spawn_p.add_argument("--pre-sha", default=None)
|
|
337
|
+
spawn_p.add_argument("--engine", default=None)
|
|
338
|
+
spawn_p.add_argument("--model", default=None)
|
|
339
|
+
|
|
340
|
+
complete_p = sub.add_parser("complete")
|
|
341
|
+
complete_p.add_argument("--verdict", choices=sorted(VALID_VERDICTS), default=None)
|
|
342
|
+
complete_p.add_argument("--findings-file", default=None)
|
|
343
|
+
complete_p.add_argument("--log-file", default=None)
|
|
344
|
+
complete_p.add_argument("--engine", default=None)
|
|
345
|
+
complete_p.add_argument("--model", default=None)
|
|
346
|
+
|
|
347
|
+
args = ap.parse_args()
|
|
348
|
+
if args.self_test:
|
|
349
|
+
return self_test()
|
|
350
|
+
|
|
351
|
+
if not args.phase or args.event not in {"spawn", "complete"}:
|
|
352
|
+
ap.error("--phase and one of {spawn,complete} are required unless --self-test")
|
|
353
|
+
|
|
354
|
+
devlyn = pathlib.Path(args.devlyn_dir)
|
|
355
|
+
if not devlyn.is_dir():
|
|
356
|
+
sys.stderr.write(f"error: {devlyn} is not a directory\n")
|
|
357
|
+
return 1
|
|
358
|
+
state_path = devlyn / "pipeline.state.json"
|
|
359
|
+
state = read_state(state_path)
|
|
360
|
+
|
|
361
|
+
if args.event == "spawn":
|
|
362
|
+
if args.phase == "verify":
|
|
363
|
+
clear_verify_round_artifacts(devlyn)
|
|
364
|
+
do_spawn(state, args.phase, args.round, args.triggered_by, args.pre_sha, args.engine, args.model)
|
|
365
|
+
else:
|
|
366
|
+
do_complete(state, args.phase, args.verdict, args.findings_file, args.log_file, args.engine, args.model)
|
|
367
|
+
|
|
368
|
+
write_state(state_path, state)
|
|
369
|
+
sys.stdout.write(f"ok: phases.{args.phase}.{args.event}\n")
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
raise SystemExit(main())
|