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,930 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from collections.abc import Mapping, Sequence
5
+ from pathlib import Path
6
+ from typing import cast
7
+
8
+ from taskledger.domain.models import (
9
+ PlanRecord,
10
+ QuestionRecord,
11
+ TaskLock,
12
+ TaskRecord,
13
+ TaskTodo,
14
+ )
15
+ from taskledger.domain.states import EXIT_CODE_BAD_INPUT, IMPLEMENTABLE_TASK_STAGES
16
+ from taskledger.services.tasks import (
17
+ _build_todo_gate_report,
18
+ _cli_error,
19
+ _current_lock,
20
+ _dependency_blockers,
21
+ _optional_run,
22
+ _task_active_stage,
23
+ _task_with_sidecars,
24
+ _todo_command_hints,
25
+ _todo_done_command,
26
+ )
27
+ from taskledger.services.validation import build_validation_gate_report
28
+ from taskledger.storage.locks import lock_is_expired
29
+ from taskledger.storage.task_store import (
30
+ list_plans,
31
+ list_questions,
32
+ list_runs,
33
+ resolve_task,
34
+ )
35
+
36
+
37
+ def next_action(workspace_root: Path, task_ref: str) -> dict[str, object]:
38
+ task = resolve_task(workspace_root, task_ref)
39
+ lock = _current_lock(workspace_root, task.id)
40
+ runs = list_runs(workspace_root, task.id)
41
+ active_stage = _task_active_stage(
42
+ workspace_root,
43
+ task,
44
+ lock=lock,
45
+ runs=runs,
46
+ )
47
+ action: str
48
+ reason: str
49
+ blockers: list[dict[str, object]] = []
50
+ next_item: dict[str, object] | None = None
51
+ progress: dict[str, object] = {}
52
+ if active_stage == "planning":
53
+ questions = list_questions(workspace_root, task.id)
54
+ open_questions = _required_open_question_ids(questions)
55
+ answered_questions = [
56
+ item.id
57
+ for item in questions
58
+ if item.status == "answered" and item.required_for_plan
59
+ ]
60
+ latest_plan = _latest_plan_or_none(workspace_root, task.id)
61
+ stale_answers = (
62
+ _stale_answer_question_ids(questions, latest_plan)
63
+ if latest_plan is not None
64
+ else answered_questions
65
+ )
66
+ if open_questions:
67
+ action, reason = (
68
+ "question-answer",
69
+ "Required planning questions are open.",
70
+ )
71
+ question = _first_question_by_ids(questions, open_questions)
72
+ next_item = _question_next_item(question) if question is not None else None
73
+ progress["questions"] = {
74
+ "required_open": len(open_questions),
75
+ "required_open_ids": open_questions,
76
+ }
77
+ blockers.append(
78
+ {
79
+ "kind": "open_questions",
80
+ "question_ids": open_questions,
81
+ "message": "Required planning questions must be answered.",
82
+ }
83
+ )
84
+ elif stale_answers:
85
+ action, reason = (
86
+ "plan-regenerate",
87
+ "Answered planning questions should be reflected in the plan.",
88
+ )
89
+ question = _first_question_by_ids(questions, stale_answers)
90
+ next_item = (
91
+ _answered_question_next_item(question) if question is not None else None
92
+ )
93
+ progress["questions"] = {
94
+ "required_open": 0,
95
+ "required_open_ids": [],
96
+ "answered_since_latest_plan": stale_answers,
97
+ }
98
+ else:
99
+ action, reason = (
100
+ "plan-propose",
101
+ "Planning is active; propose the next plan.",
102
+ )
103
+ elif active_stage == "implementation":
104
+ todo_report = _build_todo_gate_report(workspace_root, task)
105
+ open_todo_ids = cast(list[str], todo_report.get("open_todos", []))
106
+ open_todo_count = len(open_todo_ids)
107
+ total_todos = todo_report.get("total", 0)
108
+ done_todos = todo_report.get("done", 0)
109
+ progress["todos"] = {
110
+ "total": total_todos if isinstance(total_todos, int) else 0,
111
+ "done": done_todos if isinstance(done_todos, int) else 0,
112
+ "open": open_todo_count,
113
+ "open_ids": open_todo_ids,
114
+ }
115
+ if open_todo_count > 0:
116
+ todo = _first_open_todo_from_report(workspace_root, task, open_todo_ids)
117
+ next_item = _todo_next_item(todo) if todo is not None else None
118
+ action, reason = (
119
+ "todo-work",
120
+ f"Implementation is in progress; {open_todo_count} todos remain.",
121
+ )
122
+ else:
123
+ action, reason = (
124
+ "implement-finish",
125
+ "All todos done; ready to finish implementation.",
126
+ )
127
+ next_item = _task_next_item(task)
128
+ elif active_stage == "validation":
129
+ gate_report = build_validation_gate_report(workspace_root, task)
130
+ report_blockers = cast(list[dict[str, object]], gate_report.get("blockers", []))
131
+ blockers.extend(_compact_next_action_blockers(report_blockers))
132
+ progress["validation"] = _validation_progress(gate_report)
133
+ if report_blockers:
134
+ action, reason = (
135
+ "validate-check",
136
+ "Validation is in progress; required checks remain.",
137
+ )
138
+ next_item = _next_validation_item(
139
+ workspace_root,
140
+ task,
141
+ gate_report,
142
+ report_blockers,
143
+ )
144
+ else:
145
+ action, reason = (
146
+ "validate-finish",
147
+ "Validation is complete enough to finish.",
148
+ )
149
+ next_item = _task_next_item(task)
150
+ elif task.status_stage == "draft":
151
+ action, reason = "plan", "Draft tasks need planning before work starts."
152
+ elif task.status_stage == "plan_review":
153
+ questions = list_questions(workspace_root, task.id)
154
+ open_questions = _required_open_question_ids(questions)
155
+ latest_plan = _latest_plan_or_none(workspace_root, task.id)
156
+ stale_answers = (
157
+ _stale_answer_question_ids(questions, latest_plan)
158
+ if latest_plan is not None
159
+ else []
160
+ )
161
+ if open_questions:
162
+ action, reason = (
163
+ "question-answer",
164
+ "Required planning questions are open.",
165
+ )
166
+ question = _first_question_by_ids(questions, open_questions)
167
+ next_item = _question_next_item(question) if question is not None else None
168
+ progress["questions"] = {
169
+ "required_open": len(open_questions),
170
+ "required_open_ids": open_questions,
171
+ }
172
+ blockers.append(
173
+ {
174
+ "kind": "open_questions",
175
+ "question_ids": open_questions,
176
+ "message": "Required planning questions must be answered.",
177
+ }
178
+ )
179
+ elif stale_answers:
180
+ action, reason = (
181
+ "plan-regenerate",
182
+ "Answered planning questions are not reflected in the latest plan.",
183
+ )
184
+ question = _first_question_by_ids(questions, stale_answers)
185
+ next_item = (
186
+ _answered_question_next_item(question) if question is not None else None
187
+ )
188
+ progress["questions"] = {
189
+ "required_open": 0,
190
+ "required_open_ids": [],
191
+ "answered_since_latest_plan": stale_answers,
192
+ }
193
+ blockers.append(
194
+ {
195
+ "kind": "stale_answers",
196
+ "question_ids": stale_answers,
197
+ "message": "Regenerate the plan from answered questions.",
198
+ }
199
+ )
200
+ else:
201
+ action, reason = "plan-approve", "A proposed plan is waiting for review."
202
+ if latest_plan is not None:
203
+ next_item = _plan_next_item(latest_plan)
204
+ elif task.status_stage == "approved":
205
+ action, reason = "implement", "The approved plan is ready for implementation."
206
+ next_item = _task_next_item(task)
207
+ if task.accepted_plan_version is None:
208
+ blockers.append(
209
+ {"kind": "approval", "message": "No accepted plan version is recorded."}
210
+ )
211
+ blockers.extend(
212
+ cast(list[dict[str, object]], _dependency_blockers(workspace_root, task))
213
+ )
214
+ elif task.status_stage == "implemented":
215
+ action, reason = "validate", "Implementation is complete and ready to validate."
216
+ next_item = _task_next_item(task)
217
+ impl_run = _optional_run(workspace_root, task, task.latest_implementation_run)
218
+ if (
219
+ impl_run is None
220
+ or impl_run.run_type != "implementation"
221
+ or impl_run.status != "finished"
222
+ ):
223
+ blockers.append(
224
+ {
225
+ "kind": "implementation",
226
+ "message": "Validation requires a finished implementation run.",
227
+ }
228
+ )
229
+ elif task.status_stage == "failed_validation":
230
+ action, reason = (
231
+ "implement-restart",
232
+ "Validation failed; restart implementation.",
233
+ )
234
+ next_item = _task_next_item(task)
235
+ blockers.extend(
236
+ cast(list[dict[str, object]], _dependency_blockers(workspace_root, task))
237
+ )
238
+ elif task.status_stage == "done":
239
+ action, reason = "none", "The task is complete."
240
+ else:
241
+ action, reason = "none", "The task is cancelled."
242
+ if lock is not None and active_stage is None:
243
+ blockers.append(
244
+ {
245
+ "kind": "lock",
246
+ "message": (
247
+ f"Task has a {lock.stage} lock from {lock.run_id} "
248
+ "without a matching running run."
249
+ ),
250
+ }
251
+ )
252
+ action = "repair-lock"
253
+ reason = "A stale or broken lock must be repaired before work can continue."
254
+ next_item = _lock_next_item(task, lock)
255
+ next_command = _primary_command_for_next_item(action, next_item)
256
+ commands = _commands_for_next_item(action, next_item)
257
+ return {
258
+ "kind": "task_next_action",
259
+ "task_id": task.id,
260
+ "status_stage": task.status_stage,
261
+ "active_stage": active_stage,
262
+ "action": action,
263
+ "reason": reason,
264
+ "blocking": blockers,
265
+ "next_command": next_command,
266
+ "next_item": next_item,
267
+ "commands": commands,
268
+ "progress": progress,
269
+ }
270
+
271
+
272
+ def can_perform(workspace_root: Path, task_ref: str, action: str) -> dict[str, object]:
273
+ task = resolve_task(workspace_root, task_ref)
274
+ lock = _current_lock(workspace_root, task.id)
275
+ active_stage = _task_active_stage(workspace_root, task, lock=lock)
276
+ ok = False
277
+ reason = ""
278
+ blocking: list[dict[str, str]] = []
279
+ if action == "plan":
280
+ ok = task.status_stage in {"draft", "plan_review"} and lock is None
281
+ reason = (
282
+ "Planning can start from draft or after plan review."
283
+ if ok
284
+ else (
285
+ "Planning is only available from draft or plan_review "
286
+ "without an active lock."
287
+ )
288
+ )
289
+ if lock is not None:
290
+ blocking.append(
291
+ {
292
+ "kind": "lock",
293
+ "message": (
294
+ f"Task has an active {lock.stage} lock from {lock.run_id}."
295
+ ),
296
+ }
297
+ )
298
+ elif action == "implement":
299
+ ok = (
300
+ task.status_stage in IMPLEMENTABLE_TASK_STAGES
301
+ and task.accepted_plan_version is not None
302
+ and not _dependency_blockers(workspace_root, task)
303
+ and lock is None
304
+ and active_stage is None
305
+ )
306
+ reason = (
307
+ "Implementation is ready."
308
+ if ok
309
+ else (
310
+ "Implementation requires an accepted plan, valid stage, "
311
+ "no conflicting lock, and completed dependencies."
312
+ )
313
+ )
314
+ if task.accepted_plan_version is None:
315
+ blocking.append(
316
+ {"kind": "approval", "message": "No accepted plan version."}
317
+ )
318
+ blocking.extend(_dependency_blockers(workspace_root, task))
319
+ if lock is not None:
320
+ blocking.append(
321
+ {
322
+ "kind": "lock",
323
+ "message": (
324
+ f"Task has an active {lock.stage} lock from {lock.run_id}."
325
+ ),
326
+ }
327
+ )
328
+ elif action == "implement-restart":
329
+ validation_run = _optional_run(workspace_root, task, task.latest_validation_run)
330
+ implementation_run = _optional_run(
331
+ workspace_root,
332
+ task,
333
+ task.latest_implementation_run,
334
+ )
335
+ ok = (
336
+ task.status_stage == "failed_validation"
337
+ and task.accepted_plan_version is not None
338
+ and validation_run is not None
339
+ and validation_run.run_type == "validation"
340
+ and validation_run.status in {"failed", "blocked"}
341
+ and validation_run.result in {"failed", "blocked"}
342
+ and implementation_run is not None
343
+ and implementation_run.run_type == "implementation"
344
+ and not _dependency_blockers(workspace_root, task)
345
+ and lock is None
346
+ and active_stage is None
347
+ )
348
+ reason = (
349
+ "Implementation restart is ready."
350
+ if ok
351
+ else (
352
+ "Implementation restart requires failed_validation state, "
353
+ "an accepted plan, recorded failed validation, a previous "
354
+ "implementation run, no conflicting lock, and completed dependencies."
355
+ )
356
+ )
357
+ if task.accepted_plan_version is None:
358
+ blocking.append(
359
+ {"kind": "approval", "message": "No accepted plan version."}
360
+ )
361
+ if (
362
+ validation_run is None
363
+ or validation_run.run_type != "validation"
364
+ or validation_run.status not in {"failed", "blocked"}
365
+ or validation_run.result not in {"failed", "blocked"}
366
+ ):
367
+ blocking.append(
368
+ {
369
+ "kind": "validation",
370
+ "message": "No failed validation run is available for restart.",
371
+ }
372
+ )
373
+ if (
374
+ implementation_run is None
375
+ or implementation_run.run_type != "implementation"
376
+ ):
377
+ blocking.append(
378
+ {
379
+ "kind": "implementation",
380
+ "message": "No previous implementation run is available.",
381
+ }
382
+ )
383
+ blocking.extend(_dependency_blockers(workspace_root, task))
384
+ if lock is not None:
385
+ blocking.append(
386
+ {
387
+ "kind": "lock",
388
+ "message": (
389
+ f"Task has an active {lock.stage} lock from {lock.run_id}."
390
+ ),
391
+ }
392
+ )
393
+ elif action == "validate":
394
+ impl_run = _optional_run(workspace_root, task, task.latest_implementation_run)
395
+ ok = (
396
+ task.status_stage == "implemented"
397
+ and lock is None
398
+ and active_stage is None
399
+ and impl_run is not None
400
+ and impl_run.run_type == "implementation"
401
+ and impl_run.status == "finished"
402
+ )
403
+ reason = (
404
+ "Validation is ready."
405
+ if ok
406
+ else (
407
+ "Validation requires implemented state, a finished "
408
+ "implementation run, and no conflicting lock."
409
+ )
410
+ )
411
+ if (
412
+ impl_run is None
413
+ or impl_run.run_type != "implementation"
414
+ or impl_run.status != "finished"
415
+ ):
416
+ blocking.append(
417
+ {
418
+ "kind": "implementation",
419
+ "message": "No finished implementation run is available.",
420
+ }
421
+ )
422
+ if lock is not None:
423
+ blocking.append(
424
+ {
425
+ "kind": "lock",
426
+ "message": (
427
+ f"Task has an active {lock.stage} lock from {lock.run_id}."
428
+ ),
429
+ }
430
+ )
431
+ else:
432
+ raise _cli_error(f"Unsupported action: {action}", EXIT_CODE_BAD_INPUT)
433
+ return {
434
+ "kind": "task_capability",
435
+ "task_id": task.id,
436
+ "action": action,
437
+ "ok": ok,
438
+ "reason": reason,
439
+ "active_stage": active_stage,
440
+ "blocking": blocking,
441
+ }
442
+
443
+
444
+ def task_dossier(
445
+ workspace_root: Path,
446
+ task_ref: str,
447
+ *,
448
+ format_name: str = "markdown",
449
+ ) -> str | dict[str, object]:
450
+ from taskledger.services.handoff import render_handoff
451
+
452
+ return render_handoff(
453
+ workspace_root,
454
+ task_ref,
455
+ mode="full",
456
+ format_name=format_name,
457
+ )
458
+
459
+
460
+ def _answer_snapshot_hash(questions: list[QuestionRecord]) -> str | None:
461
+ answered = [
462
+ f"{item.id}\0{item.answer or ''}"
463
+ for item in questions
464
+ if item.status == "answered"
465
+ ]
466
+ if not answered:
467
+ return None
468
+ digest = hashlib.sha256("\n".join(sorted(answered)).encode("utf-8")).hexdigest()
469
+ return f"sha256:{digest}"
470
+
471
+
472
+ def _required_open_question_ids(questions: list[QuestionRecord]) -> list[str]:
473
+ return [
474
+ item.id
475
+ for item in questions
476
+ if item.status == "open" and item.required_for_plan
477
+ ]
478
+
479
+
480
+ def _latest_plan_or_none(workspace_root: Path, task_id: str) -> PlanRecord | None:
481
+ plans = list_plans(workspace_root, task_id)
482
+ return plans[-1] if plans else None
483
+
484
+
485
+ def _stale_answer_question_ids(
486
+ questions: list[QuestionRecord],
487
+ plan: PlanRecord,
488
+ ) -> list[str]:
489
+ answered = [
490
+ item
491
+ for item in questions
492
+ if item.status == "answered" and item.required_for_plan
493
+ ]
494
+ if not answered:
495
+ return []
496
+ current_hash = _answer_snapshot_hash(questions)
497
+ if (
498
+ plan.generation_reason == "after_questions"
499
+ and plan.based_on_answer_hash == current_hash
500
+ ):
501
+ return []
502
+ return [item.id for item in answered]
503
+
504
+
505
+ def _question_next_item(question: QuestionRecord) -> dict[str, object]:
506
+ return {
507
+ "kind": "question",
508
+ "id": question.id,
509
+ "text": question.question,
510
+ "status": question.status,
511
+ "required_for_plan": question.required_for_plan,
512
+ "plan_version": question.plan_version,
513
+ }
514
+
515
+
516
+ def _answered_question_next_item(question: QuestionRecord) -> dict[str, object]:
517
+ return {
518
+ "kind": "answered_question",
519
+ "id": question.id,
520
+ "text": question.question,
521
+ "status": question.status,
522
+ "answer": question.answer,
523
+ "answered_at": question.answered_at,
524
+ "required_for_plan": question.required_for_plan,
525
+ "plan_version": question.plan_version,
526
+ }
527
+
528
+
529
+ def _todo_next_item(todo: TaskTodo) -> dict[str, object]:
530
+ return {
531
+ "kind": "todo",
532
+ "id": todo.id,
533
+ "text": todo.text,
534
+ "status": todo.status,
535
+ "mandatory": todo.mandatory,
536
+ "source": todo.source,
537
+ "done": todo.done,
538
+ "validation_hint": todo.validation_hint,
539
+ "done_command_hint": _todo_done_command(todo.id),
540
+ }
541
+
542
+
543
+ def _criterion_next_item(criterion_report: Mapping[str, object]) -> dict[str, object]:
544
+ return {
545
+ "kind": "criterion",
546
+ "id": criterion_report.get("id"),
547
+ "text": criterion_report.get("text"),
548
+ "mandatory": criterion_report.get("mandatory"),
549
+ "latest_status": criterion_report.get("latest_status"),
550
+ "satisfied": criterion_report.get("satisfied"),
551
+ }
552
+
553
+
554
+ def _plan_next_item(plan: PlanRecord) -> dict[str, object]:
555
+ return {
556
+ "kind": "plan",
557
+ "id": f"plan-v{plan.plan_version}",
558
+ "version": plan.plan_version,
559
+ "status": plan.status,
560
+ }
561
+
562
+
563
+ def _task_next_item(task: TaskRecord) -> dict[str, object]:
564
+ return {
565
+ "kind": "task",
566
+ "id": task.id,
567
+ "status_stage": task.status_stage,
568
+ }
569
+
570
+
571
+ def _lock_next_item(task: TaskRecord, lock: TaskLock) -> dict[str, object]:
572
+ return {
573
+ "kind": "lock",
574
+ "id": lock.lock_id,
575
+ "task_id": task.id,
576
+ "stage": lock.stage,
577
+ "run_id": lock.run_id,
578
+ "expired": lock_is_expired(lock),
579
+ }
580
+
581
+
582
+ def _command(
583
+ kind: str,
584
+ label: str,
585
+ command: str,
586
+ *,
587
+ primary: bool = False,
588
+ ) -> dict[str, object]:
589
+ return {
590
+ "kind": kind,
591
+ "label": label,
592
+ "command": command,
593
+ "primary": primary,
594
+ }
595
+
596
+
597
+ def _first_question_by_ids(
598
+ questions: Sequence[QuestionRecord],
599
+ ids: Sequence[str],
600
+ ) -> QuestionRecord | None:
601
+ wanted = set(ids)
602
+ for question in questions:
603
+ if question.id in wanted:
604
+ return question
605
+ return None
606
+
607
+
608
+ def _first_open_todo_from_report(
609
+ workspace_root: Path,
610
+ task: TaskRecord,
611
+ open_ids: Sequence[str],
612
+ ) -> TaskTodo | None:
613
+ task = _task_with_sidecars(workspace_root, task)
614
+ wanted = set(open_ids)
615
+ for todo in task.todos:
616
+ if todo.id in wanted and todo.status == "active" and not todo.done:
617
+ return todo
618
+ for todo in task.todos:
619
+ if todo.id in wanted and not todo.done:
620
+ return todo
621
+ return None
622
+
623
+
624
+ def _criterion_report_by_id(
625
+ gate_report: Mapping[str, object],
626
+ criterion_id: str,
627
+ ) -> dict[str, object] | None:
628
+ criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
629
+ for criterion in criteria:
630
+ if criterion.get("id") == criterion_id:
631
+ return criterion
632
+ return None
633
+
634
+
635
+ def _compact_next_action_blockers(
636
+ blockers: Sequence[Mapping[str, object]],
637
+ ) -> list[dict[str, object]]:
638
+ compact: list[dict[str, object]] = []
639
+ for blocker in blockers:
640
+ item: dict[str, object] = {
641
+ "kind": str(blocker.get("kind", "blocker")),
642
+ "message": str(blocker.get("message", "Next-action blocker")),
643
+ }
644
+ ref = blocker.get("ref")
645
+ if isinstance(ref, str) and ref:
646
+ item["ref"] = ref
647
+ command_hint = _optional_string_value(blocker.get("command_hint"))
648
+ if command_hint is not None:
649
+ item["command_hint"] = command_hint
650
+ compact.append(item)
651
+ return compact
652
+
653
+
654
+ def _validation_progress(gate_report: Mapping[str, object]) -> dict[str, object]:
655
+ criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
656
+ satisfied = sum(1 for criterion in criteria if criterion.get("satisfied") is True)
657
+ blocking_ids: list[str] = []
658
+ for blocker in cast(list[dict[str, object]], gate_report.get("blockers", [])):
659
+ ref = blocker.get("ref")
660
+ if isinstance(ref, str) and ref and ref not in blocking_ids:
661
+ blocking_ids.append(ref)
662
+ return {
663
+ "total": len(criteria),
664
+ "satisfied": satisfied,
665
+ "remaining": max(len(blocking_ids), len(criteria) - satisfied),
666
+ "blocking_ids": blocking_ids,
667
+ }
668
+
669
+
670
+ def _next_validation_item(
671
+ workspace_root: Path,
672
+ task: TaskRecord,
673
+ gate_report: Mapping[str, object],
674
+ blockers: Sequence[Mapping[str, object]],
675
+ ) -> dict[str, object] | None:
676
+ priority = (
677
+ "criterion_fail",
678
+ "criterion_missing",
679
+ "criterion_unsatisfied",
680
+ "todo_open",
681
+ "no_finished_implementation",
682
+ "dependency_blocker",
683
+ "no_accepted_plan",
684
+ "plan_not_accepted",
685
+ )
686
+ for kind in priority:
687
+ for blocker in blockers:
688
+ if blocker.get("kind") != kind:
689
+ continue
690
+ ref = blocker.get("ref")
691
+ if kind.startswith("criterion_") and isinstance(ref, str):
692
+ criterion = _criterion_report_by_id(gate_report, ref)
693
+ if criterion is not None:
694
+ return _criterion_next_item(criterion)
695
+ if kind == "todo_open" and isinstance(ref, str):
696
+ todo = _first_open_todo_from_report(workspace_root, task, (ref,))
697
+ if todo is not None:
698
+ return _todo_next_item(todo)
699
+ if kind == "dependency_blocker" and isinstance(ref, str):
700
+ return {"kind": "dependency", "id": ref}
701
+ if kind == "no_finished_implementation":
702
+ return _task_next_item(task)
703
+ if kind in {"no_accepted_plan", "plan_not_accepted"}:
704
+ plan = _latest_plan_or_none(workspace_root, task.id)
705
+ if plan is not None:
706
+ return _plan_next_item(plan)
707
+ return _task_next_item(task)
708
+
709
+ for criterion in cast(list[dict[str, object]], gate_report.get("criteria", [])):
710
+ criterion_blockers = criterion.get("blockers")
711
+ if isinstance(criterion_blockers, list) and criterion_blockers:
712
+ return _criterion_next_item(criterion)
713
+ return None
714
+
715
+
716
+ def _next_action_command(action: str) -> str | None:
717
+ return {
718
+ "plan": "taskledger plan start",
719
+ "plan-propose": "taskledger plan upsert --file plan.md",
720
+ "question-answer": "taskledger question answer-many --file answers.yaml",
721
+ "plan-regenerate": "taskledger plan upsert --from-answers --file plan.md",
722
+ "plan-approve": "taskledger plan approve --version VERSION --actor user",
723
+ "implement": "taskledger implement start",
724
+ "implement-restart": "taskledger implement restart --summary SUMMARY",
725
+ "todo-work": "taskledger implement checklist",
726
+ "implement-finish": "taskledger implement finish --summary SUMMARY",
727
+ "validate": "taskledger validate start",
728
+ "validate-check": (
729
+ "taskledger validate check --criterion CRITERION "
730
+ '--status pass --evidence "..."'
731
+ ),
732
+ "validate-finish": (
733
+ "taskledger validate finish --result passed --summary SUMMARY"
734
+ ),
735
+ "repair-lock": "taskledger lock show",
736
+ }.get(action)
737
+
738
+
739
+ def _primary_command_for_next_item(
740
+ action: str,
741
+ next_item: dict[str, object] | None,
742
+ ) -> str | None:
743
+ if not next_item:
744
+ return _next_action_command(action)
745
+
746
+ kind = next_item.get("kind")
747
+ item_id = next_item.get("id")
748
+
749
+ if kind == "question" and isinstance(item_id, str):
750
+ return f'taskledger question answer {item_id} --text "..."'
751
+ if kind == "todo" and isinstance(item_id, str):
752
+ return f"taskledger todo show {item_id}"
753
+ if kind == "criterion" and isinstance(item_id, str):
754
+ return (
755
+ f"taskledger validate check --criterion {item_id} "
756
+ '--status pass --evidence "..."'
757
+ )
758
+ if kind == "plan":
759
+ version = next_item.get("version")
760
+ if isinstance(version, int):
761
+ return f"taskledger plan show --version {version}"
762
+ if kind == "lock":
763
+ task_id = next_item.get("task_id")
764
+ if isinstance(task_id, str):
765
+ return f'taskledger lock break --task {task_id} --reason "..."'
766
+
767
+ return _next_action_command(action)
768
+
769
+
770
+ def _commands_for_next_item(
771
+ action: str,
772
+ next_item: dict[str, object] | None,
773
+ ) -> list[dict[str, object]]:
774
+ if next_item is None:
775
+ primary = _primary_command_for_next_item(action, next_item)
776
+ if primary is None:
777
+ return []
778
+ label = {
779
+ "plan": "Start planning",
780
+ "plan-propose": "Propose plan",
781
+ "plan-regenerate": "Regenerate plan from answers",
782
+ "plan-approve": "Approve plan",
783
+ "implement": "Start implementation",
784
+ "implement-restart": "Restart implementation",
785
+ "todo-work": "Show implementation checklist",
786
+ "implement-finish": "Finish implementation",
787
+ "validate": "Start validation",
788
+ "validate-check": "Record validation check",
789
+ "validate-finish": "Finish validation",
790
+ "repair-lock": "Show current lock",
791
+ }.get(action, "Show next action")
792
+ command_kind = {
793
+ "plan": "start",
794
+ "plan-propose": "regenerate",
795
+ "plan-regenerate": "regenerate",
796
+ "plan-approve": "approve",
797
+ "implement": "start",
798
+ "implement-restart": "restart",
799
+ "todo-work": "context",
800
+ "implement-finish": "finish",
801
+ "validate": "start",
802
+ "validate-check": "check",
803
+ "validate-finish": "finish",
804
+ "repair-lock": "inspect",
805
+ }.get(action, "context")
806
+ return [_command(command_kind, label, primary, primary=True)]
807
+
808
+ item_kind = next_item.get("kind")
809
+ item_id = next_item.get("id")
810
+ if item_kind == "question" and isinstance(item_id, str):
811
+ return [
812
+ _command(
813
+ "answer",
814
+ "Answer required question",
815
+ f'taskledger question answer {item_id} --text "..."',
816
+ primary=True,
817
+ ),
818
+ _command("context", "Show question status", "taskledger question status"),
819
+ ]
820
+ if item_kind == "answered_question":
821
+ return [
822
+ _command(
823
+ "regenerate",
824
+ "Regenerate plan from answers",
825
+ "taskledger plan upsert --from-answers --file plan.md",
826
+ primary=True,
827
+ ),
828
+ _command(
829
+ "context",
830
+ "Show answered questions",
831
+ "taskledger question answers",
832
+ ),
833
+ ]
834
+ if item_kind == "todo" and isinstance(item_id, str):
835
+ return [
836
+ *_todo_command_hints(item_id),
837
+ _command(
838
+ "context",
839
+ "Show implementation checklist",
840
+ "taskledger implement checklist",
841
+ ),
842
+ ]
843
+ if item_kind == "criterion" and isinstance(item_id, str):
844
+ return [
845
+ _command(
846
+ "check",
847
+ "Record validation check",
848
+ (
849
+ f"taskledger validate check --criterion {item_id} "
850
+ '--status pass --evidence "..."'
851
+ ),
852
+ primary=True,
853
+ ),
854
+ _command("context", "Show validation status", "taskledger validate status"),
855
+ ]
856
+ if item_kind == "plan":
857
+ version = next_item.get("version")
858
+ if isinstance(version, int):
859
+ commands = [
860
+ _command(
861
+ "inspect",
862
+ "Show proposed plan",
863
+ f"taskledger plan show --version {version}",
864
+ primary=True,
865
+ )
866
+ ]
867
+ if action == "plan-approve":
868
+ commands.append(
869
+ _command(
870
+ "approve",
871
+ "Approve plan",
872
+ f"taskledger plan approve --version {version} --actor user",
873
+ )
874
+ )
875
+ return commands
876
+ if item_kind == "lock":
877
+ task_id = next_item.get("task_id")
878
+ if isinstance(task_id, str):
879
+ return [
880
+ _command(
881
+ "repair",
882
+ "Break stale lock",
883
+ f'taskledger lock break --task {task_id} --reason "..."',
884
+ primary=True,
885
+ ),
886
+ _command("inspect", "Show current lock", "taskledger lock show"),
887
+ ]
888
+
889
+ primary = _primary_command_for_next_item(action, next_item)
890
+ if primary is None:
891
+ return []
892
+ label = {
893
+ "implement": "Start implementation",
894
+ "implement-restart": "Restart implementation",
895
+ "implement-finish": "Finish implementation",
896
+ "validate": "Start validation",
897
+ "validate-finish": "Finish validation",
898
+ }.get(action, "Show next action")
899
+ kind_name = {
900
+ "implement": "start",
901
+ "implement-restart": "restart",
902
+ "implement-finish": "finish",
903
+ "validate": "start",
904
+ "validate-finish": "finish",
905
+ }.get(action, "context")
906
+ commands = [_command(kind_name, label, primary, primary=True)]
907
+ if action == "implement-finish":
908
+ commands.append(
909
+ _command(
910
+ "context",
911
+ "Show implementation checklist",
912
+ "taskledger implement checklist",
913
+ )
914
+ )
915
+ if action == "validate-finish":
916
+ commands.append(
917
+ _command(
918
+ "context",
919
+ "Show validation status",
920
+ "taskledger validate status",
921
+ )
922
+ )
923
+ return commands
924
+
925
+
926
+ def _optional_string_value(value: object) -> str | None:
927
+ return value if isinstance(value, str) and value.strip() else None
928
+
929
+
930
+ __all__ = ["can_perform", "next_action", "task_dossier"]