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.
Files changed (67) hide show
  1. taskledger/__init__.py +5 -0
  2. taskledger/__main__.py +6 -0
  3. taskledger/_version.py +24 -0
  4. taskledger/api/__init__.py +13 -0
  5. taskledger/api/handoff.py +247 -0
  6. taskledger/api/introductions.py +9 -0
  7. taskledger/api/locks.py +4 -0
  8. taskledger/api/plans.py +31 -0
  9. taskledger/api/project.py +185 -0
  10. taskledger/api/questions.py +19 -0
  11. taskledger/api/search.py +87 -0
  12. taskledger/api/task_runs.py +38 -0
  13. taskledger/api/tasks.py +61 -0
  14. taskledger/cli.py +600 -0
  15. taskledger/cli_actor.py +196 -0
  16. taskledger/cli_common.py +617 -0
  17. taskledger/cli_implement.py +409 -0
  18. taskledger/cli_migrate.py +328 -0
  19. taskledger/cli_misc.py +984 -0
  20. taskledger/cli_plan.py +478 -0
  21. taskledger/cli_question.py +350 -0
  22. taskledger/cli_task.py +257 -0
  23. taskledger/cli_validate.py +285 -0
  24. taskledger/command_inventory.py +125 -0
  25. taskledger/domain/__init__.py +2 -0
  26. taskledger/domain/models.py +1697 -0
  27. taskledger/domain/policies.py +542 -0
  28. taskledger/domain/states.py +320 -0
  29. taskledger/errors.py +165 -0
  30. taskledger/exchange.py +343 -0
  31. taskledger/ids.py +19 -0
  32. taskledger/py.typed +0 -0
  33. taskledger/search.py +349 -0
  34. taskledger/services/__init__.py +1 -0
  35. taskledger/services/actors.py +245 -0
  36. taskledger/services/dashboard.py +306 -0
  37. taskledger/services/doctor.py +435 -0
  38. taskledger/services/handoff.py +1029 -0
  39. taskledger/services/handoff_lifecycle.py +154 -0
  40. taskledger/services/navigation.py +930 -0
  41. taskledger/services/phase5_lock_transfer.py +96 -0
  42. taskledger/services/plan_lint.py +397 -0
  43. taskledger/services/serve_read_model.py +852 -0
  44. taskledger/services/tasks.py +4224 -0
  45. taskledger/services/validation.py +221 -0
  46. taskledger/services/web_dashboard.py +1742 -0
  47. taskledger/storage/__init__.py +39 -0
  48. taskledger/storage/atomic.py +57 -0
  49. taskledger/storage/common.py +90 -0
  50. taskledger/storage/events.py +98 -0
  51. taskledger/storage/frontmatter.py +57 -0
  52. taskledger/storage/indexes.py +42 -0
  53. taskledger/storage/init.py +187 -0
  54. taskledger/storage/locks.py +83 -0
  55. taskledger/storage/meta.py +103 -0
  56. taskledger/storage/migrations.py +207 -0
  57. taskledger/storage/paths.py +166 -0
  58. taskledger/storage/project_config.py +393 -0
  59. taskledger/storage/repos.py +256 -0
  60. taskledger/storage/task_store.py +836 -0
  61. taskledger/timeutils.py +7 -0
  62. taskledger-0.1.0.dist-info/METADATA +411 -0
  63. taskledger-0.1.0.dist-info/RECORD +67 -0
  64. taskledger-0.1.0.dist-info/WHEEL +5 -0
  65. taskledger-0.1.0.dist-info/entry_points.txt +2 -0
  66. taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  67. 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
+ ]