devlyn-cli 4.1.0 → 4.2.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.
Files changed (201) hide show
  1. package/AGENTS.md +10 -94
  2. package/CLAUDE.md +7 -160
  3. package/LICENSE +21 -0
  4. package/README.md +68 -111
  5. package/bin/devlyn.js +265 -259
  6. package/bin/instruction-templates.json +43 -2
  7. package/bin/instructions.js +29 -8
  8. package/bin/skill-history.json +140 -0
  9. package/bin/skill-ownership.js +67 -0
  10. package/config/skills/_shared/adapters/claude.md +8 -85
  11. package/config/skills/_shared/adapters/codex.md +14 -34
  12. package/config/skills/_shared/adapters/omp.md +1 -11
  13. package/config/skills/_shared/codex-monitored.sh +4 -54
  14. package/config/skills/_shared/engine-doctor.sh +18 -58
  15. package/config/skills/_shared/engine-preflight.md +15 -47
  16. package/config/skills/_shared/expected-contract.py +242 -0
  17. package/config/skills/_shared/expected.schema.json +10 -95
  18. package/config/skills/_shared/invocation-receipt.py +26 -757
  19. package/config/skills/_shared/platform-support.py +37 -28
  20. package/config/skills/_shared/role-config.py +100 -421
  21. package/config/skills/_shared/runtime-principles.md +17 -93
  22. package/config/skills/_shared/task-complete.py +490 -285
  23. package/config/skills/_shared/task-completion.md +235 -0
  24. package/config/skills/devlyn-engines/SKILL.md +9 -11
  25. package/config/skills/devlyn-ideate/SKILL.md +57 -107
  26. package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
  27. package/config/skills/devlyn-ideate/references/loop.md +110 -0
  28. package/config/skills/devlyn-ideate/references/package-format.md +128 -0
  29. package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
  30. package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
  31. package/node_modules/@inquirer/ansi/LICENSE +22 -0
  32. package/node_modules/@inquirer/ansi/README.md +121 -0
  33. package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
  34. package/node_modules/@inquirer/ansi/dist/index.js +21 -0
  35. package/node_modules/@inquirer/ansi/package.json +79 -0
  36. package/node_modules/@inquirer/checkbox/LICENSE +22 -0
  37. package/node_modules/@inquirer/checkbox/README.md +265 -0
  38. package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
  39. package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
  40. package/node_modules/@inquirer/checkbox/package.json +93 -0
  41. package/node_modules/@inquirer/core/LICENSE +22 -0
  42. package/node_modules/@inquirer/core/README.md +424 -0
  43. package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
  44. package/node_modules/@inquirer/core/dist/index.js +12 -0
  45. package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
  46. package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
  47. package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
  48. package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
  49. package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
  50. package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
  51. package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
  52. package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
  53. package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
  54. package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
  55. package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
  56. package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
  57. package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
  58. package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
  59. package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
  60. package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
  61. package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
  62. package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
  63. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
  64. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
  65. package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
  66. package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
  67. package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
  68. package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
  69. package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
  70. package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
  71. package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
  72. package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
  73. package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
  74. package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
  75. package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
  76. package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
  77. package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
  78. package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
  79. package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
  80. package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
  81. package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
  82. package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
  83. package/node_modules/@inquirer/core/package.json +98 -0
  84. package/node_modules/@inquirer/figures/LICENSE +22 -0
  85. package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
  86. package/node_modules/@inquirer/figures/dist/index.js +315 -0
  87. package/node_modules/@inquirer/figures/package.json +81 -0
  88. package/node_modules/@inquirer/select/LICENSE +22 -0
  89. package/node_modules/@inquirer/select/README.md +258 -0
  90. package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
  91. package/node_modules/@inquirer/select/dist/index.js +191 -0
  92. package/node_modules/@inquirer/select/package.json +93 -0
  93. package/node_modules/@inquirer/type/LICENSE +22 -0
  94. package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
  95. package/node_modules/@inquirer/type/dist/index.js +2 -0
  96. package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
  97. package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
  98. package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
  99. package/node_modules/@inquirer/type/dist/utils.js +16 -0
  100. package/node_modules/@inquirer/type/package.json +88 -0
  101. package/node_modules/cli-width/LICENSE +13 -0
  102. package/node_modules/cli-width/README.md +71 -0
  103. package/node_modules/cli-width/index.d.ts +13 -0
  104. package/node_modules/cli-width/index.js +49 -0
  105. package/node_modules/cli-width/package.json +40 -0
  106. package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
  107. package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
  108. package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
  109. package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
  110. package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
  111. package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
  112. package/node_modules/fast-string-truncated-width/license +21 -0
  113. package/node_modules/fast-string-truncated-width/package.json +35 -0
  114. package/node_modules/fast-string-truncated-width/readme.md +59 -0
  115. package/node_modules/fast-string-width/dist/index.d.ts +4 -0
  116. package/node_modules/fast-string-width/dist/index.js +14 -0
  117. package/node_modules/fast-string-width/license +21 -0
  118. package/node_modules/fast-string-width/package.json +34 -0
  119. package/node_modules/fast-string-width/readme.md +42 -0
  120. package/node_modules/fast-wrap-ansi/LICENSE +23 -0
  121. package/node_modules/fast-wrap-ansi/README.md +26 -0
  122. package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
  123. package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
  124. package/node_modules/fast-wrap-ansi/package.json +51 -0
  125. package/node_modules/mute-stream/LICENSE +15 -0
  126. package/node_modules/mute-stream/README.md +68 -0
  127. package/node_modules/mute-stream/lib/index.js +142 -0
  128. package/node_modules/mute-stream/package.json +54 -0
  129. package/node_modules/signal-exit/LICENSE.txt +16 -0
  130. package/node_modules/signal-exit/README.md +74 -0
  131. package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  132. package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  133. package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  134. package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  135. package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  136. package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  137. package/node_modules/signal-exit/dist/cjs/index.js +279 -0
  138. package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  139. package/node_modules/signal-exit/dist/cjs/package.json +3 -0
  140. package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  141. package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  142. package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  143. package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  144. package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  145. package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  146. package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  147. package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  148. package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  149. package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  150. package/node_modules/signal-exit/dist/mjs/index.js +275 -0
  151. package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  152. package/node_modules/signal-exit/dist/mjs/package.json +3 -0
  153. package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  154. package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  155. package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  156. package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  157. package/node_modules/signal-exit/package.json +106 -0
  158. package/package.json +17 -4
  159. package/config/commit-conventions.md +0 -28
  160. package/config/skills/_shared/adapters/README.md +0 -74
  161. package/config/skills/_shared/adapters/grok.md +0 -100
  162. package/config/skills/_shared/archive_run.py +0 -969
  163. package/config/skills/_shared/codex-config.md +0 -81
  164. package/config/skills/_shared/collect-codex-findings.py +0 -505
  165. package/config/skills/_shared/finish-gate.py +0 -521
  166. package/config/skills/_shared/grok-anchor-guard.py +0 -78
  167. package/config/skills/_shared/judge-output-parser.py +0 -200
  168. package/config/skills/_shared/judge-role-evidence.py +0 -320
  169. package/config/skills/_shared/phase-prompt-render.py +0 -183
  170. package/config/skills/_shared/process-evidence.py +0 -926
  171. package/config/skills/_shared/resolve-bootstrap.py +0 -1410
  172. package/config/skills/_shared/resolve-stop-hook.py +0 -359
  173. package/config/skills/_shared/run-bounded.py +0 -58
  174. package/config/skills/_shared/spec-verify-check.py +0 -5083
  175. package/config/skills/_shared/state-phase-write.py +0 -5989
  176. package/config/skills/_shared/terminal-claim-check.py +0 -777
  177. package/config/skills/_shared/verify-merge-findings.py +0 -3528
  178. package/config/skills/devlyn-design-ui/SKILL.md +0 -650
  179. package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
  180. package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
  181. package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
  182. package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
  183. package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
  184. package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
  185. package/config/skills/devlyn-queue/SKILL.md +0 -66
  186. package/config/skills/devlyn-queue/scripts/append.py +0 -94
  187. package/config/skills/devlyn-resolve/SKILL.md +0 -379
  188. package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
  189. package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
  190. package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
  191. package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
  192. package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
  193. package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
  194. package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
  195. package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
  196. package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
  197. package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
  198. package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
  199. package/config/templates/prompt-templates.md +0 -71
  200. package/config/templates/template-feature.spec.md +0 -255
  201. package/config/templates/template-product-spec.md +0 -680
@@ -1,777 +0,0 @@
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 runpy
13
- import sys
14
- import tempfile
15
- from dataclasses import dataclass
16
- from typing import TextIO
17
-
18
-
19
- INCOMPLETE_EXIT = 79
20
- VALID_VERIFY_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED"}
21
- SAFE_RUN_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
22
- ENGINE_UNAVAILABLE_RE = re.compile(r"^[A-Za-z0-9_.-]+-unavailable$")
23
- PHASE_ORDER = (
24
- "plan",
25
- "probe_derive",
26
- "implement",
27
- "surface_close",
28
- "build_gate",
29
- "cleanup",
30
- "verify",
31
- "final_report",
32
- )
33
- WORK_PHASE_ORDER = PHASE_ORDER[:-1]
34
- HALT_WITNESS_PHASES = {
35
- "plan-empty": "plan",
36
- "risk-halt": "plan",
37
- "implement-empty": "implement",
38
- }
39
-
40
-
41
- @dataclass(frozen=True)
42
- class Classification:
43
- status: str
44
- phase: str | None
45
- reason: str
46
- run_id: str | None
47
- incomplete: bool
48
-
49
- def receipt(self) -> dict[str, str | None]:
50
- return {
51
- "status": self.status,
52
- "phase": self.phase,
53
- "reason": self.reason,
54
- "run_id": self.run_id,
55
- }
56
-
57
-
58
- def reject_json_constant(token: str) -> None:
59
- raise ValueError(f"invalid JSON numeric constant: {token}")
60
-
61
-
62
- def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
63
- result = {}
64
- for key, value in pairs:
65
- if key in result:
66
- raise ValueError(f"duplicate JSON key: {key}")
67
- result[key] = value
68
- return result
69
-
70
-
71
- def malformed(reason: str, run_id: str | None = None) -> Classification:
72
- return Classification("MALFORMED", None, reason, run_id, True)
73
-
74
-
75
- def incomplete(phase: str, reason: str, run_id: str) -> Classification:
76
- return Classification(f"INCOMPLETE:{phase}", phase, reason, run_id, True)
77
-
78
-
79
- def phase_names(phases: dict[str, object]) -> list[str]:
80
- known = [name for name in PHASE_ORDER if name in phases]
81
- return known + sorted(name for name in phases if name not in PHASE_ORDER)
82
-
83
-
84
- def validate_lifecycle(
85
- name: str, phase: dict[str, object], run_id: str
86
- ) -> Classification | None:
87
- started = phase.get("started_at")
88
- completed = phase.get("completed_at")
89
- if started is not None and (not isinstance(started, str) or not started):
90
- return malformed(f"phase {name} has invalid started_at", run_id)
91
- if completed is not None and (not isinstance(completed, str) or not completed):
92
- return malformed(f"phase {name} has invalid completed_at", run_id)
93
- if completed is not None and started is None:
94
- return malformed(f"phase {name} completed without started_at", run_id)
95
- if started is not None and completed is None:
96
- return incomplete(name, "phase started but not completed", run_id)
97
- return None
98
-
99
-
100
- def valid_final_verdict(value: object) -> bool:
101
- return isinstance(value, str) and (
102
- value in VALID_VERIFY_VERDICTS
103
- or (value.startswith("BLOCKED:") and len(value) > 8)
104
- )
105
-
106
-
107
- def terminal_halt_witness(phases: dict[str, object]) -> tuple[str, str] | None:
108
- final_report = phases.get("final_report")
109
- if not isinstance(final_report, dict):
110
- return None
111
- verdict = final_report.get("verdict")
112
- if not isinstance(verdict, str) or not verdict.startswith("BLOCKED:"):
113
- return None
114
- reason = verdict.removeprefix("BLOCKED:")
115
- target = HALT_WITNESS_PHASES.get(reason)
116
- if target is None and (
117
- reason == "fresh-context-unavailable" or ENGINE_UNAVAILABLE_RE.fullmatch(reason)
118
- ):
119
- reached = [name for name in WORK_PHASE_ORDER if phases.get(name) is not None]
120
- target = reached[-1] if reached else None
121
- if target is None:
122
- return None
123
- phase = phases.get(target)
124
- if not isinstance(phase, dict) or phase.get("completed_at") is None:
125
- return None
126
- target_index = WORK_PHASE_ORDER.index(target)
127
- if any(phases.get(name) is not None for name in WORK_PHASE_ORDER[target_index + 1:]):
128
- return None
129
- return target, reason
130
-
131
-
132
- def exhausted_origin(state: dict[str, object]) -> str | None:
133
- writer = runpy.run_path(str(pathlib.Path(__file__).with_name("state-phase-write.py")))
134
- predecessor = writer["repair_predecessor"](state)
135
- if predecessor is None:
136
- return None
137
- name, entry = predecessor
138
- if entry.get("completed_at") is None:
139
- return None
140
- if name == "verify":
141
- merged = entry.get("merged")
142
- return "verify" if entry.get("verdict") == "NEEDS_WORK" and isinstance(merged, dict) and merged.get("verdict") == "NEEDS_WORK" else None
143
- if name == "cleanup":
144
- return name if entry.get("verdict") == "FAIL" and entry.get("execution_kind") == "orchestrator_commands" else None
145
- if name == "build_gate":
146
- return name if entry.get("verdict") == "FAIL" else None
147
- progress = entry.get("exec")
148
- if not writer["valid_phase_gate_progress"](progress):
149
- return None
150
- if (entry.get("verdict") == "FAIL"
151
- and progress["statuses"][progress["current"] - 1] == "FAIL"):
152
- return "phase_gate"
153
- return None
154
-
155
-
156
- def classify_state_bytes(
157
- root: pathlib.Path,
158
- state_path: pathlib.Path,
159
- state_bytes: bytes,
160
- *,
161
- archived: bool,
162
- ) -> tuple[Classification, dict[str, object] | None]:
163
- try:
164
- state = json.loads(
165
- state_bytes.decode("utf-8"),
166
- parse_constant=reject_json_constant,
167
- object_pairs_hook=reject_duplicate_keys,
168
- )
169
- except (UnicodeError, ValueError):
170
- return malformed(f"run state unreadable or invalid: {state_path}"), None
171
- if not isinstance(state, dict):
172
- return malformed("run state must be a JSON object"), None
173
-
174
- run_id = state.get("run_id")
175
- if not isinstance(run_id, str) or run_id in (".", "..") or not SAFE_RUN_ID_RE.fullmatch(run_id):
176
- return malformed("run state has invalid run_id"), state
177
- phases = state.get("phases")
178
- if not isinstance(phases, dict):
179
- return malformed("run state phases must be a JSON object", run_id), state
180
-
181
- open_span: Classification | None = None
182
- for name in phase_names(phases):
183
- phase = phases[name]
184
- if phase is None:
185
- continue
186
- if not isinstance(phase, dict):
187
- return malformed(f"phase {name} must be a JSON object or null", run_id), state
188
- lifecycle = validate_lifecycle(name, phase, run_id)
189
- if lifecycle is not None:
190
- if lifecycle.status == "MALFORMED":
191
- return lifecycle, state
192
- open_span = open_span or lifecycle
193
- history = phase.get("history")
194
- if history is None:
195
- continue
196
- if not isinstance(history, list):
197
- return malformed(f"phase {name} history must be an array", run_id), state
198
- for index, prior in enumerate(history):
199
- if not isinstance(prior, dict):
200
- return malformed(f"phase {name} history[{index}] must be an object", run_id), state
201
- lifecycle = validate_lifecycle(name, prior, run_id)
202
- if lifecycle is not None:
203
- if lifecycle.status == "MALFORMED":
204
- return lifecycle, state
205
- open_span = open_span or incomplete(
206
- name, f"phase {name} history[{index}] started but not completed", run_id
207
- )
208
-
209
- final_report = phases.get("final_report")
210
- final_completed = (
211
- isinstance(final_report, dict) and final_report.get("completed_at") is not None
212
- )
213
- if final_completed and not valid_final_verdict(final_report.get("verdict")):
214
- return malformed("final_report completed with null or invalid verdict", run_id), state
215
- if open_span is not None:
216
- return open_span, state
217
-
218
- verify = phases.get("verify")
219
- if isinstance(verify, dict) and verify.get("completed_at") is not None:
220
- verdict = verify.get("verdict")
221
- if verdict is None:
222
- return incomplete("verify", "verify completed without verdict", run_id), state
223
- if not isinstance(verdict, str) or verdict not in VALID_VERIFY_VERDICTS:
224
- return malformed("verify has invalid verdict", run_id), state
225
- else:
226
- verdict = None
227
-
228
- archive_state = root / ".devlyn" / "runs" / run_id / "pipeline.state.json"
229
- archive_valid = (
230
- archived and state_path.parent.name == run_id
231
- ) or archive_state.is_file()
232
- report_verdict = final_report.get("verdict") if isinstance(final_report, dict) else None
233
- rounds = state.get("rounds")
234
- schema_v3 = state.get("version") == "3.0"
235
- has_v3_repair_round = schema_v3 and any(
236
- isinstance(phases.get(name), dict) and "round" in phases[name]
237
- for name in ("implement", "build_gate", "cleanup", "verify")
238
- )
239
- try:
240
- origin = exhausted_origin(state) if schema_v3 and (rounds is not None or has_v3_repair_round) else None
241
- except SystemExit:
242
- return malformed("repair predecessor has invalid round", run_id), state
243
- exhausted = (isinstance(rounds, dict) and type(rounds.get("global")) is int
244
- and type(rounds.get("max_rounds")) is int and rounds["global"] == rounds["max_rounds"]
245
- and rounds["max_rounds"] >= 1)
246
- claims_exhaustion = schema_v3 and (report_verdict == "BLOCKED:repair-budget-exhausted" or (
247
- report_verdict == "NEEDS_WORK" and state.get("mode") != "verify-only"
248
- and (rounds is not None or origin is not None)
249
- ))
250
- if claims_exhaustion:
251
- expected = "NEEDS_WORK" if origin == "verify" else "BLOCKED:repair-budget-exhausted"
252
- blocked_source = any(
253
- isinstance(entry, dict) and entry.get("verdict") == "BLOCKED"
254
- for name, entry in phases.items() if name != "final_report"
255
- )
256
- if not final_completed or not exhausted or origin is None or report_verdict != expected or blocked_source:
257
- return incomplete("final_report", "repair exhaustion witness invalid", run_id), state
258
- if not archive_valid:
259
- return incomplete("archive", "repair exhaustion report not archived", run_id), state
260
- return Classification("CLEAN", None, f"repair budget exhausted at {origin}", run_id, False), state
261
- if (origin is not None and verdict is not None
262
- and not (state.get("mode") == "verify-only" and origin == "verify" and report_verdict == "NEEDS_WORK")
263
- and not (isinstance(report_verdict, str) and report_verdict.startswith("BLOCKED:"))):
264
- return incomplete("final_report", "current repair exhaustion cannot use stale VERIFY", run_id), state
265
- if verdict is not None:
266
- if not final_completed:
267
- return incomplete(
268
- "final_report", "verify completed but final report not completed", run_id,
269
- ), state
270
- if not archive_valid:
271
- return incomplete(
272
- "archive", "final report completed but run state not archived", run_id,
273
- ), state
274
- return Classification(
275
- "CLEAN", None, "verify and terminal archive complete", run_id, False
276
- ), state
277
-
278
- witness = terminal_halt_witness(phases)
279
- if witness is not None:
280
- phase, reason = witness
281
- if not archive_valid:
282
- return incomplete("archive", "terminal halt witness not archived", run_id), state
283
- return Classification(
284
- "CLEAN", None, f"terminal halt witnessed at {phase}: {reason}", run_id, False,
285
- ), state
286
-
287
- return incomplete("verify", "verify did not complete with a valid verdict", run_id), state
288
-
289
-
290
- def classify_active_state(
291
- root: pathlib.Path, state_bytes: bytes,
292
- ) -> tuple[Classification, dict[str, object] | None]:
293
- """Classify only the supplied active-state snapshot and return its parsed object."""
294
- state_path = root / ".devlyn" / "pipeline.state.json"
295
- return classify_state_bytes(root, state_path, state_bytes, archived=False)
296
-
297
-
298
- def classify_state(
299
- root: pathlib.Path, state_path: pathlib.Path, *, archived: bool,
300
- ) -> Classification:
301
- try:
302
- state_path.lstat()
303
- except FileNotFoundError:
304
- return malformed(f"run state absent: {state_path}")
305
- except OSError:
306
- return malformed(f"run state unreadable or invalid: {state_path}")
307
- if not state_path.is_file():
308
- return malformed(f"run state is not a file: {state_path}")
309
-
310
- try:
311
- state_bytes = state_path.read_bytes()
312
- except OSError:
313
- return malformed(f"run state unreadable or invalid: {state_path}")
314
- result, _ = classify_state_bytes(
315
- root, state_path, state_bytes, archived=archived,
316
- )
317
- return result
318
-
319
-
320
- def invocation_members(
321
- root: pathlib.Path, before_run_ids: set[str],
322
- ) -> list[tuple[pathlib.Path, bool]]:
323
- members: list[tuple[pathlib.Path, bool]] = []
324
- runs = root / ".devlyn" / "runs"
325
- try:
326
- children = list(runs.iterdir())
327
- except FileNotFoundError:
328
- children = []
329
- except OSError:
330
- return [(runs, True)]
331
- for child in children:
332
- if child.name in before_run_ids:
333
- continue
334
- state_path = child / "pipeline.state.json"
335
- try:
336
- state_path.lstat()
337
- except FileNotFoundError:
338
- continue
339
- except OSError:
340
- pass
341
- members.append((state_path, True))
342
- active = root / ".devlyn" / "pipeline.state.json"
343
- try:
344
- active.lstat()
345
- except FileNotFoundError:
346
- pass
347
- except OSError:
348
- members.append((active, False))
349
- else:
350
- members.append((active, False))
351
- return members
352
-
353
-
354
- def classify(root: pathlib.Path, before_run_ids: set[str] | None = None) -> Classification:
355
- members = invocation_members(root, before_run_ids or set())
356
- if not members:
357
- return Classification(
358
- "NOT_APPLICABLE", None, "no new archived or active run state", None, False
359
- )
360
- results = [
361
- classify_state(root, state_path, archived=archived)
362
- for state_path, archived in members
363
- ]
364
- failures = [result for result in results if result.incomplete]
365
- if not failures:
366
- return Classification(
367
- "CLEAN", None, f"all {len(results)} invocation run states complete", None, False
368
- )
369
- if len(results) == 1:
370
- return failures[0]
371
- status = (
372
- "MALFORMED"
373
- if any(result.status == "MALFORMED" for result in failures)
374
- else "INCOMPLETE:set"
375
- )
376
- phase = None if status == "MALFORMED" else "set"
377
- reason = "invocation run-set incomplete: " + json.dumps([
378
- {"run_id": result.run_id, "status": result.status, "reason": result.reason}
379
- for result in failures
380
- ], separators=(",", ":"))
381
- return Classification(status, phase, reason, None, True)
382
-
383
-
384
- def run_check(
385
- root: pathlib.Path, output: TextIO, before_run_ids: set[str] | None = None,
386
- ) -> int:
387
- result = classify(root, before_run_ids)
388
- if not result.incomplete:
389
- return 0
390
- output.write(json.dumps(result.receipt(), separators=(",", ":")) + "\n")
391
- return INCOMPLETE_EXIT
392
-
393
-
394
- def bound_exit(arm_exit: int, classifier_exit: int) -> int:
395
- if arm_exit == 0 and classifier_exit == INCOMPLETE_EXIT:
396
- return INCOMPLETE_EXIT
397
- return arm_exit
398
-
399
-
400
- def write_state(root: pathlib.Path, state: object) -> None:
401
- devlyn = root / ".devlyn"
402
- devlyn.mkdir(parents=True, exist_ok=True)
403
- (devlyn / "pipeline.state.json").write_text(
404
- json.dumps(state) + "\n", encoding="utf-8"
405
- )
406
-
407
-
408
- def write_archived_state(root: pathlib.Path, state: dict[str, object]) -> None:
409
- archive = root / ".devlyn" / "runs" / str(state["run_id"])
410
- archive.mkdir(parents=True, exist_ok=True)
411
- (archive / "pipeline.state.json").write_text(
412
- json.dumps(state) + "\n", encoding="utf-8"
413
- )
414
-
415
-
416
- def self_test() -> int:
417
- tests = 0
418
- classification, _state = classify_state_bytes(
419
- pathlib.Path("."), pathlib.Path("pipeline.state.json"),
420
- b'{"run_id":"a","run_id":"b","phases":{}}', archived=False,
421
- )
422
- assert classification.status == "MALFORMED"
423
- with tempfile.TemporaryDirectory() as tmp:
424
- base = pathlib.Path(tmp)
425
-
426
- f23_state = {
427
- "version": "3.0",
428
- "run_id": "rs-20260720T133550Z-df45c8e5de59",
429
- "phases": {
430
- "plan": {"started_at": "2026-07-20T13:37:22Z", "completed_at": "2026-07-20T13:42:08Z", "verdict": "PASS"},
431
- "probe_derive": None,
432
- "implement": {"started_at": "2026-07-20T13:42:46Z", "completed_at": "2026-07-20T13:54:01Z", "verdict": "PASS"},
433
- "surface_close": {"started_at": "2026-07-20T14:01:49Z", "completed_at": "2026-07-20T14:04:11Z", "verdict": "BLOCKED"},
434
- "build_gate": None,
435
- "cleanup": None,
436
- "verify": None,
437
- "final_report": {"started_at": "2026-07-20T14:04:40Z", "completed_at": "2026-07-20T14:05:05Z", "verdict": "BLOCKED:surface-close-adjudication-malformed"},
438
- },
439
- }
440
- fs1_state = {
441
- "version": "3.0",
442
- "run_id": "rs-20260720T140815Z-068baf0da60c",
443
- "phases": {
444
- "plan": {"started_at": "2026-07-20T14:11:37Z", "completed_at": "2026-07-20T14:15:58Z", "verdict": "PASS"},
445
- "probe_derive": None,
446
- "implement": {"started_at": "2026-07-20T14:16:12Z", "completed_at": "2026-07-20T14:21:53Z", "verdict": "PASS"},
447
- "surface_close": {"started_at": "2026-07-20T14:40:32Z", "completed_at": "2026-07-20T14:43:53Z", "verdict": "BLOCKED"},
448
- "build_gate": {"started_at": "2026-07-20T14:25:44Z", "completed_at": "2026-07-20T14:25:44Z", "verdict": "PASS"},
449
- "cleanup": {"started_at": "2026-07-20T14:25:57Z", "completed_at": "2026-07-20T14:27:10Z", "verdict": "PASS"},
450
- "verify": {"started_at": "2026-07-20T14:27:42Z", "completed_at": "2026-07-20T14:44:56Z", "verdict": "BLOCKED"},
451
- "final_report": {"started_at": "2026-07-20T14:45:10Z", "completed_at": "2026-07-20T14:45:24Z", "verdict": "BLOCKED:surface-close-adjudication-malformed"},
452
- },
453
- }
454
-
455
- def exhausted_case(origin: str) -> dict[str, object]:
456
- span = {"started_at": "2026-07-20T00:00:00Z", "completed_at": "2026-07-20T00:01:00Z"}
457
- phases = {"verify": {**span, "round": 0, "verdict": "PASS"}}
458
- if origin == "phase_gate":
459
- phases["implement"] = {**span, "round": 1, "verdict": "FAIL",
460
- "exec": {"total": 2, "current": 2, "statuses": ["PASS", "FAIL"]}}
461
- elif origin == "verify":
462
- phases["verify"] = {**span, "round": 1, "verdict": "NEEDS_WORK",
463
- "merged": {"verdict": "NEEDS_WORK"}}
464
- else:
465
- phases[origin] = {**span, "round": 1, "verdict": "FAIL"}
466
- if origin == "cleanup":
467
- phases[origin]["execution_kind"] = "orchestrator_commands"
468
- phases["final_report"] = {**span, "round": 0,
469
- "verdict": "NEEDS_WORK" if origin == "verify" else "BLOCKED:repair-budget-exhausted"}
470
- return {"version": "3.0", "run_id": "repair-" + origin,
471
- "rounds": {"global": 1, "max_rounds": 1},
472
- "phases": phases}
473
-
474
- for origin in ("build_gate", "cleanup", "verify", "phase_gate"):
475
- state = exhausted_case(origin)
476
- path = base / ".devlyn" / "runs" / state["run_id"] / "pipeline.state.json"
477
- raw = json.dumps(state).encode()
478
- result, _ = classify_state_bytes(base, path, raw, archived=True)
479
- assert result.status == "CLEAN" and not result.incomplete, (origin, result)
480
- tests += 1
481
- result, _ = classify_state_bytes(base, path, raw, archived=False)
482
- assert result.status == "INCOMPLETE:archive", (origin, result)
483
- tests += 1
484
- for bad in (True, 2):
485
- forged = json.loads(raw)
486
- forged["rounds"]["global"] = bad
487
- result, _ = classify_state_bytes(base, path, json.dumps(forged).encode(), archived=True)
488
- assert result.status != "CLEAN", (origin, bad, result)
489
- tests += 1
490
- opened = json.loads(raw)
491
- name = "implement" if origin == "phase_gate" else origin
492
- opened["phases"][name]["completed_at"] = None
493
- result, _ = classify_state_bytes(base, path, json.dumps(opened).encode(), archived=True)
494
- assert result.incomplete, (origin, result)
495
- tests += 1
496
- opened = json.loads(raw)
497
- opened["phases"][name]["history"] = [{"started_at": "2026-07-19T00:00:00Z", "completed_at": None}]
498
- result, _ = classify_state_bytes(base, path, json.dumps(opened).encode(), archived=True)
499
- assert result.incomplete, (origin, result)
500
- tests += 1
501
- opened = json.loads(raw)
502
- opened["phases"]["final_report"]["completed_at"] = None
503
- result, _ = classify_state_bytes(base, path, json.dumps(opened).encode(), archived=True)
504
- assert result.incomplete, (origin, result)
505
- tests += 1
506
- if origin != "verify":
507
- stale = json.loads(raw)
508
- stale["phases"]["final_report"]["verdict"] = "PASS"
509
- result, _ = classify_state_bytes(base, path, json.dumps(stale).encode(), archived=True)
510
- assert result.status == "INCOMPLETE:final_report", (origin, result)
511
- tests += 1
512
- if origin == "phase_gate":
513
- forged = json.loads(raw)
514
- forged["phases"]["implement"]["exec"]["total"] = 1
515
- result, _ = classify_state_bytes(base, path, json.dumps(forged).encode(), archived=True)
516
- assert result.status == "INCOMPLETE:final_report", result
517
- tests += 1
518
- blocked = json.loads(raw)
519
- blocked["phases"]["surface_close"] = {"started_at": "2026-07-19T00:00:00Z",
520
- "completed_at": "2026-07-19T00:01:00Z", "verdict": "BLOCKED"}
521
- result, _ = classify_state_bytes(base, path, json.dumps(blocked).encode(), archived=True)
522
- assert result.status == "INCOMPLETE:final_report", (origin, result)
523
- tests += 1
524
- for missing in (True, False):
525
- without_rounds = json.loads(raw)
526
- if missing:
527
- del without_rounds["rounds"]
528
- else:
529
- without_rounds["rounds"] = None
530
- without_rounds["phases"]["final_report"]["verdict"] = (
531
- "NEEDS_WORK" if origin == "verify" else "PASS"
532
- )
533
- result, _ = classify_state_bytes(
534
- base, path, json.dumps(without_rounds).encode(), archived=True,
535
- )
536
- assert result.status == "INCOMPLETE:final_report", (origin, missing, result)
537
- tests += 1
538
-
539
- def test_higher_priority_terminal_floor() -> None:
540
- state = exhausted_case("build_gate")
541
- state["rounds"] = {"global": 4, "max_rounds": 4}
542
- state["phases"]["build_gate"]["round"] = 4
543
- state["phases"]["verify"]["round"] = 3
544
- state["phases"]["final_report"]["verdict"] = "BLOCKED:finish-gate-unclean"
545
- path = base / ".devlyn" / "runs" / state["run_id"] / "pipeline.state.json"
546
- result, _ = classify_state_bytes(base, path, json.dumps(state).encode(), archived=True)
547
- assert result.status == "CLEAN", result
548
-
549
- def test_invalid_counters_reject_stale_verify() -> None:
550
- for global_ in (True, 5):
551
- state = exhausted_case("build_gate")
552
- state["rounds"] = {"global": global_, "max_rounds": 4}
553
- state["phases"]["build_gate"]["round"] = 4
554
- state["phases"]["verify"]["round"] = 3
555
- state["phases"]["final_report"]["verdict"] = "PASS"
556
- path = base / ".devlyn" / "runs" / state["run_id"] / "pipeline.state.json"
557
- result, _ = classify_state_bytes(base, path, json.dumps(state).encode(), archived=True)
558
- assert result.status != "CLEAN", (global_, result)
559
-
560
- def test_invalid_phase_gate_witness() -> None:
561
- state = exhausted_case("phase_gate")
562
- state["phases"]["implement"]["exec"] = {
563
- "total": 2, "current": 1, "statuses": ["FAIL", "UNKNOWN"],
564
- }
565
- writer = runpy.run_path(str(pathlib.Path(__file__).with_name("state-phase-write.py")))
566
- try:
567
- writer["repair_admission"](state, "implement", 2, None)
568
- except SystemExit as exc:
569
- assert str(exc) == "BLOCKED:repair-edge-invalid", exc
570
- else:
571
- raise AssertionError("writer admitted malformed phase gate")
572
- path = base / ".devlyn" / "runs" / state["run_id"] / "pipeline.state.json"
573
- result, _ = classify_state_bytes(base, path, json.dumps(state).encode(), archived=True)
574
- assert result.status == "INCOMPLETE:final_report", result
575
-
576
- def test_verify_only_direct_report_and_stale_verify() -> None:
577
- state = exhausted_case("verify")
578
- state["mode"] = "verify-only"
579
- state["rounds"] = {"global": 0, "max_rounds": 4}
580
- state["phases"]["verify"]["round"] = 0
581
- path = base / ".devlyn" / "runs" / state["run_id"] / "pipeline.state.json"
582
- result, _ = classify_state_bytes(base, path, json.dumps(state).encode(), archived=True)
583
- assert result.status == "CLEAN", result
584
- for missing in (True, False):
585
- direct = json.loads(json.dumps(state))
586
- if missing:
587
- del direct["rounds"]
588
- else:
589
- direct["rounds"] = None
590
- result, _ = classify_state_bytes(base, path, json.dumps(direct).encode(), archived=True)
591
- assert result.status == "CLEAN", (missing, result)
592
- ordinary = json.loads(json.dumps(direct))
593
- ordinary.pop("mode")
594
- ordinary["phases"]["verify"]["verdict"] = "PASS"
595
- ordinary["phases"]["verify"]["merged"]["verdict"] = "PASS"
596
- ordinary["phases"]["final_report"]["verdict"] = "PASS"
597
- result, _ = classify_state_bytes(base, path, json.dumps(ordinary).encode(), archived=True)
598
- assert result.status == "CLEAN", (missing, result)
599
- stale = exhausted_case("build_gate")
600
- stale["mode"] = "verify-only"
601
- stale["rounds"] = {"global": 0, "max_rounds": 4}
602
- stale["phases"]["final_report"]["verdict"] = "PASS"
603
- path = base / ".devlyn" / "runs" / stale["run_id"] / "pipeline.state.json"
604
- result, _ = classify_state_bytes(base, path, json.dumps(stale).encode(), archived=True)
605
- assert result.status == "INCOMPLETE:final_report", result
606
-
607
- def test_pre_v3_exhaustion_archive_compatibility() -> None:
608
- for version, expected in (("2.0", "CLEAN"), ("3.0", "INCOMPLETE:final_report")):
609
- state = exhausted_case("verify")
610
- state["version"] = version
611
- state["rounds"] = {"global": 4, "max_rounds": 4}
612
- state["phases"]["verify"]["round"] = 4
613
- del state["phases"]["verify"]["merged"]
614
- root = base / f"exhausted-archive-{version}"
615
- write_archived_state(root, state)
616
- result = classify(root)
617
- assert result.status == expected, (version, result)
618
-
619
- test_higher_priority_terminal_floor()
620
- test_invalid_counters_reject_stale_verify()
621
- test_invalid_phase_gate_witness()
622
- test_verify_only_direct_report_and_stale_verify()
623
- test_pre_v3_exhaustion_archive_compatibility()
624
- tests += 11
625
-
626
- root = base / "set-quantification"
627
- write_archived_state(root, f23_state)
628
- write_archived_state(root, fs1_state)
629
- output = io.StringIO()
630
- assert run_check(root, output) == INCOMPLETE_EXIT
631
- receipt = json.loads(output.getvalue())
632
- assert receipt["status"] == "INCOMPLETE:set"
633
- assert f23_state["run_id"] in receipt["reason"]
634
- tests += 1
635
-
636
- root = base / "f25-open-history"
637
- f25_state = {
638
- "version": "3.0",
639
- "run_id": "rs-20260720T102955Z-6246a50033bb",
640
- "phases": {
641
- "build_gate": {
642
- "started_at": "2026-07-20T10:57:53Z",
643
- "completed_at": "2026-07-20T11:00:13Z",
644
- "verdict": "PASS",
645
- "history": [{"started_at": "2026-07-20T10:51:37Z", "completed_at": None, "verdict": None}],
646
- },
647
- "verify": {
648
- "started_at": "2026-07-20T11:02:49Z",
649
- "completed_at": None,
650
- "verdict": None,
651
- },
652
- "final_report": None,
653
- },
654
- }
655
- write_state(root, f25_state)
656
- output = io.StringIO()
657
- assert run_check(root, output) == INCOMPLETE_EXIT
658
- receipt = json.loads(output.getvalue())
659
- assert receipt["status"] == "INCOMPLETE:build_gate"
660
- assert "history[0]" in receipt["reason"]
661
- tests += 1
662
-
663
- root = base / "fs1-clean"
664
- write_archived_state(root, fs1_state)
665
- output = io.StringIO()
666
- assert run_check(root, output) == 0
667
- assert output.getvalue() == ""
668
- tests += 1
669
-
670
- root = base / "clean-full-run"
671
- clean_state = {
672
- "run_id": "clean-run",
673
- "phases": {
674
- "verify": {"started_at": "2026-07-19T02:00:00Z", "completed_at": "2026-07-19T02:01:00Z", "verdict": "PASS"},
675
- "final_report": {"started_at": "2026-07-19T02:01:00Z", "completed_at": "2026-07-19T02:02:00Z", "verdict": "PASS"},
676
- },
677
- }
678
- write_archived_state(root, clean_state)
679
- output = io.StringIO()
680
- assert run_check(root, output) == 0
681
- assert output.getvalue() == ""
682
- tests += 1
683
-
684
- witness_rows = (
685
- ("plan-empty", "plan", "PASS"),
686
- ("risk-halt", "plan", "PASS"),
687
- ("implement-empty", "implement", "PASS"),
688
- ("fresh-context-unavailable", "implement", "BLOCKED"),
689
- ("codex-unavailable", "plan", "BLOCKED"),
690
- )
691
- for reason, halt_phase, phase_verdict in witness_rows:
692
- root = base / f"witness-{reason}"
693
- phases: dict[str, object] = {name: None for name in PHASE_ORDER}
694
- phases[halt_phase] = {
695
- "started_at": "2026-07-20T00:00:00Z",
696
- "completed_at": "2026-07-20T00:01:00Z",
697
- "verdict": phase_verdict,
698
- }
699
- phases["final_report"] = {
700
- "started_at": "2026-07-20T00:01:00Z",
701
- "completed_at": "2026-07-20T00:02:00Z",
702
- "verdict": f"BLOCKED:{reason}",
703
- }
704
- state = {"run_id": f"witness-{reason}", "phases": phases}
705
- assert terminal_halt_witness(phases) == (halt_phase, reason)
706
- write_archived_state(root, state)
707
- assert classify(root).status == "CLEAN"
708
- tests += 1
709
-
710
- root = base / "rolled-back-not-witness"
711
- rolled_back = {
712
- "run_id": "rolled-back",
713
- "phases": {
714
- "surface_close": {
715
- "started_at": "2026-07-20T00:00:00Z",
716
- "completed_at": "2026-07-20T00:01:00Z",
717
- "verdict": None,
718
- "skipped_reason": "surface_close_rolled_back_adjudication_malformed",
719
- "continued_after_block": True,
720
- },
721
- "verify": None,
722
- "final_report": {"started_at": "2026-07-20T00:01:00Z", "completed_at": "2026-07-20T00:02:00Z", "verdict": "BLOCKED:surface-close-adjudication-malformed"},
723
- },
724
- }
725
- write_archived_state(root, rolled_back)
726
- assert classify(root).status == "INCOMPLETE:verify"
727
- tests += 1
728
-
729
- root = base / "malformed-final"
730
- malformed_final = {
731
- "run_id": "malformed-final",
732
- "phases": {
733
- "final_report": {"started_at": "2026-07-20T00:00:00Z", "completed_at": "2026-07-20T00:01:00Z", "verdict": None},
734
- },
735
- }
736
- write_archived_state(root, malformed_final)
737
- output = io.StringIO()
738
- assert run_check(root, output) == INCOMPLETE_EXIT
739
- assert json.loads(output.getvalue())["status"] == "MALFORMED"
740
- tests += 1
741
-
742
- root = base / "not-applicable"
743
- write_archived_state(root, clean_state)
744
- assert classify(root, {"clean-run"}).status == "NOT_APPLICABLE"
745
- tests += 1
746
-
747
- assert bound_exit(0, INCOMPLETE_EXIT) == INCOMPLETE_EXIT
748
- for protected in (86, 78, 124):
749
- assert bound_exit(protected, INCOMPLETE_EXIT) == protected
750
- tests += 1
751
-
752
- print(f"terminal-claim-check self-test: PASS ({tests} tests)")
753
- return 0
754
-
755
-
756
- def parse_args(argv: list[str]) -> argparse.Namespace:
757
- parser = argparse.ArgumentParser(
758
- description="Classify a devlyn terminal claim from project-root state."
759
- )
760
- parser.add_argument("root", nargs="?", default=".", type=pathlib.Path)
761
- parser.add_argument("--self-test", action="store_true")
762
- return parser.parse_args(argv)
763
-
764
-
765
- def main(argv: list[str] | None = None) -> int:
766
- args = parse_args(sys.argv[1:] if argv is None else argv)
767
- if args.self_test:
768
- return self_test()
769
- before_run_ids = {
770
- run_id for run_id in os.environ.get("DEVLYN_RUN_IDS_BEFORE", "").splitlines()
771
- if run_id
772
- }
773
- return run_check(args.root, sys.stdout, before_run_ids)
774
-
775
-
776
- if __name__ == "__main__":
777
- raise SystemExit(main())