devlyn-cli 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/AGENTS.md +9 -1
  2. package/CLAUDE.md +28 -11
  3. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/task.ko.txt +8 -0
  4. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/NOTES.md +28 -0
  5. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/RETIRED.md +15 -0
  6. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/expected.json +57 -0
  7. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/metadata.json +10 -0
  8. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/setup.sh +50 -0
  9. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/spec.md +59 -0
  10. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/task.txt +9 -0
  11. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/ordered-settlement.js +88 -0
  12. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/reject-invalid.js +45 -0
  13. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/NOTES.md +30 -0
  14. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/RETIRED.md +16 -0
  15. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/expected.json +64 -0
  16. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/metadata.json +10 -0
  17. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/setup.sh +21 -0
  18. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/spec.md +58 -0
  19. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/task.txt +9 -0
  20. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/dup-op.js +54 -0
  21. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/priority-apply.js +82 -0
  22. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/rollback.js +77 -0
  23. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/NOTES.md +99 -0
  24. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/RETIRED.md +18 -0
  25. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/expected.json +64 -0
  26. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/metadata.json +10 -0
  27. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/setup.sh +4 -0
  28. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/spec.md +61 -0
  29. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/task.txt +9 -0
  30. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/correctness-small.js +135 -0
  31. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/scale.js +100 -0
  32. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/validation.js +75 -0
  33. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/NOTES.md +93 -0
  34. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/RETIRED.md +16 -0
  35. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/expected.json +65 -0
  36. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/metadata.json +10 -0
  37. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/setup.sh +18 -0
  38. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/spec.md +66 -0
  39. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/task.txt +9 -0
  40. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/correctness-small.js +84 -0
  41. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/scale.js +184 -0
  42. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/validation.js +74 -0
  43. package/benchmark/auto-resolve/scripts/pair-rejected-fixtures.sh +12 -0
  44. package/benchmark/auto-resolve/scripts/run-fixture.sh +39 -1
  45. package/benchmark/auto-resolve/scripts/test-lint-fixtures.sh +1 -1
  46. package/bin/devlyn.js +42 -1
  47. package/config/skills/_shared/adapters/README.md +7 -0
  48. package/config/skills/_shared/adapters/claude.md +47 -0
  49. package/config/skills/_shared/adapters/omp.md +17 -0
  50. package/config/skills/_shared/archive_run.py +8 -2
  51. package/config/skills/_shared/codex-config.md +22 -6
  52. package/config/skills/_shared/engine-doctor.sh +107 -0
  53. package/config/skills/_shared/engine-preflight.md +18 -11
  54. package/config/skills/_shared/expected.schema.json +26 -0
  55. package/config/skills/_shared/runtime-principles.md +1 -1
  56. package/config/skills/_shared/spec-verify-check.py +773 -486
  57. package/config/skills/_shared/state-phase-write.py +374 -0
  58. package/config/skills/_shared/verify-merge-findings.py +177 -61
  59. package/config/skills/devlyn:engines/SKILL.md +47 -0
  60. package/config/skills/devlyn:ideate/SKILL.md +22 -3
  61. package/config/skills/devlyn:ideate/references/elicitation.md +2 -2
  62. package/config/skills/devlyn:ideate/references/from-spec-mode.md +2 -2
  63. package/config/skills/devlyn:ideate/references/project-mode.md +1 -1
  64. package/config/skills/devlyn:ideate/references/spec-template.md +15 -4
  65. package/config/skills/devlyn:queue/SKILL.md +26 -0
  66. package/config/skills/devlyn:resolve/SKILL.md +54 -24
  67. package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
  68. package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
  69. package/config/skills/devlyn:resolve/references/phases/cleanup.md +1 -1
  70. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
  71. package/config/skills/devlyn:resolve/references/phases/plan.md +3 -2
  72. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +8 -0
  73. package/config/skills/devlyn:resolve/references/phases/verify.md +18 -9
  74. package/config/skills/devlyn:resolve/references/state-schema.md +10 -6
  75. package/optional-skills/devlyn:pencil-pull/SKILL.md +5 -0
  76. package/optional-skills/devlyn:pencil-push/SKILL.md +5 -0
  77. package/package.json +1 -1
  78. 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())