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.
Files changed (124) hide show
  1. deviatdd-2.5.1.dist-info/METADATA +386 -0
  2. deviatdd-2.5.1.dist-info/RECORD +124 -0
  3. deviatdd-2.5.1.dist-info/WHEEL +4 -0
  4. deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
  5. deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
  6. deviate/__init__.py +3 -0
  7. deviate/cli/__init__.py +824 -0
  8. deviate/cli/_common.py +155 -0
  9. deviate/cli/adhoc.py +183 -0
  10. deviate/cli/constitution.py +113 -0
  11. deviate/cli/feature.py +94 -0
  12. deviate/cli/init.py +485 -0
  13. deviate/cli/inspect.py +208 -0
  14. deviate/cli/macro.py +937 -0
  15. deviate/cli/meso.py +1894 -0
  16. deviate/cli/micro.py +3249 -0
  17. deviate/cli/review.py +441 -0
  18. deviate/core/__init__.py +0 -0
  19. deviate/core/_shared.py +20 -0
  20. deviate/core/agent.py +505 -0
  21. deviate/core/cache_discipline.py +65 -0
  22. deviate/core/commands.py +202 -0
  23. deviate/core/commit.py +86 -0
  24. deviate/core/complexity.py +36 -0
  25. deviate/core/constitution.py +85 -0
  26. deviate/core/contract.py +17 -0
  27. deviate/core/convention.py +147 -0
  28. deviate/core/epic.py +73 -0
  29. deviate/core/issues.py +46 -0
  30. deviate/core/prd.py +18 -0
  31. deviate/core/profile.py +33 -0
  32. deviate/core/repo.py +47 -0
  33. deviate/core/run_logger.py +50 -0
  34. deviate/core/tasks_ledger.py +69 -0
  35. deviate/core/treesitter/__init__.py +31 -0
  36. deviate/core/treesitter/analysis.py +457 -0
  37. deviate/core/treesitter/models.py +35 -0
  38. deviate/core/treesitter/parser.py +184 -0
  39. deviate/core/treesitter/queries/bash.scm +7 -0
  40. deviate/core/treesitter/queries/c_sharp.scm +11 -0
  41. deviate/core/treesitter/queries/cpp.scm +9 -0
  42. deviate/core/treesitter/queries/css.scm +4 -0
  43. deviate/core/treesitter/queries/dockerfile.scm +8 -0
  44. deviate/core/treesitter/queries/elixir.scm +6 -0
  45. deviate/core/treesitter/queries/go.scm +9 -0
  46. deviate/core/treesitter/queries/hcl.scm +3 -0
  47. deviate/core/treesitter/queries/html.scm +3 -0
  48. deviate/core/treesitter/queries/javascript.scm +12 -0
  49. deviate/core/treesitter/queries/json.scm +4 -0
  50. deviate/core/treesitter/queries/kotlin.scm +8 -0
  51. deviate/core/treesitter/queries/markdown.scm +4 -0
  52. deviate/core/treesitter/queries/python.scm +10 -0
  53. deviate/core/treesitter/queries/rust.scm +12 -0
  54. deviate/core/treesitter/queries/sql.scm +7 -0
  55. deviate/core/treesitter/queries/swift.scm +10 -0
  56. deviate/core/treesitter/queries/toml.scm +3 -0
  57. deviate/core/treesitter/queries/tsx.scm +14 -0
  58. deviate/core/treesitter/queries/typescript.scm +14 -0
  59. deviate/core/treesitter/queries/yaml.scm +4 -0
  60. deviate/core/validation.py +153 -0
  61. deviate/core/worktree.py +141 -0
  62. deviate/main.py +4 -0
  63. deviate/prompts/__init__.py +0 -0
  64. deviate/prompts/assembly.py +122 -0
  65. deviate/prompts/auto/__init__.py +1 -0
  66. deviate/prompts/auto/execute.md +62 -0
  67. deviate/prompts/auto/explore.md +103 -0
  68. deviate/prompts/auto/green.md +137 -0
  69. deviate/prompts/auto/judge.md +260 -0
  70. deviate/prompts/auto/plan.md +127 -0
  71. deviate/prompts/auto/prd.md +108 -0
  72. deviate/prompts/auto/red.md +156 -0
  73. deviate/prompts/auto/refactor.md +166 -0
  74. deviate/prompts/auto/research.md +111 -0
  75. deviate/prompts/auto/shard.md +90 -0
  76. deviate/prompts/auto/specify.md +77 -0
  77. deviate/prompts/auto/tasks.md +191 -0
  78. deviate/prompts/commands/deviate-adhoc.md +216 -0
  79. deviate/prompts/commands/deviate-architecture.md +147 -0
  80. deviate/prompts/commands/deviate-constitution.md +215 -0
  81. deviate/prompts/commands/deviate-e2e.md +264 -0
  82. deviate/prompts/commands/deviate-execute.md +223 -0
  83. deviate/prompts/commands/deviate-explore.md +253 -0
  84. deviate/prompts/commands/deviate-flows.md +226 -0
  85. deviate/prompts/commands/deviate-green.md +217 -0
  86. deviate/prompts/commands/deviate-hotfix.md +188 -0
  87. deviate/prompts/commands/deviate-init.md +170 -0
  88. deviate/prompts/commands/deviate-judge.md +193 -0
  89. deviate/prompts/commands/deviate-merge.md +221 -0
  90. deviate/prompts/commands/deviate-plan.md +158 -0
  91. deviate/prompts/commands/deviate-pr.md +144 -0
  92. deviate/prompts/commands/deviate-prd.md +216 -0
  93. deviate/prompts/commands/deviate-prune.md +260 -0
  94. deviate/prompts/commands/deviate-red.md +231 -0
  95. deviate/prompts/commands/deviate-refactor.md +211 -0
  96. deviate/prompts/commands/deviate-release.md +123 -0
  97. deviate/prompts/commands/deviate-research.md +359 -0
  98. deviate/prompts/commands/deviate-review.md +289 -0
  99. deviate/prompts/commands/deviate-shard.md +226 -0
  100. deviate/prompts/commands/deviate-tasks.md +281 -0
  101. deviate/prompts/commands/deviate-triage.md +141 -0
  102. deviate/prompts/constitution_seed.md +51 -0
  103. deviate/prompts/core/core.md +21 -0
  104. deviate/prompts/core/macro-auto.md +59 -0
  105. deviate/prompts/core/macro-command.md +54 -0
  106. deviate/prompts/core/macro-skill.md +54 -0
  107. deviate/prompts/core/meso-auto.md +63 -0
  108. deviate/prompts/core/meso-command.md +58 -0
  109. deviate/prompts/core/meso-skill.md +58 -0
  110. deviate/prompts/core/micro-auto.md +76 -0
  111. deviate/prompts/core/micro-command.md +67 -0
  112. deviate/prompts/core/micro-skill.md +67 -0
  113. deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
  114. deviate/prompts/governance/__init__.py +0 -0
  115. deviate/prompts/governance/agents_seed.md +1 -0
  116. deviate/prompts/governance/claudemd_seed.md +1 -0
  117. deviate/prompts/governance/graphite_seed.md +18 -0
  118. deviate/prompts/governance/libref_seed.md +3 -0
  119. deviate/state/__init__.py +0 -0
  120. deviate/state/config.py +257 -0
  121. deviate/state/ledger.py +407 -0
  122. deviate/ui/__init__.py +5 -0
  123. deviate/ui/monitor.py +187 -0
  124. deviate/ui/render.py +26 -0
@@ -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
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from deviate.ui.monitor import MarkdownStatus, OrchestrationMonitor, TaskStatus
4
+
5
+ __all__ = ["MarkdownStatus", "OrchestrationMonitor", "TaskStatus"]
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