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.
- package/AGENTS.md +10 -94
- package/CLAUDE.md +7 -160
- package/LICENSE +21 -0
- package/README.md +68 -111
- package/bin/devlyn.js +265 -259
- package/bin/instruction-templates.json +43 -2
- package/bin/instructions.js +29 -8
- package/bin/skill-history.json +140 -0
- package/bin/skill-ownership.js +67 -0
- package/config/skills/_shared/adapters/claude.md +8 -85
- package/config/skills/_shared/adapters/codex.md +14 -34
- package/config/skills/_shared/adapters/omp.md +1 -11
- package/config/skills/_shared/codex-monitored.sh +4 -54
- package/config/skills/_shared/engine-doctor.sh +18 -58
- package/config/skills/_shared/engine-preflight.md +15 -47
- package/config/skills/_shared/expected-contract.py +242 -0
- package/config/skills/_shared/expected.schema.json +10 -95
- package/config/skills/_shared/invocation-receipt.py +26 -757
- package/config/skills/_shared/platform-support.py +37 -28
- package/config/skills/_shared/role-config.py +100 -421
- package/config/skills/_shared/runtime-principles.md +17 -93
- package/config/skills/_shared/task-complete.py +490 -285
- package/config/skills/_shared/task-completion.md +235 -0
- package/config/skills/devlyn-engines/SKILL.md +9 -11
- package/config/skills/devlyn-ideate/SKILL.md +57 -107
- package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
- package/config/skills/devlyn-ideate/references/loop.md +110 -0
- package/config/skills/devlyn-ideate/references/package-format.md +128 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
- package/node_modules/@inquirer/ansi/LICENSE +22 -0
- package/node_modules/@inquirer/ansi/README.md +121 -0
- package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
- package/node_modules/@inquirer/ansi/dist/index.js +21 -0
- package/node_modules/@inquirer/ansi/package.json +79 -0
- package/node_modules/@inquirer/checkbox/LICENSE +22 -0
- package/node_modules/@inquirer/checkbox/README.md +265 -0
- package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
- package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
- package/node_modules/@inquirer/checkbox/package.json +93 -0
- package/node_modules/@inquirer/core/LICENSE +22 -0
- package/node_modules/@inquirer/core/README.md +424 -0
- package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/index.js +12 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
- package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
- package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
- package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
- package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
- package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
- package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
- package/node_modules/@inquirer/core/package.json +98 -0
- package/node_modules/@inquirer/figures/LICENSE +22 -0
- package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
- package/node_modules/@inquirer/figures/dist/index.js +315 -0
- package/node_modules/@inquirer/figures/package.json +81 -0
- package/node_modules/@inquirer/select/LICENSE +22 -0
- package/node_modules/@inquirer/select/README.md +258 -0
- package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
- package/node_modules/@inquirer/select/dist/index.js +191 -0
- package/node_modules/@inquirer/select/package.json +93 -0
- package/node_modules/@inquirer/type/LICENSE +22 -0
- package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
- package/node_modules/@inquirer/type/dist/index.js +2 -0
- package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
- package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
- package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
- package/node_modules/@inquirer/type/dist/utils.js +16 -0
- package/node_modules/@inquirer/type/package.json +88 -0
- package/node_modules/cli-width/LICENSE +13 -0
- package/node_modules/cli-width/README.md +71 -0
- package/node_modules/cli-width/index.d.ts +13 -0
- package/node_modules/cli-width/index.js +49 -0
- package/node_modules/cli-width/package.json +40 -0
- package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
- package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
- package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
- package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
- package/node_modules/fast-string-truncated-width/license +21 -0
- package/node_modules/fast-string-truncated-width/package.json +35 -0
- package/node_modules/fast-string-truncated-width/readme.md +59 -0
- package/node_modules/fast-string-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-width/dist/index.js +14 -0
- package/node_modules/fast-string-width/license +21 -0
- package/node_modules/fast-string-width/package.json +34 -0
- package/node_modules/fast-string-width/readme.md +42 -0
- package/node_modules/fast-wrap-ansi/LICENSE +23 -0
- package/node_modules/fast-wrap-ansi/README.md +26 -0
- package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
- package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
- package/node_modules/fast-wrap-ansi/package.json +51 -0
- package/node_modules/mute-stream/LICENSE +15 -0
- package/node_modules/mute-stream/README.md +68 -0
- package/node_modules/mute-stream/lib/index.js +142 -0
- package/node_modules/mute-stream/package.json +54 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +74 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
- package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.js +279 -0
- package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/package.json +3 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
- package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
- package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.js +275 -0
- package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/package.json +3 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
- package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
- package/node_modules/signal-exit/package.json +106 -0
- package/package.json +17 -4
- package/config/commit-conventions.md +0 -28
- package/config/skills/_shared/adapters/README.md +0 -74
- package/config/skills/_shared/adapters/grok.md +0 -100
- package/config/skills/_shared/archive_run.py +0 -969
- package/config/skills/_shared/codex-config.md +0 -81
- package/config/skills/_shared/collect-codex-findings.py +0 -505
- package/config/skills/_shared/finish-gate.py +0 -521
- package/config/skills/_shared/grok-anchor-guard.py +0 -78
- package/config/skills/_shared/judge-output-parser.py +0 -200
- package/config/skills/_shared/judge-role-evidence.py +0 -320
- package/config/skills/_shared/phase-prompt-render.py +0 -183
- package/config/skills/_shared/process-evidence.py +0 -926
- package/config/skills/_shared/resolve-bootstrap.py +0 -1410
- package/config/skills/_shared/resolve-stop-hook.py +0 -359
- package/config/skills/_shared/run-bounded.py +0 -58
- package/config/skills/_shared/spec-verify-check.py +0 -5083
- package/config/skills/_shared/state-phase-write.py +0 -5989
- package/config/skills/_shared/terminal-claim-check.py +0 -777
- package/config/skills/_shared/verify-merge-findings.py +0 -3528
- package/config/skills/devlyn-design-ui/SKILL.md +0 -650
- package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
- package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
- package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
- package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
- package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
- package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
- package/config/skills/devlyn-queue/SKILL.md +0 -66
- package/config/skills/devlyn-queue/scripts/append.py +0 -94
- package/config/skills/devlyn-resolve/SKILL.md +0 -379
- package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
- package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
- package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
- package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
- package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
- package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
- package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
- package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
- package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
- package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
- package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
- package/config/templates/prompt-templates.md +0 -71
- package/config/templates/template-feature.spec.md +0 -255
- 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())
|