deviatdd 2.5.1__py3-none-any.whl
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.
- deviatdd-2.5.1.dist-info/METADATA +386 -0
- deviatdd-2.5.1.dist-info/RECORD +124 -0
- deviatdd-2.5.1.dist-info/WHEEL +4 -0
- deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
- deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
- deviate/__init__.py +3 -0
- deviate/cli/__init__.py +824 -0
- deviate/cli/_common.py +155 -0
- deviate/cli/adhoc.py +183 -0
- deviate/cli/constitution.py +113 -0
- deviate/cli/feature.py +94 -0
- deviate/cli/init.py +485 -0
- deviate/cli/inspect.py +208 -0
- deviate/cli/macro.py +937 -0
- deviate/cli/meso.py +1894 -0
- deviate/cli/micro.py +3249 -0
- deviate/cli/review.py +441 -0
- deviate/core/__init__.py +0 -0
- deviate/core/_shared.py +20 -0
- deviate/core/agent.py +505 -0
- deviate/core/cache_discipline.py +65 -0
- deviate/core/commands.py +202 -0
- deviate/core/commit.py +86 -0
- deviate/core/complexity.py +36 -0
- deviate/core/constitution.py +85 -0
- deviate/core/contract.py +17 -0
- deviate/core/convention.py +147 -0
- deviate/core/epic.py +73 -0
- deviate/core/issues.py +46 -0
- deviate/core/prd.py +18 -0
- deviate/core/profile.py +33 -0
- deviate/core/repo.py +47 -0
- deviate/core/run_logger.py +50 -0
- deviate/core/tasks_ledger.py +69 -0
- deviate/core/treesitter/__init__.py +31 -0
- deviate/core/treesitter/analysis.py +457 -0
- deviate/core/treesitter/models.py +35 -0
- deviate/core/treesitter/parser.py +184 -0
- deviate/core/treesitter/queries/bash.scm +7 -0
- deviate/core/treesitter/queries/c_sharp.scm +11 -0
- deviate/core/treesitter/queries/cpp.scm +9 -0
- deviate/core/treesitter/queries/css.scm +4 -0
- deviate/core/treesitter/queries/dockerfile.scm +8 -0
- deviate/core/treesitter/queries/elixir.scm +6 -0
- deviate/core/treesitter/queries/go.scm +9 -0
- deviate/core/treesitter/queries/hcl.scm +3 -0
- deviate/core/treesitter/queries/html.scm +3 -0
- deviate/core/treesitter/queries/javascript.scm +12 -0
- deviate/core/treesitter/queries/json.scm +4 -0
- deviate/core/treesitter/queries/kotlin.scm +8 -0
- deviate/core/treesitter/queries/markdown.scm +4 -0
- deviate/core/treesitter/queries/python.scm +10 -0
- deviate/core/treesitter/queries/rust.scm +12 -0
- deviate/core/treesitter/queries/sql.scm +7 -0
- deviate/core/treesitter/queries/swift.scm +10 -0
- deviate/core/treesitter/queries/toml.scm +3 -0
- deviate/core/treesitter/queries/tsx.scm +14 -0
- deviate/core/treesitter/queries/typescript.scm +14 -0
- deviate/core/treesitter/queries/yaml.scm +4 -0
- deviate/core/validation.py +153 -0
- deviate/core/worktree.py +141 -0
- deviate/main.py +4 -0
- deviate/prompts/__init__.py +0 -0
- deviate/prompts/assembly.py +122 -0
- deviate/prompts/auto/__init__.py +1 -0
- deviate/prompts/auto/execute.md +62 -0
- deviate/prompts/auto/explore.md +103 -0
- deviate/prompts/auto/green.md +137 -0
- deviate/prompts/auto/judge.md +260 -0
- deviate/prompts/auto/plan.md +127 -0
- deviate/prompts/auto/prd.md +108 -0
- deviate/prompts/auto/red.md +156 -0
- deviate/prompts/auto/refactor.md +166 -0
- deviate/prompts/auto/research.md +111 -0
- deviate/prompts/auto/shard.md +90 -0
- deviate/prompts/auto/specify.md +77 -0
- deviate/prompts/auto/tasks.md +191 -0
- deviate/prompts/commands/deviate-adhoc.md +216 -0
- deviate/prompts/commands/deviate-architecture.md +147 -0
- deviate/prompts/commands/deviate-constitution.md +215 -0
- deviate/prompts/commands/deviate-e2e.md +264 -0
- deviate/prompts/commands/deviate-execute.md +223 -0
- deviate/prompts/commands/deviate-explore.md +253 -0
- deviate/prompts/commands/deviate-flows.md +226 -0
- deviate/prompts/commands/deviate-green.md +217 -0
- deviate/prompts/commands/deviate-hotfix.md +188 -0
- deviate/prompts/commands/deviate-init.md +170 -0
- deviate/prompts/commands/deviate-judge.md +193 -0
- deviate/prompts/commands/deviate-merge.md +221 -0
- deviate/prompts/commands/deviate-plan.md +158 -0
- deviate/prompts/commands/deviate-pr.md +144 -0
- deviate/prompts/commands/deviate-prd.md +216 -0
- deviate/prompts/commands/deviate-prune.md +260 -0
- deviate/prompts/commands/deviate-red.md +231 -0
- deviate/prompts/commands/deviate-refactor.md +211 -0
- deviate/prompts/commands/deviate-release.md +123 -0
- deviate/prompts/commands/deviate-research.md +359 -0
- deviate/prompts/commands/deviate-review.md +289 -0
- deviate/prompts/commands/deviate-shard.md +226 -0
- deviate/prompts/commands/deviate-tasks.md +281 -0
- deviate/prompts/commands/deviate-triage.md +141 -0
- deviate/prompts/constitution_seed.md +51 -0
- deviate/prompts/core/core.md +21 -0
- deviate/prompts/core/macro-auto.md +59 -0
- deviate/prompts/core/macro-command.md +54 -0
- deviate/prompts/core/macro-skill.md +54 -0
- deviate/prompts/core/meso-auto.md +63 -0
- deviate/prompts/core/meso-command.md +58 -0
- deviate/prompts/core/meso-skill.md +58 -0
- deviate/prompts/core/micro-auto.md +76 -0
- deviate/prompts/core/micro-command.md +67 -0
- deviate/prompts/core/micro-skill.md +67 -0
- deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
- deviate/prompts/governance/__init__.py +0 -0
- deviate/prompts/governance/agents_seed.md +1 -0
- deviate/prompts/governance/claudemd_seed.md +1 -0
- deviate/prompts/governance/graphite_seed.md +18 -0
- deviate/prompts/governance/libref_seed.md +3 -0
- deviate/state/__init__.py +0 -0
- deviate/state/config.py +257 -0
- deviate/state/ledger.py +407 -0
- deviate/ui/__init__.py +5 -0
- deviate/ui/monitor.py +187 -0
- deviate/ui/render.py +26 -0
deviate/state/ledger.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import warnings
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import (
|
|
11
|
+
BaseModel,
|
|
12
|
+
Field,
|
|
13
|
+
ValidationError as PydanticValidationError,
|
|
14
|
+
field_validator,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import fcntl
|
|
19
|
+
|
|
20
|
+
HAS_FCNTL = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HAS_FCNTL = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IssueRecord(BaseModel):
|
|
26
|
+
issue_id: str
|
|
27
|
+
type: str
|
|
28
|
+
title: str = Field(min_length=1)
|
|
29
|
+
status: Literal["DRAFT", "BACKLOG", "SPECIFIED", "SHARDED", "COMPLETED"] = "DRAFT"
|
|
30
|
+
source_file: str
|
|
31
|
+
blocked_by: list[str] = []
|
|
32
|
+
coordinates_with: list[str] = []
|
|
33
|
+
timestamp: datetime
|
|
34
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
35
|
+
flow_refs: list[str] = Field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
model_config = {"extra": "forbid"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read_ledger(path: Path) -> list[dict]:
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return []
|
|
43
|
+
records: list[dict] = []
|
|
44
|
+
with path.open("r", encoding="utf-8") as f:
|
|
45
|
+
for line_no, line in enumerate(f, start=1):
|
|
46
|
+
line = line.strip()
|
|
47
|
+
if not line:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
records.append(json.loads(line))
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
warnings.warn(
|
|
53
|
+
f"Skipping malformed JSONL line {line_no} in {path}",
|
|
54
|
+
stacklevel=2,
|
|
55
|
+
)
|
|
56
|
+
continue
|
|
57
|
+
return records
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TaskRecord(BaseModel):
|
|
61
|
+
id: str
|
|
62
|
+
issue_id: str
|
|
63
|
+
description: str = Field(min_length=1)
|
|
64
|
+
status: Literal[
|
|
65
|
+
"PENDING",
|
|
66
|
+
"RED",
|
|
67
|
+
"GREEN",
|
|
68
|
+
"JUDGE",
|
|
69
|
+
"REFACTOR",
|
|
70
|
+
"COMPLETED",
|
|
71
|
+
"FAILED",
|
|
72
|
+
] = "PENDING"
|
|
73
|
+
execution_mode: Literal["TDD", "DIRECT", "EXECUTE", "E2E", "IMMEDIATE"] = "TDD"
|
|
74
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
75
|
+
|
|
76
|
+
model_config = {"extra": "forbid"}
|
|
77
|
+
|
|
78
|
+
@field_validator("id")
|
|
79
|
+
@classmethod
|
|
80
|
+
def _validate_task_id(cls, v: str) -> str:
|
|
81
|
+
if not re.match(r"^TSK-\d{3}-\d{2}$", v):
|
|
82
|
+
raise ValueError(f"Invalid task ID format: {v}")
|
|
83
|
+
return v
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _append_record(
|
|
87
|
+
record_json: str,
|
|
88
|
+
record_id: str,
|
|
89
|
+
id_field: str,
|
|
90
|
+
ledger_path: Path,
|
|
91
|
+
) -> bool:
|
|
92
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
with ledger_path.open("a+", encoding="utf-8") as f:
|
|
94
|
+
if HAS_FCNTL:
|
|
95
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
96
|
+
try:
|
|
97
|
+
f.seek(0)
|
|
98
|
+
for line in f:
|
|
99
|
+
line = line.strip()
|
|
100
|
+
if not line:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(line)
|
|
104
|
+
if data.get(id_field) == record_id:
|
|
105
|
+
return False
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
continue
|
|
108
|
+
f.write(record_json + "\n")
|
|
109
|
+
finally:
|
|
110
|
+
if HAS_FCNTL:
|
|
111
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _append_with_compound_key(
|
|
116
|
+
record_json: str,
|
|
117
|
+
key_fields: list[str],
|
|
118
|
+
ledger_path: Path,
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""Append a record only if no existing entry matches all *key_fields* values."""
|
|
121
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
record_data = json.loads(record_json)
|
|
123
|
+
with ledger_path.open("a+", encoding="utf-8") as f:
|
|
124
|
+
if HAS_FCNTL:
|
|
125
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
126
|
+
try:
|
|
127
|
+
f.seek(0)
|
|
128
|
+
for line in f:
|
|
129
|
+
line = line.strip()
|
|
130
|
+
if not line:
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(line)
|
|
134
|
+
if all(data.get(k) == record_data.get(k) for k in key_fields):
|
|
135
|
+
return False
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
continue
|
|
138
|
+
f.write(record_json + "\n")
|
|
139
|
+
finally:
|
|
140
|
+
if HAS_FCNTL:
|
|
141
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def append_issue_transition(record: IssueRecord, ledger_path: Path) -> bool:
|
|
146
|
+
"""Append a status-transition entry for an issue.
|
|
147
|
+
|
|
148
|
+
Idempotency is checked on the ``(issue_id, status)`` compound key so that
|
|
149
|
+
multiple transitions for the same issue (e.g. BACKLOG → CLAIMED →
|
|
150
|
+
COMPLETED) are all recorded, but re-running the same transition is safe.
|
|
151
|
+
"""
|
|
152
|
+
return _append_with_compound_key(
|
|
153
|
+
record_json=record.model_dump_json(),
|
|
154
|
+
key_fields=["issue_id", "status"],
|
|
155
|
+
ledger_path=ledger_path,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def append_task_record(record: TaskRecord, ledger_path: Path) -> bool:
|
|
160
|
+
return _append_record(
|
|
161
|
+
record_json=record.model_dump_json(),
|
|
162
|
+
record_id=record.id,
|
|
163
|
+
id_field="id",
|
|
164
|
+
ledger_path=ledger_path,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def append_task_transition(record: TaskRecord, ledger_path: Path) -> bool:
|
|
169
|
+
"""Append a status-transition entry for a task.
|
|
170
|
+
|
|
171
|
+
Idempotency is checked on the ``(id, status)`` compound key so that
|
|
172
|
+
multiple transitions for the same task (e.g. PENDING → RED → GREEN)
|
|
173
|
+
are all recorded, but re-running the same transition is safe.
|
|
174
|
+
"""
|
|
175
|
+
return _append_with_compound_key(
|
|
176
|
+
record_json=record.model_dump_json(),
|
|
177
|
+
key_fields=["id", "status"],
|
|
178
|
+
ledger_path=ledger_path,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def resolve_issue_record(issue_id: str, ledger_path: Path) -> IssueRecord | None:
|
|
183
|
+
"""Resolve the authoritative record for *issue_id*.
|
|
184
|
+
|
|
185
|
+
``COMPLETED`` is a terminal status and always takes precedence over later
|
|
186
|
+
non-``COMPLETED`` entries: once an issue has been recorded ``COMPLETED``,
|
|
187
|
+
no subsequent ``SPECIFIED`` / ``BACKLOG`` / ``DRAFT`` transition overrides
|
|
188
|
+
it — even if that transition appears later in the ledger. This guards
|
|
189
|
+
against merge flows that re-append a non-terminal transition after the
|
|
190
|
+
``COMPLETED`` write, and against idempotent merges whose write order is
|
|
191
|
+
non-monotonic.
|
|
192
|
+
|
|
193
|
+
Among non-``COMPLETED`` entries, the most recent valid record by file
|
|
194
|
+
position wins (the prior behaviour).
|
|
195
|
+
|
|
196
|
+
Tolerates sparse transitions (e.g. bare ``{issue_id, status, timestamp}``
|
|
197
|
+
written by external tools like squash-merge) by merging them with the
|
|
198
|
+
last fully-resolved record so they are not silently dropped by Pydantic
|
|
199
|
+
validation.
|
|
200
|
+
"""
|
|
201
|
+
records = _read_ledger(ledger_path)
|
|
202
|
+
fallback: IssueRecord | None = None
|
|
203
|
+
base: IssueRecord | None = None
|
|
204
|
+
|
|
205
|
+
def _resolve_base(exclude: dict) -> IssueRecord | None:
|
|
206
|
+
for prev in reversed(records):
|
|
207
|
+
if prev.get("issue_id") != issue_id or prev is exclude:
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
return IssueRecord.model_validate(prev)
|
|
211
|
+
except PydanticValidationError:
|
|
212
|
+
continue
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
for data in reversed(records):
|
|
216
|
+
if data.get("issue_id") != issue_id:
|
|
217
|
+
continue
|
|
218
|
+
try:
|
|
219
|
+
candidate = IssueRecord.model_validate(data)
|
|
220
|
+
except PydanticValidationError:
|
|
221
|
+
# Sparse transition — resolve base on first need.
|
|
222
|
+
if base is None:
|
|
223
|
+
base = _resolve_base(data)
|
|
224
|
+
if base is None:
|
|
225
|
+
continue
|
|
226
|
+
merged = {**base.model_dump(), **data}
|
|
227
|
+
try:
|
|
228
|
+
candidate = IssueRecord.model_validate(merged)
|
|
229
|
+
except PydanticValidationError:
|
|
230
|
+
continue
|
|
231
|
+
# COMPLETED is terminal — return immediately, regardless of file order.
|
|
232
|
+
if candidate.status == "COMPLETED":
|
|
233
|
+
return candidate
|
|
234
|
+
# Track the latest non-COMPLETED candidate as a fallback.
|
|
235
|
+
if fallback is None:
|
|
236
|
+
fallback = candidate
|
|
237
|
+
|
|
238
|
+
return fallback
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def append_issue_record(record: IssueRecord, ledger_path: Path) -> bool:
|
|
242
|
+
return _append_record(
|
|
243
|
+
record_json=record.model_dump_json(),
|
|
244
|
+
record_id=record.issue_id,
|
|
245
|
+
id_field="issue_id",
|
|
246
|
+
ledger_path=ledger_path,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class LedgerFilter(BaseModel):
|
|
251
|
+
entity_type: Literal["issue", "task"]
|
|
252
|
+
status_filter: str | None = None
|
|
253
|
+
limit: int = Field(default=20, gt=0)
|
|
254
|
+
offset: int = Field(default=0, ge=0)
|
|
255
|
+
sort_by: Literal["created_at", "timestamp", "status"] = "created_at"
|
|
256
|
+
sort_desc: bool = True
|
|
257
|
+
model_config = {"extra": "forbid"}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _read_ledger_strict(path: Path) -> list[dict]:
|
|
261
|
+
if not path.exists():
|
|
262
|
+
return []
|
|
263
|
+
records: list[dict] = []
|
|
264
|
+
with path.open("r", encoding="utf-8") as f:
|
|
265
|
+
for line_no, line in enumerate(f, start=1):
|
|
266
|
+
line = line.strip()
|
|
267
|
+
if not line:
|
|
268
|
+
continue
|
|
269
|
+
try:
|
|
270
|
+
records.append(json.loads(line))
|
|
271
|
+
except json.JSONDecodeError:
|
|
272
|
+
raise ValueError(f"Malformed JSONL line {line_no} in {path}")
|
|
273
|
+
return records
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def filter_tasks(ledger_path: Path, filter_obj: LedgerFilter) -> list[TaskRecord]:
|
|
277
|
+
records = _read_ledger_strict(ledger_path)
|
|
278
|
+
seen: set[str] = set()
|
|
279
|
+
deduped: list[dict] = []
|
|
280
|
+
for rec in records:
|
|
281
|
+
task_id = rec.get("id")
|
|
282
|
+
if task_id and task_id in seen:
|
|
283
|
+
continue
|
|
284
|
+
if task_id:
|
|
285
|
+
seen.add(task_id)
|
|
286
|
+
deduped.append(rec)
|
|
287
|
+
if filter_obj.status_filter:
|
|
288
|
+
deduped = [r for r in deduped if r.get("status") == filter_obj.status_filter]
|
|
289
|
+
sort_key = filter_obj.sort_by
|
|
290
|
+
deduped.sort(
|
|
291
|
+
key=lambda r: r.get(sort_key, "") or "",
|
|
292
|
+
reverse=filter_obj.sort_desc,
|
|
293
|
+
)
|
|
294
|
+
start = filter_obj.offset
|
|
295
|
+
end = start + filter_obj.limit
|
|
296
|
+
result = deduped[start:end]
|
|
297
|
+
tasks: list[TaskRecord] = []
|
|
298
|
+
for r in result:
|
|
299
|
+
try:
|
|
300
|
+
tasks.append(TaskRecord.model_validate(r))
|
|
301
|
+
except PydanticValidationError as e:
|
|
302
|
+
warnings.warn(f"Skipping invalid task record: {e}")
|
|
303
|
+
continue
|
|
304
|
+
return tasks
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class RollbackSnapshot(BaseModel):
|
|
308
|
+
phase: str
|
|
309
|
+
branch: str
|
|
310
|
+
commit_sha: str = Field(pattern=r"^[a-f0-9]{40}$")
|
|
311
|
+
red_sha: str = Field(pattern=r"^[a-f0-9]{40}$")
|
|
312
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
313
|
+
reason: str
|
|
314
|
+
restored: bool = False
|
|
315
|
+
model_config = {"extra": "forbid"}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
ROLLBACK_LEDGER_NAME = "rollback.jsonl"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def append_rollback_snapshot(snapshot: RollbackSnapshot, deviate_dir: Path) -> bool:
|
|
322
|
+
"""Persist a RollbackSnapshot to .deviate/rollback.jsonl.
|
|
323
|
+
|
|
324
|
+
Idempotency is checked on the (phase, commit_sha) compound key so that
|
|
325
|
+
re-running the same rollback does not create duplicate entries.
|
|
326
|
+
"""
|
|
327
|
+
ledger_path = deviate_dir / ROLLBACK_LEDGER_NAME
|
|
328
|
+
return _append_with_compound_key(
|
|
329
|
+
record_json=snapshot.model_dump_json(),
|
|
330
|
+
key_fields=["phase", "commit_sha"],
|
|
331
|
+
ledger_path=ledger_path,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class AdhocRecord(BaseModel):
|
|
336
|
+
issue_id: str = Field(min_length=1)
|
|
337
|
+
description: str = Field(min_length=1)
|
|
338
|
+
execution_mode: Literal["TDD", "DIRECT", "EXECUTE", "E2E", "IMMEDIATE"] = "DIRECT"
|
|
339
|
+
status: Literal["PENDING", "COMPLETED"] = "PENDING"
|
|
340
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
341
|
+
flow_refs: list[str] = Field(default_factory=list)
|
|
342
|
+
|
|
343
|
+
model_config = {"extra": "forbid"}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _parse_timestamp(value: object) -> datetime:
|
|
347
|
+
"""Parse an ISO 8601 timestamp string to a timezone-aware datetime.
|
|
348
|
+
|
|
349
|
+
Returns ``datetime.min`` (UTC) for unparseable or missing values so that
|
|
350
|
+
malformed entries sort to the bottom rather than crashing the comparator.
|
|
351
|
+
"""
|
|
352
|
+
if not isinstance(value, str):
|
|
353
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
354
|
+
try:
|
|
355
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
356
|
+
except (ValueError, TypeError):
|
|
357
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _get_unblocked_backlog_features(ledger_path: Path) -> list[IssueRecord]:
|
|
361
|
+
records = _read_ledger(ledger_path)
|
|
362
|
+
if not records:
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
status_map: dict[str, str] = {}
|
|
366
|
+
for data in records:
|
|
367
|
+
issue_id = data.get("issue_id")
|
|
368
|
+
status = data.get("status")
|
|
369
|
+
if issue_id and status:
|
|
370
|
+
status_map[issue_id] = status
|
|
371
|
+
|
|
372
|
+
typed: list[dict] = [r for r in records if r.get("type") is not None]
|
|
373
|
+
issue_map: dict[str, dict] = {}
|
|
374
|
+
for f in typed:
|
|
375
|
+
issue_map[f["issue_id"]] = f
|
|
376
|
+
|
|
377
|
+
candidates: list[IssueRecord] = []
|
|
378
|
+
for issue_id, record in issue_map.items():
|
|
379
|
+
latest_status = status_map.get(issue_id, "BACKLOG")
|
|
380
|
+
if latest_status != "BACKLOG":
|
|
381
|
+
continue
|
|
382
|
+
blocked_by = record.get("blocked_by", [])
|
|
383
|
+
is_unblocked = True
|
|
384
|
+
for dep_id in blocked_by:
|
|
385
|
+
dep_status = status_map.get(dep_id, "UNKNOWN")
|
|
386
|
+
if dep_status != "COMPLETED":
|
|
387
|
+
is_unblocked = False
|
|
388
|
+
break
|
|
389
|
+
if is_unblocked:
|
|
390
|
+
candidates.append(IssueRecord.model_validate(record))
|
|
391
|
+
|
|
392
|
+
candidates.sort(key=lambda r: r.created_at or r.timestamp)
|
|
393
|
+
return candidates
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def select_next_unblocked_issue(ledger_path: Path) -> IssueRecord | None:
|
|
397
|
+
candidates = _get_unblocked_backlog_features(ledger_path)
|
|
398
|
+
return candidates[0] if candidates else None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def select_unblocked_candidates(ledger_path: Path) -> list[IssueRecord]:
|
|
402
|
+
"""Return all unblocked BACKLOG issue records, sorted oldest-first.
|
|
403
|
+
|
|
404
|
+
Multi-candidate version of ``select_next_unblocked_issue`` used by the
|
|
405
|
+
try-claim loop in the specify pre command.
|
|
406
|
+
"""
|
|
407
|
+
return _get_unblocked_backlog_features(ledger_path)
|
deviate/ui/__init__.py
ADDED
deviate/ui/monitor.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _resolve_task_id(data: dict[str, Any]) -> str:
|
|
10
|
+
return data.get("id", data.get("task_id", ""))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MarkdownStatus(str, Enum):
|
|
14
|
+
PENDING = "[ ]"
|
|
15
|
+
IN_PROGRESS = "[/]"
|
|
16
|
+
COMPLETED = "[X]"
|
|
17
|
+
FAILED = "[✗]"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
VALID_EVENT_TYPES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"task_started",
|
|
23
|
+
"phase_change",
|
|
24
|
+
"task_completed",
|
|
25
|
+
"task_failed",
|
|
26
|
+
"pipeline_halted",
|
|
27
|
+
"pipeline_complete",
|
|
28
|
+
"agent_output",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TaskStatus:
|
|
35
|
+
id: str
|
|
36
|
+
description: str
|
|
37
|
+
marker: MarkdownStatus = MarkdownStatus.PENDING
|
|
38
|
+
phase: str = ""
|
|
39
|
+
error_reason: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OrchestrationMonitor:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
console: Any,
|
|
46
|
+
*,
|
|
47
|
+
json_mode: bool = False,
|
|
48
|
+
total_tasks: int = 0,
|
|
49
|
+
verbose: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
self._console = console
|
|
52
|
+
self._json_mode = json_mode
|
|
53
|
+
self._total_tasks = total_tasks
|
|
54
|
+
self._verbose = verbose
|
|
55
|
+
self._tasks: dict[str, TaskStatus] = {}
|
|
56
|
+
self._interrupted = False
|
|
57
|
+
self._exited = False
|
|
58
|
+
self.display_active = False
|
|
59
|
+
self._agent_output_buffer: deque[str] = deque(maxlen=5)
|
|
60
|
+
self._current_phase: str = "PENDING"
|
|
61
|
+
self._dispatch: dict[str, Any] = {
|
|
62
|
+
"task_started": self._on_task_started,
|
|
63
|
+
"phase_change": self._on_phase_change,
|
|
64
|
+
"task_completed": self._on_task_completed,
|
|
65
|
+
"task_failed": self._on_task_failed,
|
|
66
|
+
"pipeline_complete": self._on_pipeline_complete,
|
|
67
|
+
"agent_output": self._on_agent_output,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def __enter__(self) -> OrchestrationMonitor:
|
|
71
|
+
self._exited = False
|
|
72
|
+
self.display_active = True
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(self, *args: Any) -> None:
|
|
76
|
+
if self._exited:
|
|
77
|
+
return
|
|
78
|
+
self._exited = True
|
|
79
|
+
self.display_active = False
|
|
80
|
+
|
|
81
|
+
def _validate_event(self, event_type: str) -> None:
|
|
82
|
+
if event_type not in VALID_EVENT_TYPES:
|
|
83
|
+
raise ValueError(f"Unknown event type: {event_type}")
|
|
84
|
+
|
|
85
|
+
def push_event(self, event_type: str, **data: Any) -> None:
|
|
86
|
+
self._validate_event(event_type)
|
|
87
|
+
if event_type == "agent_output" and not self._verbose:
|
|
88
|
+
if self._agent_output_buffer is not None:
|
|
89
|
+
line = data.get("line", "")
|
|
90
|
+
if line:
|
|
91
|
+
self._agent_output_buffer.append(line)
|
|
92
|
+
return
|
|
93
|
+
handler = self._dispatch.get(event_type)
|
|
94
|
+
if handler is not None:
|
|
95
|
+
handler(data)
|
|
96
|
+
if self._json_mode:
|
|
97
|
+
from deviate.ui.render import emit_jsonl
|
|
98
|
+
|
|
99
|
+
emit_jsonl(event_type, **data)
|
|
100
|
+
|
|
101
|
+
def _on_task_started(self, data: dict[str, Any]) -> None:
|
|
102
|
+
task_id = _resolve_task_id(data)
|
|
103
|
+
self._agent_output_buffer.clear()
|
|
104
|
+
if (
|
|
105
|
+
task_id in self._tasks
|
|
106
|
+
and self._tasks[task_id].marker is not MarkdownStatus.PENDING
|
|
107
|
+
):
|
|
108
|
+
return
|
|
109
|
+
self._tasks[task_id] = TaskStatus(
|
|
110
|
+
id=task_id,
|
|
111
|
+
description=data.get("description", ""),
|
|
112
|
+
marker=MarkdownStatus.IN_PROGRESS,
|
|
113
|
+
phase=data.get("phase", ""),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _on_phase_change(self, data: dict[str, Any]) -> None:
|
|
117
|
+
task_id = _resolve_task_id(data)
|
|
118
|
+
phase = data.get("phase", "")
|
|
119
|
+
self._current_phase = phase
|
|
120
|
+
if task_id not in self._tasks:
|
|
121
|
+
self._tasks[task_id] = TaskStatus(
|
|
122
|
+
id=task_id,
|
|
123
|
+
description=data.get("description", ""),
|
|
124
|
+
marker=MarkdownStatus.IN_PROGRESS,
|
|
125
|
+
phase=phase,
|
|
126
|
+
)
|
|
127
|
+
return
|
|
128
|
+
self._tasks[task_id].phase = phase
|
|
129
|
+
if self._tasks[task_id].marker is MarkdownStatus.PENDING:
|
|
130
|
+
self._tasks[task_id].marker = MarkdownStatus.IN_PROGRESS
|
|
131
|
+
|
|
132
|
+
def _on_task_completed(self, data: dict[str, Any]) -> None:
|
|
133
|
+
task_id = _resolve_task_id(data)
|
|
134
|
+
if task_id not in self._tasks:
|
|
135
|
+
self._tasks[task_id] = TaskStatus(
|
|
136
|
+
id=task_id,
|
|
137
|
+
description=data.get("description", ""),
|
|
138
|
+
marker=MarkdownStatus.COMPLETED,
|
|
139
|
+
phase=data.get("phase", ""),
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
self._tasks[task_id].marker = MarkdownStatus.COMPLETED
|
|
143
|
+
self._tasks[task_id].phase = data.get("phase", self._tasks[task_id].phase)
|
|
144
|
+
|
|
145
|
+
def _on_task_failed(self, data: dict[str, Any]) -> None:
|
|
146
|
+
task_id = _resolve_task_id(data)
|
|
147
|
+
if task_id not in self._tasks:
|
|
148
|
+
self._tasks[task_id] = TaskStatus(
|
|
149
|
+
id=task_id,
|
|
150
|
+
description=data.get("description", ""),
|
|
151
|
+
marker=MarkdownStatus.FAILED,
|
|
152
|
+
error_reason=data.get("error_reason", ""),
|
|
153
|
+
phase=data.get("phase", ""),
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
self._tasks[task_id].marker = MarkdownStatus.FAILED
|
|
157
|
+
self._tasks[task_id].error_reason = data.get("error_reason", "")
|
|
158
|
+
self._tasks[task_id].phase = data.get("phase", self._tasks[task_id].phase)
|
|
159
|
+
|
|
160
|
+
def _on_agent_output(self, data: dict[str, Any]) -> None:
|
|
161
|
+
line = data.get("line", "")
|
|
162
|
+
if line:
|
|
163
|
+
self._agent_output_buffer.append(line)
|
|
164
|
+
|
|
165
|
+
def _on_pipeline_complete(self, data: dict[str, Any]) -> None:
|
|
166
|
+
self.display_active = False
|
|
167
|
+
|
|
168
|
+
def render(self) -> None:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def get_task_phase(self, task_id: str) -> str:
|
|
172
|
+
task = self._tasks.get(task_id)
|
|
173
|
+
return task.phase if task else ""
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def failed_count(self) -> int:
|
|
177
|
+
return sum(1 for t in self._tasks.values() if t.marker is MarkdownStatus.FAILED)
|
|
178
|
+
|
|
179
|
+
def signal_keyboard_interrupt(self) -> None:
|
|
180
|
+
self._interrupted = True
|
|
181
|
+
for task in self._tasks.values():
|
|
182
|
+
if task.marker is MarkdownStatus.IN_PROGRESS:
|
|
183
|
+
task.marker = MarkdownStatus.FAILED
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def interrupted(self) -> bool:
|
|
187
|
+
return self._interrupted
|
deviate/ui/render.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _now_iso() -> str:
|
|
11
|
+
return datetime.now(timezone.utc).isoformat()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def emit_jsonl(event: str, **fields: Any) -> None:
|
|
15
|
+
data = {"event": event, "timestamp": _now_iso(), **fields}
|
|
16
|
+
sys.stdout.write(json.dumps(data) + "\n")
|
|
17
|
+
sys.stdout.flush()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_interactive() -> bool:
|
|
21
|
+
if os.environ.get("CI"):
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
return os.isatty(sys.stdout.fileno())
|
|
25
|
+
except (OSError, AttributeError):
|
|
26
|
+
return False
|