devlyn-cli 2.9.3 → 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.
Files changed (28) hide show
  1. package/AGENTS.md +3 -3
  2. package/CLAUDE.md +4 -4
  3. package/README.md +4 -4
  4. package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
  5. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
  6. package/bin/devlyn.js +55 -7
  7. package/config/skills/_shared/adapters/README.md +2 -2
  8. package/config/skills/_shared/adapters/grok.md +77 -0
  9. package/config/skills/_shared/archive_run.py +35 -1
  10. package/config/skills/_shared/collect-codex-findings.py +15 -10
  11. package/config/skills/_shared/engine-doctor.sh +6 -3
  12. package/config/skills/_shared/engine-preflight.md +12 -5
  13. package/config/skills/_shared/resolve-bootstrap.py +630 -0
  14. package/config/skills/_shared/resolve-stop-hook.py +325 -0
  15. package/config/skills/_shared/state-phase-write.py +2264 -13
  16. package/config/skills/_shared/terminal-claim-check.py +537 -0
  17. package/config/skills/_shared/verify-merge-findings.py +363 -16
  18. package/config/skills/devlyn:engines/SKILL.md +1 -1
  19. package/config/skills/devlyn:queue/SKILL.md +12 -0
  20. package/config/skills/devlyn:resolve/SKILL.md +54 -73
  21. package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
  22. package/config/skills/devlyn:resolve/references/phases/verify.md +65 -96
  23. package/config/skills/devlyn:resolve/references/state-schema.md +25 -18
  24. package/optional-skills/devlyn:reap/SKILL.md +1 -1
  25. package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
  26. package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
  27. package/package.json +1 -1
  28. 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())