taskledger 0.1.0__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.
- taskledger/__init__.py +5 -0
- taskledger/__main__.py +6 -0
- taskledger/_version.py +24 -0
- taskledger/api/__init__.py +13 -0
- taskledger/api/handoff.py +247 -0
- taskledger/api/introductions.py +9 -0
- taskledger/api/locks.py +4 -0
- taskledger/api/plans.py +31 -0
- taskledger/api/project.py +185 -0
- taskledger/api/questions.py +19 -0
- taskledger/api/search.py +87 -0
- taskledger/api/task_runs.py +38 -0
- taskledger/api/tasks.py +61 -0
- taskledger/cli.py +600 -0
- taskledger/cli_actor.py +196 -0
- taskledger/cli_common.py +617 -0
- taskledger/cli_implement.py +409 -0
- taskledger/cli_migrate.py +328 -0
- taskledger/cli_misc.py +984 -0
- taskledger/cli_plan.py +478 -0
- taskledger/cli_question.py +350 -0
- taskledger/cli_task.py +257 -0
- taskledger/cli_validate.py +285 -0
- taskledger/command_inventory.py +125 -0
- taskledger/domain/__init__.py +2 -0
- taskledger/domain/models.py +1697 -0
- taskledger/domain/policies.py +542 -0
- taskledger/domain/states.py +320 -0
- taskledger/errors.py +165 -0
- taskledger/exchange.py +343 -0
- taskledger/ids.py +19 -0
- taskledger/py.typed +0 -0
- taskledger/search.py +349 -0
- taskledger/services/__init__.py +1 -0
- taskledger/services/actors.py +245 -0
- taskledger/services/dashboard.py +306 -0
- taskledger/services/doctor.py +435 -0
- taskledger/services/handoff.py +1029 -0
- taskledger/services/handoff_lifecycle.py +154 -0
- taskledger/services/navigation.py +930 -0
- taskledger/services/phase5_lock_transfer.py +96 -0
- taskledger/services/plan_lint.py +397 -0
- taskledger/services/serve_read_model.py +852 -0
- taskledger/services/tasks.py +4224 -0
- taskledger/services/validation.py +221 -0
- taskledger/services/web_dashboard.py +1742 -0
- taskledger/storage/__init__.py +39 -0
- taskledger/storage/atomic.py +57 -0
- taskledger/storage/common.py +90 -0
- taskledger/storage/events.py +98 -0
- taskledger/storage/frontmatter.py +57 -0
- taskledger/storage/indexes.py +42 -0
- taskledger/storage/init.py +187 -0
- taskledger/storage/locks.py +83 -0
- taskledger/storage/meta.py +103 -0
- taskledger/storage/migrations.py +207 -0
- taskledger/storage/paths.py +166 -0
- taskledger/storage/project_config.py +393 -0
- taskledger/storage/repos.py +256 -0
- taskledger/storage/task_store.py +836 -0
- taskledger/timeutils.py +7 -0
- taskledger-0.1.0.dist-info/METADATA +411 -0
- taskledger-0.1.0.dist-info/RECORD +67 -0
- taskledger-0.1.0.dist-info/WHEEL +5 -0
- taskledger-0.1.0.dist-info/entry_points.txt +2 -0
- taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- taskledger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from taskledger.domain.models import (
|
|
8
|
+
PlanRecord,
|
|
9
|
+
QuestionRecord,
|
|
10
|
+
RequirementCollection,
|
|
11
|
+
TaskLock,
|
|
12
|
+
TaskRecord,
|
|
13
|
+
TaskRunRecord,
|
|
14
|
+
TaskTodo,
|
|
15
|
+
TodoCollection,
|
|
16
|
+
ValidationCheck,
|
|
17
|
+
)
|
|
18
|
+
from taskledger.domain.policies import derive_active_stage
|
|
19
|
+
from taskledger.services.navigation import (
|
|
20
|
+
_answered_question_next_item,
|
|
21
|
+
_commands_for_next_item,
|
|
22
|
+
_compact_next_action_blockers,
|
|
23
|
+
_plan_next_item,
|
|
24
|
+
_primary_command_for_next_item,
|
|
25
|
+
_question_next_item,
|
|
26
|
+
_required_open_question_ids,
|
|
27
|
+
_stale_answer_question_ids,
|
|
28
|
+
_task_next_item,
|
|
29
|
+
_todo_next_item,
|
|
30
|
+
_validation_progress,
|
|
31
|
+
)
|
|
32
|
+
from taskledger.storage.events import load_recent_events
|
|
33
|
+
from taskledger.storage.locks import lock_is_expired
|
|
34
|
+
from taskledger.storage.paths import resolve_project_paths
|
|
35
|
+
from taskledger.storage.task_store import (
|
|
36
|
+
list_changes,
|
|
37
|
+
list_plans,
|
|
38
|
+
list_questions,
|
|
39
|
+
list_runs,
|
|
40
|
+
list_tasks,
|
|
41
|
+
load_active_locks,
|
|
42
|
+
load_active_task_state,
|
|
43
|
+
load_requirements,
|
|
44
|
+
load_todos,
|
|
45
|
+
read_lock,
|
|
46
|
+
resolve_task,
|
|
47
|
+
resolve_task_or_active,
|
|
48
|
+
resolve_v2_paths,
|
|
49
|
+
task_lock_path,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_LOCK_STAGE_TO_ACTIVE_STAGE = {
|
|
53
|
+
"planning": "planning",
|
|
54
|
+
"implementing": "implementation",
|
|
55
|
+
"validating": "validation",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True, frozen=True)
|
|
60
|
+
class ServeReadOptions:
|
|
61
|
+
include_events: bool = False
|
|
62
|
+
event_limit: int = 50
|
|
63
|
+
include_all_plans: bool = True
|
|
64
|
+
include_changes: bool = True
|
|
65
|
+
include_validation: bool = True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_DEFAULT_SERVE_READ_OPTIONS = ServeReadOptions()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True, frozen=True)
|
|
72
|
+
class TaskDashboardSnapshot:
|
|
73
|
+
task: TaskRecord
|
|
74
|
+
lock: TaskLock | None
|
|
75
|
+
plans: list[PlanRecord]
|
|
76
|
+
questions: list[QuestionRecord]
|
|
77
|
+
runs: list[TaskRunRecord]
|
|
78
|
+
changes: list[dict[str, object]]
|
|
79
|
+
todos: TodoCollection
|
|
80
|
+
requirements: RequirementCollection
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def serve_project_summary(workspace_root: Path) -> dict[str, object]:
|
|
84
|
+
paths = resolve_project_paths(workspace_root)
|
|
85
|
+
active_task = None
|
|
86
|
+
active_state = load_active_task_state(workspace_root)
|
|
87
|
+
if active_state is not None:
|
|
88
|
+
task = resolve_task(workspace_root, active_state.task_id)
|
|
89
|
+
active_task = {
|
|
90
|
+
"task_id": task.id,
|
|
91
|
+
"slug": task.slug,
|
|
92
|
+
"title": task.title,
|
|
93
|
+
"status_stage": task.status_stage,
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
"kind": "serve_project",
|
|
97
|
+
"workspace_root": str(paths.workspace_root),
|
|
98
|
+
"config_path": str(paths.config_path),
|
|
99
|
+
"taskledger_dir": str(paths.taskledger_dir),
|
|
100
|
+
"project_dir": str(paths.project_dir),
|
|
101
|
+
"active_task": active_task,
|
|
102
|
+
"health": "not_checked",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def serve_task_summaries(workspace_root: Path) -> dict[str, object]:
|
|
107
|
+
tasks = list_tasks(workspace_root)
|
|
108
|
+
active_locks = {
|
|
109
|
+
lock.task_id: lock
|
|
110
|
+
for lock in load_active_locks(workspace_root)
|
|
111
|
+
if not lock_is_expired(lock)
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
"kind": "tasks",
|
|
115
|
+
"tasks": [
|
|
116
|
+
{
|
|
117
|
+
"id": task.id,
|
|
118
|
+
"slug": task.slug,
|
|
119
|
+
"title": task.title,
|
|
120
|
+
"status": task.status_stage,
|
|
121
|
+
"status_stage": task.status_stage,
|
|
122
|
+
"active_stage": (
|
|
123
|
+
_LOCK_STAGE_TO_ACTIVE_STAGE.get(active_locks[task.id].stage)
|
|
124
|
+
if task.id in active_locks
|
|
125
|
+
else None
|
|
126
|
+
),
|
|
127
|
+
"created_at": task.created_at,
|
|
128
|
+
"updated_at": task.updated_at,
|
|
129
|
+
"description_summary": task.description_summary,
|
|
130
|
+
"priority": task.priority,
|
|
131
|
+
"labels": list(task.labels),
|
|
132
|
+
"owner": task.owner,
|
|
133
|
+
"accepted_plan_version": task.accepted_plan_version,
|
|
134
|
+
"latest_plan_version": task.latest_plan_version,
|
|
135
|
+
}
|
|
136
|
+
for task in tasks
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def serve_dashboard_snapshot(
|
|
142
|
+
workspace_root: Path,
|
|
143
|
+
*,
|
|
144
|
+
ref: str | None,
|
|
145
|
+
options: ServeReadOptions | None = None,
|
|
146
|
+
) -> dict[str, object]:
|
|
147
|
+
options = options or _DEFAULT_SERVE_READ_OPTIONS
|
|
148
|
+
snapshot = _load_task_dashboard_snapshot(workspace_root, ref=ref)
|
|
149
|
+
active_stage = _snapshot_active_stage(snapshot)
|
|
150
|
+
todo_items = list(snapshot.todos.todos)
|
|
151
|
+
payload: dict[str, object] = {
|
|
152
|
+
"kind": "dashboard",
|
|
153
|
+
"task": {
|
|
154
|
+
"id": snapshot.task.id,
|
|
155
|
+
"slug": snapshot.task.slug,
|
|
156
|
+
"title": snapshot.task.title,
|
|
157
|
+
"status_stage": snapshot.task.status_stage,
|
|
158
|
+
"active_stage": active_stage,
|
|
159
|
+
"created_at": snapshot.task.created_at,
|
|
160
|
+
"updated_at": snapshot.task.updated_at,
|
|
161
|
+
"description_summary": snapshot.task.description_summary,
|
|
162
|
+
"priority": snapshot.task.priority,
|
|
163
|
+
"labels": list(snapshot.task.labels),
|
|
164
|
+
"owner": snapshot.task.owner,
|
|
165
|
+
},
|
|
166
|
+
"plan": _plan_summary(snapshot.plans),
|
|
167
|
+
"plans": (
|
|
168
|
+
[plan.to_dict() for plan in snapshot.plans]
|
|
169
|
+
if options.include_all_plans
|
|
170
|
+
else ([snapshot.plans[-1].to_dict()] if snapshot.plans else [])
|
|
171
|
+
),
|
|
172
|
+
"next_action": _build_next_action_from_snapshot(workspace_root, snapshot),
|
|
173
|
+
"questions": {
|
|
174
|
+
"total": len(snapshot.questions),
|
|
175
|
+
"open": sum(
|
|
176
|
+
1 for question in snapshot.questions if question.status == "open"
|
|
177
|
+
),
|
|
178
|
+
"items": [question.to_dict() for question in snapshot.questions],
|
|
179
|
+
},
|
|
180
|
+
"todos": {
|
|
181
|
+
"total": len(todo_items),
|
|
182
|
+
"done": sum(1 for todo in todo_items if todo.done),
|
|
183
|
+
"items": [todo.to_dict() for todo in todo_items],
|
|
184
|
+
},
|
|
185
|
+
"files": {
|
|
186
|
+
"total": len(snapshot.task.file_links),
|
|
187
|
+
"links": [file_link.to_dict() for file_link in snapshot.task.file_links],
|
|
188
|
+
},
|
|
189
|
+
"runs": [run.to_dict() for run in snapshot.runs],
|
|
190
|
+
"changes": snapshot.changes if options.include_changes else [],
|
|
191
|
+
"lock": snapshot.lock.to_dict() if snapshot.lock is not None else None,
|
|
192
|
+
}
|
|
193
|
+
if options.include_validation:
|
|
194
|
+
payload["validation"] = _build_validation_gate_report_from_snapshot(
|
|
195
|
+
workspace_root, snapshot
|
|
196
|
+
)
|
|
197
|
+
if options.include_events:
|
|
198
|
+
payload["events"] = serve_task_events(
|
|
199
|
+
workspace_root,
|
|
200
|
+
ref=snapshot.task.id,
|
|
201
|
+
limit=options.event_limit,
|
|
202
|
+
)
|
|
203
|
+
return payload
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def serve_task_events(
|
|
207
|
+
workspace_root: Path,
|
|
208
|
+
*,
|
|
209
|
+
ref: str | None,
|
|
210
|
+
limit: int,
|
|
211
|
+
) -> dict[str, object]:
|
|
212
|
+
task = resolve_task_or_active(workspace_root, ref)
|
|
213
|
+
events = load_recent_events(
|
|
214
|
+
resolve_v2_paths(workspace_root).events_dir,
|
|
215
|
+
task_id=task.id,
|
|
216
|
+
limit=limit,
|
|
217
|
+
)
|
|
218
|
+
return {
|
|
219
|
+
"kind": "events",
|
|
220
|
+
"task_id": task.id,
|
|
221
|
+
"items": [event.to_dict() for event in events],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _load_task_dashboard_snapshot(
|
|
226
|
+
workspace_root: Path,
|
|
227
|
+
*,
|
|
228
|
+
ref: str | None,
|
|
229
|
+
) -> TaskDashboardSnapshot:
|
|
230
|
+
task = resolve_task_or_active(workspace_root, ref)
|
|
231
|
+
paths = resolve_v2_paths(workspace_root)
|
|
232
|
+
return TaskDashboardSnapshot(
|
|
233
|
+
task=task,
|
|
234
|
+
lock=read_lock(task_lock_path(paths, task.id)),
|
|
235
|
+
plans=list_plans(workspace_root, task.id),
|
|
236
|
+
questions=list_questions(workspace_root, task.id),
|
|
237
|
+
runs=list_runs(workspace_root, task.id),
|
|
238
|
+
changes=[change.to_dict() for change in list_changes(workspace_root, task.id)],
|
|
239
|
+
todos=load_todos(workspace_root, task.id),
|
|
240
|
+
requirements=load_requirements(workspace_root, task.id),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _snapshot_active_stage(snapshot: TaskDashboardSnapshot) -> str | None:
|
|
245
|
+
if snapshot.lock is None or lock_is_expired(snapshot.lock):
|
|
246
|
+
return None
|
|
247
|
+
return derive_active_stage(snapshot.lock, snapshot.runs)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _latest_plan(snapshot: TaskDashboardSnapshot) -> PlanRecord | None:
|
|
251
|
+
return snapshot.plans[-1] if snapshot.plans else None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _accepted_plan(snapshot: TaskDashboardSnapshot) -> PlanRecord | None:
|
|
255
|
+
if snapshot.task.accepted_plan_version is None:
|
|
256
|
+
return None
|
|
257
|
+
for plan in snapshot.plans:
|
|
258
|
+
if plan.plan_version == snapshot.task.accepted_plan_version:
|
|
259
|
+
return plan
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _plan_summary(plans: list[PlanRecord]) -> dict[str, object] | None:
|
|
264
|
+
if not plans:
|
|
265
|
+
return None
|
|
266
|
+
latest = plans[-1]
|
|
267
|
+
return {
|
|
268
|
+
"version": latest.plan_version,
|
|
269
|
+
"status": latest.status,
|
|
270
|
+
"criteria": [criterion.to_dict() for criterion in latest.criteria],
|
|
271
|
+
"body": latest.body,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_next_action_from_snapshot(
|
|
276
|
+
workspace_root: Path,
|
|
277
|
+
snapshot: TaskDashboardSnapshot,
|
|
278
|
+
) -> dict[str, object]:
|
|
279
|
+
task = snapshot.task
|
|
280
|
+
active_stage = _snapshot_active_stage(snapshot)
|
|
281
|
+
action: str
|
|
282
|
+
reason: str
|
|
283
|
+
blockers: list[dict[str, object]] = []
|
|
284
|
+
next_item: dict[str, object] | None = None
|
|
285
|
+
progress: dict[str, object] = {}
|
|
286
|
+
latest_plan = _latest_plan(snapshot)
|
|
287
|
+
|
|
288
|
+
if active_stage == "planning":
|
|
289
|
+
open_questions = _required_open_question_ids(snapshot.questions)
|
|
290
|
+
answered_questions = [
|
|
291
|
+
item.id
|
|
292
|
+
for item in snapshot.questions
|
|
293
|
+
if item.status == "answered" and item.required_for_plan
|
|
294
|
+
]
|
|
295
|
+
stale_answers = (
|
|
296
|
+
_stale_answer_question_ids(snapshot.questions, latest_plan)
|
|
297
|
+
if latest_plan is not None
|
|
298
|
+
else answered_questions
|
|
299
|
+
)
|
|
300
|
+
if open_questions:
|
|
301
|
+
action, reason = "question-answer", "Required planning questions are open."
|
|
302
|
+
question = _first_question_by_ids(snapshot.questions, open_questions)
|
|
303
|
+
next_item = _question_next_item(question) if question is not None else None
|
|
304
|
+
progress["questions"] = {
|
|
305
|
+
"required_open": len(open_questions),
|
|
306
|
+
"required_open_ids": open_questions,
|
|
307
|
+
}
|
|
308
|
+
blockers.append(
|
|
309
|
+
{
|
|
310
|
+
"kind": "open_questions",
|
|
311
|
+
"question_ids": open_questions,
|
|
312
|
+
"message": "Required planning questions must be answered.",
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
elif stale_answers:
|
|
316
|
+
action, reason = (
|
|
317
|
+
"plan-regenerate",
|
|
318
|
+
"Answered planning questions should be reflected in the plan.",
|
|
319
|
+
)
|
|
320
|
+
question = _first_question_by_ids(snapshot.questions, stale_answers)
|
|
321
|
+
next_item = (
|
|
322
|
+
_answered_question_next_item(question) if question is not None else None
|
|
323
|
+
)
|
|
324
|
+
progress["questions"] = {
|
|
325
|
+
"required_open": 0,
|
|
326
|
+
"required_open_ids": [],
|
|
327
|
+
"answered_since_latest_plan": stale_answers,
|
|
328
|
+
}
|
|
329
|
+
else:
|
|
330
|
+
action, reason = (
|
|
331
|
+
"plan-propose",
|
|
332
|
+
"Planning is active; propose the next plan.",
|
|
333
|
+
)
|
|
334
|
+
elif active_stage == "implementation":
|
|
335
|
+
todo_report = _todo_gate_report(snapshot)
|
|
336
|
+
open_todo_ids = cast(list[str], todo_report.get("open_todos", []))
|
|
337
|
+
progress["todos"] = {
|
|
338
|
+
"total": todo_report["total"],
|
|
339
|
+
"done": todo_report["done"],
|
|
340
|
+
"open": len(open_todo_ids),
|
|
341
|
+
"open_ids": open_todo_ids,
|
|
342
|
+
}
|
|
343
|
+
if open_todo_ids:
|
|
344
|
+
todo = _first_open_todo(snapshot, open_todo_ids)
|
|
345
|
+
next_item = _todo_next_item(todo) if todo is not None else None
|
|
346
|
+
action, reason = (
|
|
347
|
+
"todo-work",
|
|
348
|
+
f"Implementation is in progress; {len(open_todo_ids)} todos remain.",
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
action, reason = (
|
|
352
|
+
"implement-finish",
|
|
353
|
+
"All todos done; ready to finish implementation.",
|
|
354
|
+
)
|
|
355
|
+
next_item = _task_next_item(task)
|
|
356
|
+
elif active_stage == "validation":
|
|
357
|
+
gate_report = _build_validation_gate_report_from_snapshot(
|
|
358
|
+
workspace_root, snapshot
|
|
359
|
+
)
|
|
360
|
+
report_blockers = cast(list[dict[str, object]], gate_report.get("blockers", []))
|
|
361
|
+
blockers.extend(_compact_next_action_blockers(report_blockers))
|
|
362
|
+
progress["validation"] = _validation_progress(gate_report)
|
|
363
|
+
if report_blockers:
|
|
364
|
+
action, reason = (
|
|
365
|
+
"validate-check",
|
|
366
|
+
"Validation is in progress; required checks remain.",
|
|
367
|
+
)
|
|
368
|
+
next_item = _next_validation_item(snapshot, gate_report, report_blockers)
|
|
369
|
+
else:
|
|
370
|
+
action, reason = (
|
|
371
|
+
"validate-finish",
|
|
372
|
+
"Validation is complete enough to finish.",
|
|
373
|
+
)
|
|
374
|
+
next_item = _task_next_item(task)
|
|
375
|
+
elif task.status_stage == "draft":
|
|
376
|
+
action, reason = "plan", "Draft tasks need planning before work starts."
|
|
377
|
+
elif task.status_stage == "plan_review":
|
|
378
|
+
open_questions = _required_open_question_ids(snapshot.questions)
|
|
379
|
+
stale_answers = (
|
|
380
|
+
_stale_answer_question_ids(snapshot.questions, latest_plan)
|
|
381
|
+
if latest_plan is not None
|
|
382
|
+
else []
|
|
383
|
+
)
|
|
384
|
+
if open_questions:
|
|
385
|
+
action, reason = "question-answer", "Required planning questions are open."
|
|
386
|
+
question = _first_question_by_ids(snapshot.questions, open_questions)
|
|
387
|
+
next_item = _question_next_item(question) if question is not None else None
|
|
388
|
+
progress["questions"] = {
|
|
389
|
+
"required_open": len(open_questions),
|
|
390
|
+
"required_open_ids": open_questions,
|
|
391
|
+
}
|
|
392
|
+
blockers.append(
|
|
393
|
+
{
|
|
394
|
+
"kind": "open_questions",
|
|
395
|
+
"question_ids": open_questions,
|
|
396
|
+
"message": "Required planning questions must be answered.",
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
elif stale_answers:
|
|
400
|
+
action, reason = (
|
|
401
|
+
"plan-regenerate",
|
|
402
|
+
"Answered planning questions are not reflected in the latest plan.",
|
|
403
|
+
)
|
|
404
|
+
question = _first_question_by_ids(snapshot.questions, stale_answers)
|
|
405
|
+
next_item = (
|
|
406
|
+
_answered_question_next_item(question) if question is not None else None
|
|
407
|
+
)
|
|
408
|
+
progress["questions"] = {
|
|
409
|
+
"required_open": 0,
|
|
410
|
+
"required_open_ids": [],
|
|
411
|
+
"answered_since_latest_plan": stale_answers,
|
|
412
|
+
}
|
|
413
|
+
blockers.append(
|
|
414
|
+
{
|
|
415
|
+
"kind": "stale_answers",
|
|
416
|
+
"question_ids": stale_answers,
|
|
417
|
+
"message": "Regenerate the plan from answered questions.",
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
action, reason = "plan-approve", "A proposed plan is waiting for review."
|
|
422
|
+
if latest_plan is not None:
|
|
423
|
+
next_item = _plan_next_item(latest_plan)
|
|
424
|
+
elif task.status_stage == "approved":
|
|
425
|
+
action, reason = "implement", "The approved plan is ready for implementation."
|
|
426
|
+
next_item = _task_next_item(task)
|
|
427
|
+
if task.accepted_plan_version is None:
|
|
428
|
+
blockers.append(
|
|
429
|
+
{"kind": "approval", "message": "No accepted plan version is recorded."}
|
|
430
|
+
)
|
|
431
|
+
blockers.extend(_dependency_blockers_from_snapshot(workspace_root, snapshot))
|
|
432
|
+
elif task.status_stage == "implemented":
|
|
433
|
+
action, reason = "validate", "Implementation is complete and ready to validate."
|
|
434
|
+
next_item = _task_next_item(task)
|
|
435
|
+
implementation_run = _find_run(snapshot.runs, task.latest_implementation_run)
|
|
436
|
+
if (
|
|
437
|
+
implementation_run is None
|
|
438
|
+
or implementation_run.run_type != "implementation"
|
|
439
|
+
or implementation_run.status != "finished"
|
|
440
|
+
):
|
|
441
|
+
blockers.append(
|
|
442
|
+
{
|
|
443
|
+
"kind": "implementation",
|
|
444
|
+
"message": "Validation requires a finished implementation run.",
|
|
445
|
+
}
|
|
446
|
+
)
|
|
447
|
+
elif task.status_stage == "failed_validation":
|
|
448
|
+
action, reason = (
|
|
449
|
+
"implement-restart",
|
|
450
|
+
"Validation failed; restart implementation.",
|
|
451
|
+
)
|
|
452
|
+
next_item = _task_next_item(task)
|
|
453
|
+
blockers.extend(_dependency_blockers_from_snapshot(workspace_root, snapshot))
|
|
454
|
+
elif task.status_stage == "done":
|
|
455
|
+
action, reason = "none", "The task is complete."
|
|
456
|
+
else:
|
|
457
|
+
action, reason = "none", "The task is cancelled."
|
|
458
|
+
|
|
459
|
+
if snapshot.lock is not None and active_stage is None:
|
|
460
|
+
blockers.append(
|
|
461
|
+
{
|
|
462
|
+
"kind": "lock",
|
|
463
|
+
"message": (
|
|
464
|
+
f"Task has a {snapshot.lock.stage} lock from "
|
|
465
|
+
f"{snapshot.lock.run_id} without a matching running run."
|
|
466
|
+
),
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
action = "repair-lock"
|
|
470
|
+
reason = "A stale or broken lock must be repaired before work can continue."
|
|
471
|
+
next_item = {
|
|
472
|
+
"kind": "lock",
|
|
473
|
+
"id": snapshot.lock.lock_id,
|
|
474
|
+
"task_id": task.id,
|
|
475
|
+
"stage": snapshot.lock.stage,
|
|
476
|
+
"run_id": snapshot.lock.run_id,
|
|
477
|
+
"expired": lock_is_expired(snapshot.lock),
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
next_command = _primary_command_for_next_item(action, next_item)
|
|
481
|
+
return {
|
|
482
|
+
"kind": "task_next_action",
|
|
483
|
+
"task_id": task.id,
|
|
484
|
+
"status_stage": task.status_stage,
|
|
485
|
+
"active_stage": active_stage,
|
|
486
|
+
"action": action,
|
|
487
|
+
"reason": reason,
|
|
488
|
+
"blocking": blockers,
|
|
489
|
+
"next_command": next_command,
|
|
490
|
+
"next_item": next_item,
|
|
491
|
+
"commands": _commands_for_next_item(action, next_item),
|
|
492
|
+
"progress": progress,
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _todo_gate_report(snapshot: TaskDashboardSnapshot) -> dict[str, object]:
|
|
497
|
+
open_todos = [
|
|
498
|
+
todo.id
|
|
499
|
+
for todo in snapshot.todos.todos
|
|
500
|
+
if not todo.done
|
|
501
|
+
and todo.status not in {"done", "skipped"}
|
|
502
|
+
and (
|
|
503
|
+
not todo.mandatory
|
|
504
|
+
or todo.active_at is not None
|
|
505
|
+
or todo.source == "plan"
|
|
506
|
+
or todo.source_plan_id is not None
|
|
507
|
+
)
|
|
508
|
+
]
|
|
509
|
+
return {
|
|
510
|
+
"kind": "todo_gate_report",
|
|
511
|
+
"task_id": snapshot.task.id,
|
|
512
|
+
"total": len(snapshot.todos.todos),
|
|
513
|
+
"done": len(snapshot.todos.todos) - len(open_todos),
|
|
514
|
+
"open_todos": open_todos,
|
|
515
|
+
"blockers": [
|
|
516
|
+
{
|
|
517
|
+
"kind": "todo_open",
|
|
518
|
+
"ref": todo_id,
|
|
519
|
+
"message": f"Todo {todo_id} is not done.",
|
|
520
|
+
"command_hint": f'taskledger todo done {todo_id} --evidence "..."',
|
|
521
|
+
}
|
|
522
|
+
for todo_id in open_todos
|
|
523
|
+
],
|
|
524
|
+
"can_finish_implementation": not open_todos,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _first_open_todo(
|
|
529
|
+
snapshot: TaskDashboardSnapshot,
|
|
530
|
+
open_ids: list[str],
|
|
531
|
+
) -> TaskTodo | None:
|
|
532
|
+
wanted = set(open_ids)
|
|
533
|
+
for todo in snapshot.todos.todos:
|
|
534
|
+
if todo.id in wanted and todo.status == "active" and not todo.done:
|
|
535
|
+
return todo
|
|
536
|
+
for todo in snapshot.todos.todos:
|
|
537
|
+
if todo.id in wanted and not todo.done:
|
|
538
|
+
return todo
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _build_validation_gate_report_from_snapshot(
|
|
543
|
+
workspace_root: Path,
|
|
544
|
+
snapshot: TaskDashboardSnapshot,
|
|
545
|
+
) -> dict[str, object]:
|
|
546
|
+
task = snapshot.task
|
|
547
|
+
run = _find_run(snapshot.runs, task.latest_validation_run)
|
|
548
|
+
implementation_run = _find_run(snapshot.runs, task.latest_implementation_run)
|
|
549
|
+
accepted_plan = _accepted_plan(snapshot)
|
|
550
|
+
report: dict[str, Any] = {
|
|
551
|
+
"kind": "validation_status",
|
|
552
|
+
"task_id": task.id,
|
|
553
|
+
"task_slug": task.slug,
|
|
554
|
+
"status_stage": task.status_stage,
|
|
555
|
+
"active_stage": None,
|
|
556
|
+
"run_id": run.run_id if run is not None else None,
|
|
557
|
+
"can_finish_passed": False,
|
|
558
|
+
"accepted_plan": {},
|
|
559
|
+
"implementation": {},
|
|
560
|
+
"criteria": [],
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if accepted_plan is not None:
|
|
564
|
+
report["accepted_plan"] = {
|
|
565
|
+
"version": accepted_plan.plan_version,
|
|
566
|
+
"status": accepted_plan.status,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if implementation_run is not None:
|
|
570
|
+
report["implementation"] = {
|
|
571
|
+
"run_id": implementation_run.run_id,
|
|
572
|
+
"status": implementation_run.status,
|
|
573
|
+
"satisfied": implementation_run.status == "finished",
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
missing_criteria: list[str] = []
|
|
577
|
+
failing_criteria: list[str] = []
|
|
578
|
+
checks_by_criterion: dict[str, list[ValidationCheck]] = {}
|
|
579
|
+
if run is not None:
|
|
580
|
+
for check in run.checks:
|
|
581
|
+
if check.criterion_id is not None:
|
|
582
|
+
checks_by_criterion.setdefault(check.criterion_id, []).append(check)
|
|
583
|
+
|
|
584
|
+
if accepted_plan is not None:
|
|
585
|
+
for criterion in accepted_plan.criteria:
|
|
586
|
+
checks = checks_by_criterion.get(criterion.id, [])
|
|
587
|
+
latest_check = checks[-1] if checks else None
|
|
588
|
+
latest_status = (
|
|
589
|
+
latest_check.status if latest_check is not None else "not_run"
|
|
590
|
+
)
|
|
591
|
+
has_waiver = (
|
|
592
|
+
latest_check is not None
|
|
593
|
+
and latest_check.waiver is not None
|
|
594
|
+
and latest_check.waiver.actor.actor_type == "user"
|
|
595
|
+
)
|
|
596
|
+
satisfied = latest_status == "pass" or has_waiver
|
|
597
|
+
criterion_blockers: list[dict[str, str]] = []
|
|
598
|
+
if criterion.mandatory:
|
|
599
|
+
if latest_status == "fail":
|
|
600
|
+
criterion_blockers.append(
|
|
601
|
+
{"kind": "criterion_fail", "message": "Latest check failed"}
|
|
602
|
+
)
|
|
603
|
+
failing_criteria.append(criterion.id)
|
|
604
|
+
elif latest_status == "not_run":
|
|
605
|
+
criterion_blockers.append(
|
|
606
|
+
{
|
|
607
|
+
"kind": "criterion_missing",
|
|
608
|
+
"message": "No passing check recorded",
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
missing_criteria.append(criterion.id)
|
|
612
|
+
elif not satisfied:
|
|
613
|
+
criterion_blockers.append(
|
|
614
|
+
{
|
|
615
|
+
"kind": "criterion_unsatisfied",
|
|
616
|
+
"message": f"Latest check status: {latest_status}",
|
|
617
|
+
}
|
|
618
|
+
)
|
|
619
|
+
missing_criteria.append(criterion.id)
|
|
620
|
+
cast(list[dict[str, object]], report["criteria"]).append(
|
|
621
|
+
{
|
|
622
|
+
"id": criterion.id,
|
|
623
|
+
"text": criterion.text,
|
|
624
|
+
"mandatory": criterion.mandatory,
|
|
625
|
+
"latest_check_id": (
|
|
626
|
+
latest_check.id if latest_check is not None else None
|
|
627
|
+
),
|
|
628
|
+
"latest_status": latest_status,
|
|
629
|
+
"satisfied": satisfied,
|
|
630
|
+
"has_waiver": has_waiver,
|
|
631
|
+
"evidence": list(latest_check.evidence) if latest_check else [],
|
|
632
|
+
"history": [
|
|
633
|
+
{"check_id": check.id, "status": check.status}
|
|
634
|
+
for check in checks
|
|
635
|
+
],
|
|
636
|
+
"blockers": criterion_blockers,
|
|
637
|
+
}
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
open_todos = [
|
|
641
|
+
todo.id for todo in snapshot.todos.todos if todo.mandatory and not todo.done
|
|
642
|
+
]
|
|
643
|
+
dependency_blockers = _dependency_blockers_from_snapshot(workspace_root, snapshot)
|
|
644
|
+
|
|
645
|
+
blockers: list[dict[str, object]] = []
|
|
646
|
+
if accepted_plan is None:
|
|
647
|
+
blockers.append(
|
|
648
|
+
{
|
|
649
|
+
"kind": "no_accepted_plan",
|
|
650
|
+
"message": "No accepted plan is recorded.",
|
|
651
|
+
"command_hint": (
|
|
652
|
+
"taskledger plan propose ... && taskledger plan approve ..."
|
|
653
|
+
),
|
|
654
|
+
}
|
|
655
|
+
)
|
|
656
|
+
elif accepted_plan.status != "accepted":
|
|
657
|
+
blockers.append(
|
|
658
|
+
{
|
|
659
|
+
"kind": "plan_not_accepted",
|
|
660
|
+
"message": (
|
|
661
|
+
"Accepted plan record status is "
|
|
662
|
+
f"{accepted_plan.status}, not accepted."
|
|
663
|
+
),
|
|
664
|
+
}
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
if implementation_run is None or implementation_run.status != "finished":
|
|
668
|
+
blockers.append(
|
|
669
|
+
{
|
|
670
|
+
"kind": "no_finished_implementation",
|
|
671
|
+
"message": "No finished implementation run is recorded.",
|
|
672
|
+
"command_hint": (
|
|
673
|
+
"taskledger implement start ... && taskledger implement finish ..."
|
|
674
|
+
),
|
|
675
|
+
}
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
for criterion_id in missing_criteria:
|
|
679
|
+
blockers.append(
|
|
680
|
+
{
|
|
681
|
+
"kind": "criterion_missing",
|
|
682
|
+
"ref": criterion_id,
|
|
683
|
+
"message": f"Mandatory criterion {criterion_id} has no passing check.",
|
|
684
|
+
"command_hint": (
|
|
685
|
+
"taskledger validate check "
|
|
686
|
+
f"--criterion {criterion_id} --status pass "
|
|
687
|
+
'--evidence "..."'
|
|
688
|
+
),
|
|
689
|
+
}
|
|
690
|
+
)
|
|
691
|
+
for criterion_id in failing_criteria:
|
|
692
|
+
blockers.append(
|
|
693
|
+
{
|
|
694
|
+
"kind": "criterion_fail",
|
|
695
|
+
"ref": criterion_id,
|
|
696
|
+
"message": f"Mandatory criterion {criterion_id} has a failing check.",
|
|
697
|
+
"command_hint": (
|
|
698
|
+
"taskledger validate check "
|
|
699
|
+
f"--criterion {criterion_id} --status pass "
|
|
700
|
+
'--evidence "..."'
|
|
701
|
+
),
|
|
702
|
+
}
|
|
703
|
+
)
|
|
704
|
+
for todo_id in open_todos:
|
|
705
|
+
blockers.append(
|
|
706
|
+
{
|
|
707
|
+
"kind": "todo_open",
|
|
708
|
+
"ref": todo_id,
|
|
709
|
+
"message": f"Mandatory todo {todo_id} is not done.",
|
|
710
|
+
"command_hint": f'taskledger todo done {todo_id} --evidence "..."',
|
|
711
|
+
}
|
|
712
|
+
)
|
|
713
|
+
for blocker in dependency_blockers:
|
|
714
|
+
blockers.append(
|
|
715
|
+
{
|
|
716
|
+
"kind": "dependency_blocker",
|
|
717
|
+
"ref": blocker["ref"],
|
|
718
|
+
"message": blocker["message"],
|
|
719
|
+
}
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
report["todos"] = {"open_mandatory": open_todos}
|
|
723
|
+
report["dependencies"] = {"blockers": dependency_blockers}
|
|
724
|
+
report["blockers"] = blockers
|
|
725
|
+
report["can_finish_passed"] = not blockers
|
|
726
|
+
return report
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _dependency_blockers_from_snapshot(
|
|
730
|
+
workspace_root: Path,
|
|
731
|
+
snapshot: TaskDashboardSnapshot,
|
|
732
|
+
) -> list[dict[str, object]]:
|
|
733
|
+
blockers: list[dict[str, object]] = []
|
|
734
|
+
for requirement in snapshot.requirements.requirements:
|
|
735
|
+
if (
|
|
736
|
+
requirement.waiver is not None
|
|
737
|
+
and requirement.waiver.actor.actor_type == "user"
|
|
738
|
+
):
|
|
739
|
+
continue
|
|
740
|
+
required = resolve_task(workspace_root, requirement.task_id)
|
|
741
|
+
if required.status_stage != "done":
|
|
742
|
+
blockers.append(
|
|
743
|
+
{
|
|
744
|
+
"kind": "dependency",
|
|
745
|
+
"ref": required.id,
|
|
746
|
+
"message": (
|
|
747
|
+
f"Requirement {required.id} is still {required.status_stage}."
|
|
748
|
+
),
|
|
749
|
+
}
|
|
750
|
+
)
|
|
751
|
+
return blockers
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _find_run(
|
|
755
|
+
runs: list[TaskRunRecord],
|
|
756
|
+
run_id: str | None,
|
|
757
|
+
) -> TaskRunRecord | None:
|
|
758
|
+
if run_id is None:
|
|
759
|
+
return None
|
|
760
|
+
for run in runs:
|
|
761
|
+
if run.run_id == run_id:
|
|
762
|
+
return run
|
|
763
|
+
return None
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _first_question_by_ids(
|
|
767
|
+
questions: list[QuestionRecord],
|
|
768
|
+
ids: list[str],
|
|
769
|
+
) -> QuestionRecord | None:
|
|
770
|
+
wanted = set(ids)
|
|
771
|
+
for question in questions:
|
|
772
|
+
if question.id in wanted:
|
|
773
|
+
return question
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _criterion_report_by_id(
|
|
778
|
+
gate_report: dict[str, object],
|
|
779
|
+
criterion_id: str,
|
|
780
|
+
) -> dict[str, object] | None:
|
|
781
|
+
for criterion in cast(list[dict[str, object]], gate_report.get("criteria", [])):
|
|
782
|
+
if criterion.get("id") == criterion_id:
|
|
783
|
+
return criterion
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _next_validation_item(
|
|
788
|
+
snapshot: TaskDashboardSnapshot,
|
|
789
|
+
gate_report: dict[str, object],
|
|
790
|
+
blockers: list[dict[str, object]],
|
|
791
|
+
) -> dict[str, object] | None:
|
|
792
|
+
priority = (
|
|
793
|
+
"criterion_fail",
|
|
794
|
+
"criterion_missing",
|
|
795
|
+
"criterion_unsatisfied",
|
|
796
|
+
"todo_open",
|
|
797
|
+
"no_finished_implementation",
|
|
798
|
+
"dependency_blocker",
|
|
799
|
+
"no_accepted_plan",
|
|
800
|
+
"plan_not_accepted",
|
|
801
|
+
)
|
|
802
|
+
for kind in priority:
|
|
803
|
+
for blocker in blockers:
|
|
804
|
+
if blocker.get("kind") != kind:
|
|
805
|
+
continue
|
|
806
|
+
ref = blocker.get("ref")
|
|
807
|
+
if kind.startswith("criterion_") and isinstance(ref, str):
|
|
808
|
+
criterion = _criterion_report_by_id(gate_report, ref)
|
|
809
|
+
if criterion is not None:
|
|
810
|
+
return {
|
|
811
|
+
"kind": "criterion",
|
|
812
|
+
"id": criterion.get("id"),
|
|
813
|
+
"text": criterion.get("text"),
|
|
814
|
+
"mandatory": criterion.get("mandatory"),
|
|
815
|
+
"latest_status": criterion.get("latest_status"),
|
|
816
|
+
"satisfied": criterion.get("satisfied"),
|
|
817
|
+
}
|
|
818
|
+
if kind == "todo_open" and isinstance(ref, str):
|
|
819
|
+
todo = _first_open_todo(snapshot, [ref])
|
|
820
|
+
if todo is not None:
|
|
821
|
+
return _todo_next_item(todo)
|
|
822
|
+
if kind == "dependency_blocker" and isinstance(ref, str):
|
|
823
|
+
return {"kind": "dependency", "id": ref}
|
|
824
|
+
if kind == "no_finished_implementation":
|
|
825
|
+
return _task_next_item(snapshot.task)
|
|
826
|
+
if kind in {"no_accepted_plan", "plan_not_accepted"}:
|
|
827
|
+
latest_plan = _latest_plan(snapshot)
|
|
828
|
+
if latest_plan is not None:
|
|
829
|
+
return _plan_next_item(latest_plan)
|
|
830
|
+
return _task_next_item(snapshot.task)
|
|
831
|
+
|
|
832
|
+
for criterion in cast(list[dict[str, object]], gate_report.get("criteria", [])):
|
|
833
|
+
if criterion.get("blockers"):
|
|
834
|
+
return {
|
|
835
|
+
"kind": "criterion",
|
|
836
|
+
"id": criterion.get("id"),
|
|
837
|
+
"text": criterion.get("text"),
|
|
838
|
+
"mandatory": criterion.get("mandatory"),
|
|
839
|
+
"latest_status": criterion.get("latest_status"),
|
|
840
|
+
"satisfied": criterion.get("satisfied"),
|
|
841
|
+
}
|
|
842
|
+
return None
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
__all__ = [
|
|
846
|
+
"ServeReadOptions",
|
|
847
|
+
"TaskDashboardSnapshot",
|
|
848
|
+
"serve_dashboard_snapshot",
|
|
849
|
+
"serve_project_summary",
|
|
850
|
+
"serve_task_events",
|
|
851
|
+
"serve_task_summaries",
|
|
852
|
+
]
|