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,4224 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import getpass
5
+ import hashlib
6
+ import os
7
+ import shlex
8
+ import socket
9
+ import subprocess
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import replace
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Literal, TypedDict, cast
15
+
16
+ import yaml
17
+
18
+ from taskledger.domain.models import (
19
+ AcceptanceCriterion,
20
+ ActiveTaskState,
21
+ ActorRef,
22
+ CodeChangeRecord,
23
+ CriterionWaiver,
24
+ DependencyRequirement,
25
+ DependencyWaiver,
26
+ FileLink,
27
+ HarnessRef,
28
+ IntroductionRecord,
29
+ LinkCollection,
30
+ PlanRecord,
31
+ QuestionRecord,
32
+ RequirementCollection,
33
+ TaskEvent,
34
+ TaskLock,
35
+ TaskRecord,
36
+ TaskRunRecord,
37
+ TaskTodo,
38
+ TodoCollection,
39
+ ValidationCheck,
40
+ )
41
+ from taskledger.domain.policies import (
42
+ Decision,
43
+ derive_active_stage,
44
+ implementation_mutation_decision,
45
+ metadata_edit_decision,
46
+ plan_approve_decision,
47
+ plan_command_decision,
48
+ plan_propose_decision,
49
+ plan_revise_decision,
50
+ question_add_decision,
51
+ question_mutation_decision,
52
+ require_known_actor_role,
53
+ todo_add_decision,
54
+ todo_toggle_decision,
55
+ validation_check_decision,
56
+ )
57
+ from taskledger.domain.states import (
58
+ ACTIVE_TASK_STAGES,
59
+ EXIT_CODE_APPROVAL_REQUIRED,
60
+ EXIT_CODE_BAD_INPUT,
61
+ EXIT_CODE_DEPENDENCY_BLOCKED,
62
+ EXIT_CODE_GENERIC_FAILURE,
63
+ EXIT_CODE_INVALID_TRANSITION,
64
+ EXIT_CODE_LOCK_CONFLICT,
65
+ EXIT_CODE_MISSING,
66
+ EXIT_CODE_STALE_LOCK_REQUIRES_BREAK,
67
+ EXIT_CODE_VALIDATION_FAILED,
68
+ IMPLEMENTABLE_TASK_STAGES,
69
+ TaskStatusStage,
70
+ normalize_file_link_kind,
71
+ normalize_run_type,
72
+ normalize_validation_check_status,
73
+ normalize_validation_result,
74
+ require_transition,
75
+ )
76
+ from taskledger.errors import LaunchError, LockConflict, NoActiveTask
77
+ from taskledger.ids import next_project_id, slugify_project_ref
78
+ from taskledger.services.plan_lint import lint_plan
79
+ from taskledger.storage.atomic import atomic_write_text
80
+ from taskledger.storage.events import append_event, load_events, next_event_id
81
+ from taskledger.storage.indexes import rebuild_v2_indexes
82
+ from taskledger.storage.locks import (
83
+ lock_is_expired,
84
+ lock_status,
85
+ read_lock,
86
+ remove_lock,
87
+ write_lock,
88
+ )
89
+ from taskledger.storage.task_store import (
90
+ V2Paths,
91
+ ensure_v2_layout,
92
+ list_changes,
93
+ list_introductions,
94
+ list_plans,
95
+ list_questions,
96
+ list_runs,
97
+ list_tasks,
98
+ load_active_locks,
99
+ load_active_task_state,
100
+ load_links,
101
+ load_requirements,
102
+ load_todos,
103
+ overwrite_plan,
104
+ resolve_introduction,
105
+ resolve_plan,
106
+ resolve_question,
107
+ resolve_run,
108
+ resolve_task,
109
+ resolve_v2_paths,
110
+ save_active_task_state,
111
+ save_change,
112
+ save_introduction,
113
+ save_links,
114
+ save_plan,
115
+ save_question,
116
+ save_requirements,
117
+ save_run,
118
+ save_task,
119
+ save_todos,
120
+ task_artifacts_dir,
121
+ task_audit_dir,
122
+ task_lock_path,
123
+ )
124
+ from taskledger.storage.task_store import (
125
+ resolve_active_task as storage_resolve_active_task,
126
+ )
127
+ from taskledger.timeutils import utc_now_iso
128
+
129
+
130
+ def create_task(
131
+ workspace_root: Path,
132
+ *,
133
+ title: str,
134
+ description: str,
135
+ slug: str | None = None,
136
+ priority: str | None = None,
137
+ labels: tuple[str, ...] = (),
138
+ owner: str | None = None,
139
+ ) -> TaskRecord:
140
+ paths = ensure_v2_layout(workspace_root)
141
+ tasks = list_tasks(workspace_root)
142
+ task_slug = _unique_slug(tasks, slug or title)
143
+ task = TaskRecord(
144
+ id=next_project_id("task", [item.id for item in tasks]),
145
+ slug=task_slug,
146
+ title=title,
147
+ body=description.strip(),
148
+ description_summary=_summary_line(description),
149
+ priority=priority,
150
+ labels=tuple(dict.fromkeys(labels)),
151
+ owner=owner,
152
+ )
153
+ save_task(workspace_root, task)
154
+ _append_event(
155
+ paths.project_dir,
156
+ task.id,
157
+ "task.created",
158
+ {"slug": task.slug, "title": task.title},
159
+ )
160
+ rebuild_v2_indexes(paths)
161
+ return task
162
+
163
+
164
+ def list_task_summaries(workspace_root: Path) -> list[dict[str, object]]:
165
+ tasks = list_tasks(workspace_root)
166
+ active_state = load_active_task_state(workspace_root)
167
+ active_task_id = active_state.task_id if active_state is not None else None
168
+ return [
169
+ {
170
+ "id": task.id,
171
+ "slug": task.slug,
172
+ "title": task.title,
173
+ "status": task.status_stage,
174
+ "status_stage": task.status_stage,
175
+ "is_active": task.id == active_task_id,
176
+ "active_stage": _task_active_stage(workspace_root, task),
177
+ "accepted_plan_version": task.accepted_plan_version,
178
+ }
179
+ for task in tasks
180
+ ]
181
+
182
+
183
+ def resolve_active_task(workspace_root: Path) -> TaskRecord:
184
+ return storage_resolve_active_task(workspace_root)
185
+
186
+
187
+ def show_active_task(workspace_root: Path) -> dict[str, object]:
188
+ state = load_active_task_state(workspace_root)
189
+ if state is None:
190
+ raise NoActiveTask()
191
+ task = storage_resolve_active_task(workspace_root)
192
+ return _active_task_payload(
193
+ workspace_root,
194
+ task,
195
+ state=state,
196
+ changed=False,
197
+ previous_task_id=state.previous_task_id,
198
+ )
199
+
200
+
201
+ def activate_task(
202
+ workspace_root: Path,
203
+ ref: str,
204
+ *,
205
+ reason: str | None = None,
206
+ actor_type: str = "user",
207
+ force: bool = False,
208
+ ) -> dict[str, object]:
209
+ task = resolve_task(workspace_root, ref)
210
+ previous = load_active_task_state(workspace_root)
211
+ previous_task_id = previous.task_id if previous is not None else None
212
+ if previous_task_id == task.id and previous is not None:
213
+ return _active_task_payload(
214
+ workspace_root,
215
+ task,
216
+ state=previous,
217
+ changed=False,
218
+ previous_task_id=previous.previous_task_id,
219
+ )
220
+ if previous_task_id is not None:
221
+ _ensure_active_switch_allowed(
222
+ workspace_root,
223
+ previous_task_id,
224
+ force=force,
225
+ reason=reason,
226
+ )
227
+ state = ActiveTaskState(
228
+ task_id=task.id,
229
+ activated_by=_actor_for_active_task(actor_type),
230
+ reason=reason,
231
+ previous_task_id=previous_task_id,
232
+ )
233
+ save_active_task_state(workspace_root, state)
234
+ project_dir = resolve_v2_paths(workspace_root).project_dir
235
+ if previous_task_id is not None:
236
+ _append_event(
237
+ project_dir,
238
+ previous_task_id,
239
+ "task.deactivated",
240
+ {"reason": reason, "next_task_id": task.id, "forced": force},
241
+ )
242
+ _append_event(
243
+ project_dir,
244
+ task.id,
245
+ "task.activated",
246
+ {"reason": reason, "previous_task_id": previous_task_id, "forced": force},
247
+ )
248
+ return _active_task_payload(
249
+ workspace_root,
250
+ task,
251
+ state=state,
252
+ changed=True,
253
+ previous_task_id=previous_task_id,
254
+ )
255
+
256
+
257
+ def deactivate_task(
258
+ workspace_root: Path,
259
+ *,
260
+ reason: str,
261
+ actor_type: str = "user",
262
+ force: bool = False,
263
+ ) -> dict[str, object]:
264
+ return clear_active_task(
265
+ workspace_root,
266
+ reason=reason,
267
+ actor_type=actor_type,
268
+ force=force,
269
+ )
270
+
271
+
272
+ def clear_active_task(
273
+ workspace_root: Path,
274
+ *,
275
+ reason: str,
276
+ actor_type: str = "user",
277
+ force: bool = False,
278
+ ) -> dict[str, object]:
279
+ from taskledger.storage.task_store import clear_active_task_state
280
+
281
+ state = load_active_task_state(workspace_root)
282
+ if state is None:
283
+ raise NoActiveTask()
284
+ _ensure_active_switch_allowed(
285
+ workspace_root,
286
+ state.task_id,
287
+ force=force,
288
+ reason=reason,
289
+ )
290
+ task = storage_resolve_active_task(workspace_root)
291
+ clear_active_task_state(workspace_root)
292
+ _append_event(
293
+ resolve_v2_paths(workspace_root).project_dir,
294
+ task.id,
295
+ "task.deactivated",
296
+ {"reason": reason, "forced": force, "actor_type": actor_type},
297
+ )
298
+ return _active_task_payload(
299
+ workspace_root,
300
+ task,
301
+ state=state,
302
+ changed=True,
303
+ previous_task_id=state.previous_task_id,
304
+ active=False,
305
+ )
306
+
307
+
308
+ def show_task(workspace_root: Path, ref: str) -> dict[str, object]:
309
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, ref))
310
+ lock = read_lock(task_lock_path(resolve_v2_paths(workspace_root), task.id))
311
+ plans = list_plans(workspace_root, task.id)
312
+ questions = list_questions(workspace_root, task.id)
313
+ runs = list_runs(workspace_root, task.id)
314
+ changes = list_changes(workspace_root, task.id)
315
+ active_stage = _task_active_stage(
316
+ workspace_root,
317
+ task,
318
+ lock=lock,
319
+ runs=runs,
320
+ )
321
+ return {
322
+ "kind": "task",
323
+ "task": _task_payload(task, active_stage=active_stage),
324
+ "lock": lock.to_dict() if lock is not None else None,
325
+ "plans": [plan.to_dict() for plan in plans],
326
+ "questions": [question.to_dict() for question in questions],
327
+ "runs": [run.to_dict() for run in runs],
328
+ "changes": [change.to_dict() for change in changes],
329
+ }
330
+
331
+
332
+ def edit_task(
333
+ workspace_root: Path,
334
+ ref: str,
335
+ *,
336
+ title: str | None = None,
337
+ description: str | None = None,
338
+ priority: str | None = None,
339
+ owner: str | None = None,
340
+ add_labels: tuple[str, ...] = (),
341
+ remove_labels: tuple[str, ...] = (),
342
+ add_notes: tuple[str, ...] = (),
343
+ ) -> TaskRecord:
344
+ task = resolve_task(workspace_root, ref)
345
+ _enforce_decision(
346
+ metadata_edit_decision(task, _current_lock(workspace_root, task.id))
347
+ )
348
+ labels = [item for item in task.labels if item not in set(remove_labels)]
349
+ for label in add_labels:
350
+ if label not in labels:
351
+ labels.append(label)
352
+ notes = tuple([*task.notes, *[note for note in add_notes if note]])
353
+ updated = replace(
354
+ task,
355
+ title=title or task.title,
356
+ body=description.strip() if description is not None else task.body,
357
+ description_summary=(
358
+ _summary_line(description)
359
+ if description is not None
360
+ else task.description_summary
361
+ ),
362
+ priority=priority or task.priority,
363
+ owner=owner or task.owner,
364
+ labels=tuple(labels),
365
+ notes=notes,
366
+ updated_at=utc_now_iso(),
367
+ )
368
+ save_task(workspace_root, updated)
369
+ _append_event(
370
+ resolve_v2_paths(workspace_root).project_dir,
371
+ updated.id,
372
+ "task.updated",
373
+ {"title": updated.title},
374
+ )
375
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
376
+ return updated
377
+
378
+
379
+ def cancel_task(
380
+ workspace_root: Path,
381
+ ref: str,
382
+ *,
383
+ reason: str | None = None,
384
+ ) -> dict[str, object]:
385
+ task = resolve_task(workspace_root, ref)
386
+ require_transition(task.status_stage, "cancelled")
387
+ lock_path = task_lock_path(resolve_v2_paths(workspace_root), task.id)
388
+ lock = read_lock(lock_path)
389
+ if lock is not None:
390
+ _release_lock(
391
+ workspace_root,
392
+ task=task,
393
+ expected_stage=lock.stage,
394
+ run_id=lock.run_id,
395
+ target_stage="cancelled",
396
+ event_name="stage.failed",
397
+ extra_data={"reason": reason or "cancelled"},
398
+ )
399
+ task = resolve_task(workspace_root, ref)
400
+ updated = replace(task, status_stage="cancelled", updated_at=utc_now_iso())
401
+ save_task(workspace_root, updated)
402
+ _append_event(
403
+ resolve_v2_paths(workspace_root).project_dir,
404
+ updated.id,
405
+ "task.cancelled",
406
+ {"reason": reason},
407
+ )
408
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
409
+ return _lifecycle_payload(
410
+ "task cancel",
411
+ updated,
412
+ warnings=[],
413
+ changed=True,
414
+ )
415
+
416
+
417
+ def close_task(workspace_root: Path, ref: str) -> dict[str, object]:
418
+ task = resolve_task(workspace_root, ref)
419
+ if task.status_stage != "done":
420
+ raise _cli_error(
421
+ "Only done tasks can be closed via task close.",
422
+ EXIT_CODE_INVALID_TRANSITION,
423
+ )
424
+ return _lifecycle_payload("task close", task, warnings=[], changed=False)
425
+
426
+
427
+ def create_introduction(
428
+ workspace_root: Path,
429
+ *,
430
+ title: str,
431
+ body: str,
432
+ slug: str | None = None,
433
+ labels: tuple[str, ...] = (),
434
+ ) -> IntroductionRecord:
435
+ intros = list_introductions(workspace_root)
436
+ intro = IntroductionRecord(
437
+ id=next_project_id("intro", [item.id for item in intros]),
438
+ slug=_unique_slug(intros, slug or title),
439
+ title=title,
440
+ body=body.strip(),
441
+ labels=tuple(dict.fromkeys(labels)),
442
+ )
443
+ save_introduction(workspace_root, intro)
444
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
445
+ return intro
446
+
447
+
448
+ def link_introduction(
449
+ workspace_root: Path, task_ref: str, introduction_ref: str
450
+ ) -> TaskRecord:
451
+ task = resolve_task(workspace_root, task_ref)
452
+ intro = resolve_introduction(workspace_root, introduction_ref)
453
+ updated = replace(
454
+ task,
455
+ introduction_ref=intro.id,
456
+ updated_at=utc_now_iso(),
457
+ )
458
+ save_task(workspace_root, updated)
459
+ _append_event(
460
+ resolve_v2_paths(workspace_root).project_dir,
461
+ updated.id,
462
+ "task.updated",
463
+ {"introduction_ref": intro.id},
464
+ )
465
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
466
+ return updated
467
+
468
+
469
+ def add_requirement(
470
+ workspace_root: Path, task_ref: str, required_task_ref: str
471
+ ) -> TaskRecord:
472
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
473
+ required = resolve_task(workspace_root, required_task_ref)
474
+ requirements = list(task.requirements)
475
+ if required.id not in requirements:
476
+ requirements.append(required.id)
477
+ updated = replace(
478
+ task,
479
+ requirements=tuple(requirements),
480
+ updated_at=utc_now_iso(),
481
+ )
482
+ save_requirements(
483
+ workspace_root,
484
+ RequirementCollection(
485
+ task_id=updated.id,
486
+ requirements=tuple(
487
+ DependencyRequirement(task_id=item) for item in requirements
488
+ ),
489
+ ),
490
+ )
491
+ save_task(workspace_root, updated)
492
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
493
+ return updated
494
+
495
+
496
+ def remove_requirement(
497
+ workspace_root: Path, task_ref: str, required_task_ref: str
498
+ ) -> TaskRecord:
499
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
500
+ required = resolve_task(workspace_root, required_task_ref)
501
+ remaining = tuple(item for item in task.requirements if item != required.id)
502
+ updated = replace(
503
+ task,
504
+ requirements=remaining,
505
+ updated_at=utc_now_iso(),
506
+ )
507
+ save_requirements(
508
+ workspace_root,
509
+ RequirementCollection(
510
+ task_id=updated.id,
511
+ requirements=tuple(
512
+ DependencyRequirement(task_id=item) for item in remaining
513
+ ),
514
+ ),
515
+ )
516
+ save_task(workspace_root, updated)
517
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
518
+ return updated
519
+
520
+
521
+ def waive_requirement(
522
+ workspace_root: Path,
523
+ task_ref: str,
524
+ required_task_ref: str,
525
+ *,
526
+ actor_type: str,
527
+ reason: str,
528
+ ) -> TaskRecord:
529
+ if actor_type != "user":
530
+ raise _cli_error(
531
+ "Only user dependency waivers can unblock implementation.",
532
+ EXIT_CODE_APPROVAL_REQUIRED,
533
+ )
534
+ if not reason.strip():
535
+ raise _cli_error("Dependency waiver requires --reason.", EXIT_CODE_BAD_INPUT)
536
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
537
+ required = resolve_task(workspace_root, required_task_ref)
538
+ sidecar = load_requirements(workspace_root, task.id)
539
+ requirements = list(sidecar.requirements)
540
+ for index, item in enumerate(requirements):
541
+ if item.task_id == required.id:
542
+ requirements[index] = replace(
543
+ item,
544
+ waiver=DependencyWaiver(
545
+ actor=ActorRef(
546
+ actor_type="user",
547
+ actor_name=getpass.getuser() or "user",
548
+ tool="manual",
549
+ ),
550
+ reason=reason.strip(),
551
+ ),
552
+ )
553
+ break
554
+ else:
555
+ requirements.append(
556
+ DependencyRequirement(
557
+ task_id=required.id,
558
+ waiver=DependencyWaiver(
559
+ actor=ActorRef(
560
+ actor_type="user",
561
+ actor_name=getpass.getuser() or "user",
562
+ tool="manual",
563
+ ),
564
+ reason=reason.strip(),
565
+ ),
566
+ )
567
+ )
568
+ save_requirements(
569
+ workspace_root,
570
+ RequirementCollection(task_id=task.id, requirements=tuple(requirements)),
571
+ )
572
+ updated = replace(
573
+ task,
574
+ requirements=tuple(item.task_id for item in requirements),
575
+ updated_at=utc_now_iso(),
576
+ )
577
+ save_task(workspace_root, updated)
578
+ _append_event(
579
+ resolve_v2_paths(workspace_root).project_dir,
580
+ updated.id,
581
+ "requirement.waived",
582
+ {"required_task_id": required.id, "reason": reason.strip()},
583
+ )
584
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
585
+ return updated
586
+
587
+
588
+ def add_file_link(
589
+ workspace_root: Path,
590
+ task_ref: str,
591
+ *,
592
+ path: str,
593
+ kind: str,
594
+ label: str | None = None,
595
+ required_for_validation: bool = False,
596
+ ) -> TaskRecord:
597
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
598
+ links = list(task.file_links)
599
+ existing = next((item for item in links if item.path == path), None)
600
+ new_link = FileLink(
601
+ path=path,
602
+ kind=normalize_file_link_kind(kind),
603
+ label=label,
604
+ required_for_validation=required_for_validation,
605
+ )
606
+ if existing is not None:
607
+ links = [item for item in links if item.path != path]
608
+ links.append(new_link)
609
+ updated = replace(
610
+ task,
611
+ file_links=tuple(links),
612
+ updated_at=utc_now_iso(),
613
+ )
614
+ save_links(
615
+ workspace_root, LinkCollection(task_id=updated.id, links=updated.file_links)
616
+ )
617
+ save_task(workspace_root, updated)
618
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
619
+ return updated
620
+
621
+
622
+ def remove_file_link(workspace_root: Path, task_ref: str, *, path: str) -> TaskRecord:
623
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
624
+ remaining = tuple(item for item in task.file_links if item.path != path)
625
+ updated = replace(
626
+ task,
627
+ file_links=remaining,
628
+ updated_at=utc_now_iso(),
629
+ )
630
+ save_links(workspace_root, LinkCollection(task_id=updated.id, links=remaining))
631
+ save_task(workspace_root, updated)
632
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
633
+ return updated
634
+
635
+
636
+ def list_file_links(workspace_root: Path, task_ref: str) -> dict[str, object]:
637
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
638
+ return {
639
+ "kind": "task_file_links",
640
+ "task_id": task.id,
641
+ "file_links": [item.to_dict() for item in task.file_links],
642
+ }
643
+
644
+
645
+ def add_todo(
646
+ workspace_root: Path,
647
+ task_ref: str,
648
+ *,
649
+ text: str,
650
+ source: str | None = None,
651
+ mandatory: bool = False,
652
+ ) -> TaskRecord:
653
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
654
+ lock = _lock_for_mutation(workspace_root, task.id)
655
+ # Infer source from active lock unless explicitly provided
656
+ if source is not None:
657
+ resolved_source = source
658
+ elif lock is not None and lock.stage == "planning":
659
+ resolved_source = "planner"
660
+ elif lock is not None and lock.stage == "implementing":
661
+ resolved_source = "implementer"
662
+ else:
663
+ resolved_source = "user"
664
+ actor_role = require_known_actor_role(resolved_source)
665
+ _enforce_decision(
666
+ todo_add_decision(
667
+ task,
668
+ lock,
669
+ actor_role=actor_role,
670
+ )
671
+ )
672
+ todo = TaskTodo(
673
+ id=next_project_id("todo", [item.id for item in task.todos]),
674
+ text=text.strip(),
675
+ source=resolved_source,
676
+ mandatory=mandatory,
677
+ active_at=utc_now_iso()
678
+ if lock is not None and lock.stage == "implementing"
679
+ else None,
680
+ )
681
+ updated = replace(
682
+ task,
683
+ todos=tuple([*task.todos, todo]),
684
+ updated_at=utc_now_iso(),
685
+ )
686
+ save_todos(workspace_root, TodoCollection(task_id=updated.id, todos=updated.todos))
687
+ save_task(workspace_root, updated)
688
+ _append_event(
689
+ resolve_v2_paths(workspace_root).project_dir,
690
+ updated.id,
691
+ "todo.added",
692
+ {"todo_id": todo.id, "text": todo.text},
693
+ )
694
+ return updated
695
+
696
+
697
+ def set_todo_done(
698
+ workspace_root: Path,
699
+ task_ref: str,
700
+ todo_id: str,
701
+ *,
702
+ done: bool,
703
+ evidence: str | None = None,
704
+ artifacts: tuple[str, ...] = (),
705
+ changes: tuple[str, ...] = (),
706
+ actor: ActorRef | None = None,
707
+ harness: HarnessRef | None = None,
708
+ ) -> TaskRecord:
709
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
710
+ normalized_todo_id = _normalize_local_id(todo_id, "todo")
711
+ _enforce_decision(
712
+ todo_toggle_decision(
713
+ task,
714
+ _lock_for_mutation(workspace_root, task.id),
715
+ actor_role="user",
716
+ )
717
+ )
718
+ now = utc_now_iso()
719
+ resolved_actor = actor or _default_actor()
720
+ todos = [
721
+ replace(
722
+ todo,
723
+ done=done,
724
+ status="done" if done else "open",
725
+ updated_at=now,
726
+ done_at=now if done else None,
727
+ completed_by=resolved_actor if done else None,
728
+ completed_in_harness=harness if done else None,
729
+ evidence=(
730
+ tuple([*todo.evidence, evidence.strip()])
731
+ if done and evidence and evidence.strip()
732
+ else todo.evidence
733
+ ),
734
+ artifact_refs=tuple([*todo.artifact_refs, *artifacts])
735
+ if done
736
+ else todo.artifact_refs,
737
+ change_refs=tuple([*todo.change_refs, *changes])
738
+ if done
739
+ else todo.change_refs,
740
+ )
741
+ if todo.id in {todo_id, normalized_todo_id}
742
+ else todo
743
+ for todo in task.todos
744
+ ]
745
+ if not any(todo.id in {todo_id, normalized_todo_id} for todo in task.todos):
746
+ raise _cli_error(f"Todo not found: {todo_id}", EXIT_CODE_MISSING)
747
+ updated = replace(task, todos=tuple(todos), updated_at=now)
748
+ save_todos(workspace_root, TodoCollection(task_id=updated.id, todos=updated.todos))
749
+ save_task(workspace_root, updated)
750
+ _append_event(
751
+ resolve_v2_paths(workspace_root).project_dir,
752
+ updated.id,
753
+ "todo.completed" if done else "todo.toggled",
754
+ {
755
+ "todo_id": todo_id,
756
+ "done": done,
757
+ "evidence": evidence,
758
+ "artifacts": list(artifacts),
759
+ "changes": list(changes),
760
+ },
761
+ )
762
+ return updated
763
+
764
+
765
+ def show_todo(workspace_root: Path, task_ref: str, todo_id: str) -> dict[str, object]:
766
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
767
+ normalized_todo_id = _normalize_local_id(todo_id, "todo")
768
+ for todo in task.todos:
769
+ if todo.id == todo_id or todo.id == normalized_todo_id:
770
+ return {"kind": "task_todo", "task_id": task.id, "todo": todo.to_dict()}
771
+ raise _cli_error(f"Todo not found: {todo_id}", EXIT_CODE_MISSING)
772
+
773
+
774
+ def start_planning(
775
+ workspace_root: Path,
776
+ task_ref: str,
777
+ *,
778
+ actor: ActorRef | None = None,
779
+ harness: HarnessRef | None = None,
780
+ ) -> dict[str, object]:
781
+ task = resolve_task(workspace_root, task_ref)
782
+ if task.status_stage not in {"draft", "plan_review"}:
783
+ raise _cli_error(
784
+ "Planning can only start from draft or plan_review.",
785
+ EXIT_CODE_INVALID_TRANSITION,
786
+ )
787
+ run = _start_run(
788
+ workspace_root,
789
+ task,
790
+ run_type="planning",
791
+ stage="planning",
792
+ actor=actor,
793
+ harness=harness,
794
+ )
795
+ updated = replace(
796
+ resolve_task(workspace_root, task.id),
797
+ latest_planning_run=run.run_id,
798
+ updated_at=utc_now_iso(),
799
+ )
800
+ save_task(workspace_root, updated)
801
+ _append_event(
802
+ resolve_v2_paths(workspace_root).project_dir,
803
+ updated.id,
804
+ "plan.started",
805
+ {"run_id": run.run_id},
806
+ )
807
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
808
+ return _lifecycle_payload(
809
+ "plan start",
810
+ updated,
811
+ warnings=[],
812
+ changed=True,
813
+ run=run,
814
+ lock=_require_lock(workspace_root, updated.id),
815
+ )
816
+
817
+
818
+ def propose_plan(
819
+ workspace_root: Path,
820
+ task_ref: str,
821
+ *,
822
+ body: str,
823
+ criteria: tuple[str, ...] = (),
824
+ ) -> dict[str, object]:
825
+ task = resolve_task(workspace_root, task_ref)
826
+ run = _require_run(workspace_root, task, task.latest_planning_run)
827
+ lock = _lock_for_mutation(workspace_root, task.id)
828
+ _enforce_decision(plan_propose_decision(task, lock, run=run))
829
+ plans = list_plans(workspace_root, task.id)
830
+ version = plans[-1].plan_version + 1 if plans else 1
831
+ front_matter, plan_body = _parse_plan_front_matter(body)
832
+ questions = list_questions(workspace_root, task.id)
833
+ plan = PlanRecord(
834
+ task_id=task.id,
835
+ plan_version=version,
836
+ body=plan_body.strip(),
837
+ status="proposed",
838
+ created_by=_default_actor(),
839
+ supersedes=plans[-1].plan_version if plans else None,
840
+ question_refs=tuple(item.id for item in questions if item.status == "open"),
841
+ criteria=_criteria_from_plan_input(front_matter, criteria),
842
+ todos=_todos_from_plan_input(front_matter),
843
+ generation_reason=_optional_front_matter_string(
844
+ front_matter, "generation_reason"
845
+ )
846
+ or "initial",
847
+ based_on_question_ids=tuple(
848
+ item.id for item in questions if item.status == "answered"
849
+ ),
850
+ based_on_answer_hash=_answer_snapshot_hash(questions),
851
+ goal=_optional_front_matter_string(front_matter, "goal"),
852
+ files=_string_tuple_from_front_matter(front_matter, "files"),
853
+ test_commands=_string_tuple_from_front_matter(front_matter, "test_commands"),
854
+ expected_outputs=_string_tuple_from_front_matter(
855
+ front_matter, "expected_outputs"
856
+ ),
857
+ todos_waived_reason=(
858
+ _optional_front_matter_string(front_matter, "todos_waived_reason")
859
+ or _optional_front_matter_string(front_matter, "todo_waiver_reason")
860
+ or _optional_front_matter_string(front_matter, "no_todos_reason")
861
+ ),
862
+ )
863
+ save_plan(workspace_root, plan)
864
+ finished_run = replace(
865
+ run,
866
+ status="finished",
867
+ finished_at=utc_now_iso(),
868
+ summary=_summary_line(plan_body),
869
+ )
870
+ save_run(workspace_root, finished_run)
871
+ updated = replace(
872
+ task,
873
+ latest_plan_version=version,
874
+ status_stage="plan_review",
875
+ updated_at=utc_now_iso(),
876
+ )
877
+ save_task(workspace_root, updated)
878
+ _release_lock(
879
+ workspace_root,
880
+ task=updated,
881
+ expected_stage="planning",
882
+ run_id=run.run_id,
883
+ target_stage="plan_review",
884
+ event_name="stage.completed",
885
+ extra_data={"plan_version": version},
886
+ delete_only=True,
887
+ )
888
+ _append_event(
889
+ resolve_v2_paths(workspace_root).project_dir,
890
+ updated.id,
891
+ "plan.proposed",
892
+ {"plan_version": version},
893
+ )
894
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
895
+ return _lifecycle_payload(
896
+ "plan propose",
897
+ updated,
898
+ warnings=[],
899
+ changed=True,
900
+ plan_version=version,
901
+ )
902
+
903
+
904
+ def upsert_plan(
905
+ workspace_root: Path,
906
+ task_ref: str,
907
+ *,
908
+ body: str,
909
+ criteria: tuple[str, ...] = (),
910
+ from_answers: bool = False,
911
+ allow_open_questions: bool = False,
912
+ ) -> dict[str, object]:
913
+ task = resolve_task(workspace_root, task_ref)
914
+ questions = list_questions(workspace_root, task.id)
915
+ open_required = _required_open_question_ids(questions)
916
+ if open_required and not allow_open_questions:
917
+ raise _cli_error(
918
+ "Plan upsert is blocked by required open questions: "
919
+ + ", ".join(open_required),
920
+ EXIT_CODE_APPROVAL_REQUIRED,
921
+ )
922
+ latest_plan = _latest_plan_or_none(workspace_root, task.id)
923
+ stale_answers = (
924
+ _stale_answer_question_ids(questions, latest_plan)
925
+ if latest_plan is not None
926
+ else [
927
+ item.id
928
+ for item in questions
929
+ if item.status == "answered" and item.required_for_plan
930
+ ]
931
+ )
932
+ if from_answers or stale_answers:
933
+ payload = regenerate_plan_from_answers(
934
+ workspace_root,
935
+ task.id,
936
+ body=body,
937
+ criteria=criteria,
938
+ allow_open_questions=allow_open_questions,
939
+ )
940
+ payload["operation"] = "regenerated"
941
+ payload["command"] = "plan upsert"
942
+ return payload
943
+ payload = propose_plan(workspace_root, task.id, body=body, criteria=criteria)
944
+ payload["operation"] = "proposed"
945
+ payload["command"] = "plan upsert"
946
+ return payload
947
+
948
+
949
+ def show_plan(
950
+ workspace_root: Path, task_ref: str, *, version: int | None = None
951
+ ) -> dict[str, object]:
952
+ task = resolve_task(workspace_root, task_ref)
953
+ plan = resolve_plan(
954
+ workspace_root,
955
+ task.id,
956
+ version=version,
957
+ )
958
+ return {
959
+ "kind": "plan",
960
+ "task_id": task.id,
961
+ "plan": plan.to_dict(),
962
+ }
963
+
964
+
965
+ def list_plan_versions(workspace_root: Path, task_ref: str) -> dict[str, object]:
966
+ task = resolve_task(workspace_root, task_ref)
967
+ plans = list_plans(workspace_root, task.id)
968
+ return {
969
+ "kind": "plan_list",
970
+ "task_id": task.id,
971
+ "plans": [plan.to_dict() for plan in plans],
972
+ }
973
+
974
+
975
+ def diff_plan(
976
+ workspace_root: Path, task_ref: str, *, from_version: int, to_version: int
977
+ ) -> dict[str, object]:
978
+ task = resolve_task(workspace_root, task_ref)
979
+ earlier = resolve_plan(workspace_root, task.id, version=from_version)
980
+ later = resolve_plan(workspace_root, task.id, version=to_version)
981
+ diff = "\n".join(
982
+ difflib.unified_diff(
983
+ earlier.body.splitlines(),
984
+ later.body.splitlines(),
985
+ fromfile=f"plan-v{from_version}",
986
+ tofile=f"plan-v{to_version}",
987
+ lineterm="",
988
+ )
989
+ )
990
+ return {
991
+ "kind": "plan_diff",
992
+ "task_id": task.id,
993
+ "from_version": from_version,
994
+ "to_version": to_version,
995
+ "diff": diff,
996
+ }
997
+
998
+
999
+ def approve_plan(
1000
+ workspace_root: Path,
1001
+ task_ref: str,
1002
+ *,
1003
+ version: int,
1004
+ actor_type: str = "user",
1005
+ actor_name: str | None = None,
1006
+ note: str | None = None,
1007
+ allow_agent_approval: bool = False,
1008
+ reason: str | None = None,
1009
+ allow_empty_criteria: bool = False,
1010
+ materialize_todos: bool = True,
1011
+ allow_open_questions: bool = False,
1012
+ allow_empty_todos: bool = False,
1013
+ allow_lint_errors: bool = False,
1014
+ ) -> dict[str, object]:
1015
+ task = resolve_task(workspace_root, task_ref)
1016
+ _enforce_decision(
1017
+ plan_approve_decision(task, _current_lock(workspace_root, task.id))
1018
+ )
1019
+ questions = list_questions(workspace_root, task.id)
1020
+ open_questions = _required_open_question_ids(questions)
1021
+ if open_questions and not allow_open_questions:
1022
+ raise _cli_error(
1023
+ "Plan approval is blocked by open planning questions: "
1024
+ + ", ".join(open_questions),
1025
+ EXIT_CODE_APPROVAL_REQUIRED,
1026
+ )
1027
+ if allow_open_questions and not (reason or "").strip():
1028
+ raise _cli_error(
1029
+ "--allow-open-questions requires --reason.", EXIT_CODE_BAD_INPUT
1030
+ )
1031
+ target = resolve_plan(workspace_root, task.id, version=version)
1032
+ if target.status != "proposed":
1033
+ raise _cli_error(
1034
+ "Only proposed plan versions can be approved. "
1035
+ f"v{target.plan_version} is {target.status}.",
1036
+ EXIT_CODE_INVALID_TRANSITION,
1037
+ )
1038
+ stale_answer_ids = _stale_answer_question_ids(questions, target)
1039
+ if stale_answer_ids:
1040
+ error = _cli_error(
1041
+ "Plan approval is blocked by answered planning questions that are not "
1042
+ "reflected in this plan. Regenerate the plan from answers first: "
1043
+ + ", ".join(stale_answer_ids),
1044
+ EXIT_CODE_APPROVAL_REQUIRED,
1045
+ )
1046
+ error.taskledger_error_code = "APPROVAL_REQUIRED"
1047
+ raise error
1048
+ if not target.criteria and not allow_empty_criteria:
1049
+ raise _cli_error(
1050
+ "Plan approval requires at least one acceptance criterion.",
1051
+ EXIT_CODE_APPROVAL_REQUIRED,
1052
+ )
1053
+ if allow_empty_criteria and not (reason or "").strip():
1054
+ raise _cli_error(
1055
+ "--allow-empty-criteria requires --reason.", EXIT_CODE_BAD_INPUT
1056
+ )
1057
+ if not target.todos and not allow_empty_todos:
1058
+ raise _cli_error(
1059
+ "Plan approval requires at least one todo. "
1060
+ 'Use --allow-empty-todos --reason "..." for trivial tasks.',
1061
+ EXIT_CODE_APPROVAL_REQUIRED,
1062
+ )
1063
+ if allow_empty_todos and not (reason or "").strip():
1064
+ raise _cli_error("--allow-empty-todos requires --reason.", EXIT_CODE_BAD_INPUT)
1065
+ if not materialize_todos and not (reason or "").strip():
1066
+ raise _cli_error(
1067
+ "--no-materialize-todos requires --reason.", EXIT_CODE_BAD_INPUT
1068
+ )
1069
+ approved_by = _approval_actor(
1070
+ actor_type=actor_type,
1071
+ actor_name=actor_name,
1072
+ note=note,
1073
+ allow_agent_approval=allow_agent_approval,
1074
+ reason=reason,
1075
+ )
1076
+ lint_payload = lint_plan(workspace_root, task.id, version=version, strict=False)
1077
+ if not lint_payload["passed"] and not allow_lint_errors:
1078
+ lint_error = _cli_error(
1079
+ "Plan approval is blocked by plan lint errors. "
1080
+ "Run `taskledger plan lint --version ...`.",
1081
+ EXIT_CODE_APPROVAL_REQUIRED,
1082
+ )
1083
+ lint_error.taskledger_error_code = "APPROVAL_REQUIRED"
1084
+ lint_error.taskledger_data = {
1085
+ **lint_error.taskledger_data,
1086
+ "details": {"plan_lint": lint_payload},
1087
+ }
1088
+ raise lint_error
1089
+ if allow_lint_errors and not (reason or "").strip():
1090
+ raise _cli_error("--allow-lint-errors requires --reason.", EXIT_CODE_BAD_INPUT)
1091
+ approval_note = (note or reason or "").strip()
1092
+ for plan in list_plans(workspace_root, task.id):
1093
+ if plan.plan_version == target.plan_version:
1094
+ updated_plan = replace(
1095
+ plan,
1096
+ status="accepted",
1097
+ approved_at=utc_now_iso(),
1098
+ approved_by=approved_by,
1099
+ approval_note=approval_note,
1100
+ )
1101
+ elif plan.status == "rejected":
1102
+ updated_plan = plan
1103
+ else:
1104
+ updated_plan = replace(plan, status="superseded")
1105
+ overwrite_plan(workspace_root, updated_plan)
1106
+ updated = replace(
1107
+ task,
1108
+ accepted_plan_version=target.plan_version,
1109
+ status_stage="approved",
1110
+ updated_at=utc_now_iso(),
1111
+ )
1112
+ save_task(workspace_root, updated)
1113
+ materialized = 0
1114
+ if materialize_todos:
1115
+ materialized_result = materialize_plan_todos(
1116
+ workspace_root,
1117
+ updated.id,
1118
+ version=target.plan_version,
1119
+ )
1120
+ materialized = materialized_result["materialized_todos"]
1121
+ updated = resolve_task(workspace_root, updated.id)
1122
+ _append_event(
1123
+ resolve_v2_paths(workspace_root).project_dir,
1124
+ updated.id,
1125
+ "plan.approved",
1126
+ {
1127
+ "plan_version": target.plan_version,
1128
+ "approved_by": approved_by.to_dict(),
1129
+ "approval_note": approval_note,
1130
+ },
1131
+ )
1132
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
1133
+ payload = _lifecycle_payload(
1134
+ "plan approve",
1135
+ updated,
1136
+ warnings=[],
1137
+ changed=True,
1138
+ plan_version=target.plan_version,
1139
+ result=f"materialized_todos={materialized}",
1140
+ )
1141
+ payload["materialized_todos"] = materialized
1142
+ payload["mandatory_todos"] = len(
1143
+ [
1144
+ todo
1145
+ for todo in load_todos(workspace_root, updated.id).todos
1146
+ if todo.mandatory
1147
+ ]
1148
+ )
1149
+ payload["next_action"] = "taskledger implement start"
1150
+ return payload
1151
+
1152
+
1153
+ class PlanTodoMaterializationPayload(TypedDict):
1154
+ kind: str
1155
+ task_id: str
1156
+ plan_id: str
1157
+ materialized_todos: int
1158
+ todos: list[dict[str, object]]
1159
+ dry_run: bool
1160
+
1161
+
1162
+ def materialize_plan_todos(
1163
+ workspace_root: Path,
1164
+ task_ref: str,
1165
+ *,
1166
+ version: int,
1167
+ dry_run: bool = False,
1168
+ ) -> PlanTodoMaterializationPayload:
1169
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
1170
+ plan = resolve_plan(workspace_root, task.id, version=version)
1171
+ existing_keys = {
1172
+ (todo.source_plan_id, _normalize_todo_text(todo.text)) for todo in task.todos
1173
+ }
1174
+ new_todos: list[TaskTodo] = []
1175
+ next_ids = [todo.id for todo in task.todos]
1176
+ for plan_todo in plan.todos:
1177
+ key = (plan.plan_id, _normalize_todo_text(plan_todo.text))
1178
+ if key in existing_keys:
1179
+ continue
1180
+ todo_id = next_project_id("todo", [*next_ids, *(todo.id for todo in new_todos)])
1181
+ new_todos.append(
1182
+ replace(
1183
+ plan_todo,
1184
+ id=todo_id,
1185
+ source="plan",
1186
+ source_plan_id=plan.plan_id,
1187
+ mandatory=plan_todo.mandatory,
1188
+ status="open",
1189
+ done=False,
1190
+ created_at=utc_now_iso(),
1191
+ updated_at=utc_now_iso(),
1192
+ )
1193
+ )
1194
+ if new_todos and not dry_run:
1195
+ updated = replace(
1196
+ task,
1197
+ todos=tuple([*task.todos, *new_todos]),
1198
+ updated_at=utc_now_iso(),
1199
+ )
1200
+ save_todos(
1201
+ workspace_root, TodoCollection(task_id=updated.id, todos=updated.todos)
1202
+ )
1203
+ save_task(workspace_root, updated)
1204
+ _append_event(
1205
+ resolve_v2_paths(workspace_root).project_dir,
1206
+ updated.id,
1207
+ "todo.added",
1208
+ {
1209
+ "source_plan_id": plan.plan_id,
1210
+ "todo_ids": [todo.id for todo in new_todos],
1211
+ },
1212
+ )
1213
+ return PlanTodoMaterializationPayload(
1214
+ kind="plan_todo_materialization",
1215
+ task_id=task.id,
1216
+ plan_id=plan.plan_id,
1217
+ materialized_todos=len(new_todos),
1218
+ todos=[todo.to_dict() for todo in new_todos],
1219
+ dry_run=dry_run,
1220
+ )
1221
+
1222
+
1223
+ def regenerate_plan_from_answers(
1224
+ workspace_root: Path,
1225
+ task_ref: str,
1226
+ *,
1227
+ body: str,
1228
+ criteria: tuple[str, ...] = (),
1229
+ allow_open_questions: bool = False,
1230
+ ) -> dict[str, object]:
1231
+ task = resolve_task(workspace_root, task_ref)
1232
+ questions = list_questions(workspace_root, task.id)
1233
+ open_required = [
1234
+ item.id
1235
+ for item in questions
1236
+ if item.status == "open" and item.required_for_plan
1237
+ ]
1238
+ if open_required and not allow_open_questions:
1239
+ raise _cli_error(
1240
+ "Plan regeneration is blocked by required open questions: "
1241
+ + ", ".join(open_required),
1242
+ EXIT_CODE_APPROVAL_REQUIRED,
1243
+ )
1244
+ answered = [
1245
+ item
1246
+ for item in questions
1247
+ if item.status == "answered" and item.required_for_plan
1248
+ ]
1249
+ plans = list_plans(workspace_root, task.id)
1250
+ if not answered and not plans:
1251
+ raise _cli_error(
1252
+ "Plan regeneration requires answered questions or a previous plan.",
1253
+ EXIT_CODE_APPROVAL_REQUIRED,
1254
+ )
1255
+ front_matter, plan_body = _parse_plan_front_matter(body)
1256
+ version = plans[-1].plan_version + 1 if plans else 1
1257
+ plan = PlanRecord(
1258
+ task_id=task.id,
1259
+ plan_version=version,
1260
+ body=plan_body.strip(),
1261
+ status="proposed",
1262
+ created_by=_default_actor(),
1263
+ supersedes=plans[-1].plan_version if plans else None,
1264
+ question_refs=tuple(open_required),
1265
+ criteria=_criteria_from_plan_input(front_matter, criteria),
1266
+ todos=_todos_from_plan_input(front_matter),
1267
+ generation_reason="after_questions",
1268
+ based_on_question_ids=tuple(item.id for item in answered),
1269
+ based_on_answer_hash=_answer_snapshot_hash(questions),
1270
+ goal=_optional_front_matter_string(front_matter, "goal"),
1271
+ files=_string_tuple_from_front_matter(front_matter, "files"),
1272
+ test_commands=_string_tuple_from_front_matter(front_matter, "test_commands"),
1273
+ expected_outputs=_string_tuple_from_front_matter(
1274
+ front_matter, "expected_outputs"
1275
+ ),
1276
+ todos_waived_reason=(
1277
+ _optional_front_matter_string(front_matter, "todos_waived_reason")
1278
+ or _optional_front_matter_string(front_matter, "todo_waiver_reason")
1279
+ or _optional_front_matter_string(front_matter, "no_todos_reason")
1280
+ ),
1281
+ )
1282
+ save_plan(workspace_root, plan)
1283
+ if plans:
1284
+ previous = plans[-1]
1285
+ if previous.status == "proposed":
1286
+ overwrite_plan(workspace_root, replace(previous, status="superseded"))
1287
+ run_to_finish: TaskRunRecord | None = None
1288
+ lock_to_release = _current_lock(workspace_root, task.id)
1289
+ if task.latest_planning_run is not None:
1290
+ candidate_run = _optional_run(workspace_root, task, task.latest_planning_run)
1291
+ if (
1292
+ candidate_run is not None
1293
+ and candidate_run.run_type == "planning"
1294
+ and candidate_run.status == "running"
1295
+ and lock_to_release is not None
1296
+ and lock_to_release.stage == "planning"
1297
+ and lock_to_release.run_id == candidate_run.run_id
1298
+ ):
1299
+ run_to_finish = candidate_run
1300
+ save_run(
1301
+ workspace_root,
1302
+ replace(
1303
+ candidate_run,
1304
+ status="finished",
1305
+ finished_at=utc_now_iso(),
1306
+ summary=_summary_line(plan_body),
1307
+ ),
1308
+ )
1309
+ updated = replace(
1310
+ task,
1311
+ latest_plan_version=version,
1312
+ status_stage="plan_review",
1313
+ updated_at=utc_now_iso(),
1314
+ )
1315
+ save_task(workspace_root, updated)
1316
+ if run_to_finish is not None:
1317
+ _release_lock(
1318
+ workspace_root,
1319
+ task=updated,
1320
+ expected_stage="planning",
1321
+ run_id=run_to_finish.run_id,
1322
+ target_stage="plan_review",
1323
+ event_name="stage.completed",
1324
+ extra_data={"plan_version": version},
1325
+ delete_only=True,
1326
+ )
1327
+ _append_event(
1328
+ resolve_v2_paths(workspace_root).project_dir,
1329
+ updated.id,
1330
+ "plan.proposed",
1331
+ {"plan_version": version, "generation_reason": "after_questions"},
1332
+ )
1333
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
1334
+ return _lifecycle_payload(
1335
+ "plan regenerate",
1336
+ updated,
1337
+ warnings=[],
1338
+ changed=True,
1339
+ plan_version=version,
1340
+ )
1341
+
1342
+
1343
+ def reject_plan(
1344
+ workspace_root: Path,
1345
+ task_ref: str,
1346
+ *,
1347
+ reason: str | None = None,
1348
+ ) -> dict[str, object]:
1349
+ task = resolve_task(workspace_root, task_ref)
1350
+ _enforce_decision(
1351
+ plan_approve_decision(task, _current_lock(workspace_root, task.id))
1352
+ )
1353
+ latest = resolve_plan(workspace_root, task.id)
1354
+ overwrite_plan(workspace_root, replace(latest, status="rejected"))
1355
+ updated = replace(task, status_stage="plan_review", updated_at=utc_now_iso())
1356
+ save_task(workspace_root, updated)
1357
+ _append_event(
1358
+ resolve_v2_paths(workspace_root).project_dir,
1359
+ updated.id,
1360
+ "plan.rejected",
1361
+ {"plan_version": latest.plan_version, "reason": reason},
1362
+ )
1363
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
1364
+ return _lifecycle_payload(
1365
+ "plan reject",
1366
+ updated,
1367
+ warnings=[],
1368
+ changed=True,
1369
+ plan_version=latest.plan_version,
1370
+ )
1371
+
1372
+
1373
+ def revise_plan(workspace_root: Path, task_ref: str) -> dict[str, object]:
1374
+ task = resolve_task(workspace_root, task_ref)
1375
+ _enforce_decision(
1376
+ plan_revise_decision(task, _current_lock(workspace_root, task.id))
1377
+ )
1378
+ return start_planning(workspace_root, task_ref)
1379
+
1380
+
1381
+ def add_question(
1382
+ workspace_root: Path,
1383
+ task_ref: str,
1384
+ *,
1385
+ text: str,
1386
+ required_for_plan: bool = False,
1387
+ actor: ActorRef | None = None,
1388
+ harness: HarnessRef | None = None,
1389
+ ) -> QuestionRecord:
1390
+ task = resolve_task(workspace_root, task_ref)
1391
+ _enforce_decision(
1392
+ question_add_decision(
1393
+ task,
1394
+ _lock_for_mutation(workspace_root, task.id),
1395
+ actor_role="planner",
1396
+ )
1397
+ )
1398
+ question = QuestionRecord(
1399
+ id=next_project_id(
1400
+ "q",
1401
+ [item.id for item in list_questions(workspace_root, task.id)],
1402
+ ),
1403
+ task_id=task.id,
1404
+ question=text.strip(),
1405
+ plan_version=task.latest_plan_version,
1406
+ required_for_plan=required_for_plan,
1407
+ asked_by_actor=actor or _default_actor(),
1408
+ asked_in_harness=harness or _default_harness(),
1409
+ )
1410
+ save_question(workspace_root, question)
1411
+ _append_event(
1412
+ resolve_v2_paths(workspace_root).project_dir,
1413
+ task.id,
1414
+ "question.added",
1415
+ {"question_id": question.id, "required_for_plan": required_for_plan},
1416
+ )
1417
+ return question
1418
+
1419
+
1420
+ def answer_question(
1421
+ workspace_root: Path,
1422
+ task_ref: str,
1423
+ question_id: str,
1424
+ *,
1425
+ text: str,
1426
+ actor: ActorRef | None = None,
1427
+ answer_source: str = "user",
1428
+ ) -> QuestionRecord:
1429
+ task = resolve_task(workspace_root, task_ref)
1430
+ _enforce_decision(
1431
+ question_mutation_decision(
1432
+ task,
1433
+ _lock_for_mutation(workspace_root, task.id),
1434
+ actor_role="user",
1435
+ )
1436
+ )
1437
+ stripped = text.strip()
1438
+ if not stripped:
1439
+ raise _cli_error(
1440
+ "Answer text must not be empty.",
1441
+ EXIT_CODE_INVALID_TRANSITION,
1442
+ )
1443
+ question = resolve_question(workspace_root, task.id, question_id)
1444
+ answered = replace(
1445
+ question,
1446
+ status="answered",
1447
+ answer=stripped,
1448
+ answered_at=utc_now_iso(),
1449
+ answered_by=(actor.actor_name if actor is not None else "user"),
1450
+ answered_by_actor=actor or ActorRef(actor_type="user", actor_name="user"),
1451
+ answer_source=answer_source,
1452
+ )
1453
+ save_question(workspace_root, answered)
1454
+ _append_event(
1455
+ resolve_v2_paths(workspace_root).project_dir,
1456
+ task.id,
1457
+ "question.answered",
1458
+ {"question_id": answered.id},
1459
+ )
1460
+ return answered
1461
+
1462
+
1463
+ def answer_questions(
1464
+ workspace_root: Path,
1465
+ task_ref: str,
1466
+ answers: Mapping[str, str],
1467
+ *,
1468
+ actor: ActorRef | None = None,
1469
+ answer_source: str = "harness",
1470
+ ) -> dict[str, object]:
1471
+ task = resolve_task(workspace_root, task_ref)
1472
+ if not answers:
1473
+ raise _cli_error("At least one answer is required.", EXIT_CODE_BAD_INPUT)
1474
+ known = {item.id: item for item in list_questions(workspace_root, task.id)}
1475
+ unknown = [question_id for question_id in answers if question_id not in known]
1476
+ if unknown:
1477
+ raise _cli_error(
1478
+ "Unknown question ids: " + ", ".join(unknown),
1479
+ EXIT_CODE_MISSING,
1480
+ )
1481
+ empty = [question_id for question_id, text in answers.items() if not text.strip()]
1482
+ if empty:
1483
+ raise _cli_error(
1484
+ "Answer text must not be empty for: " + ", ".join(empty),
1485
+ EXIT_CODE_BAD_INPUT,
1486
+ )
1487
+ answered_ids: list[str] = []
1488
+ answered_questions: list[dict[str, object]] = []
1489
+ for question_id, text in answers.items():
1490
+ question = answer_question(
1491
+ workspace_root,
1492
+ task.id,
1493
+ question_id,
1494
+ text=text,
1495
+ actor=actor,
1496
+ answer_source=answer_source,
1497
+ )
1498
+ answered_ids.append(question.id)
1499
+ answered_questions.append(question.to_dict())
1500
+ status = question_status(workspace_root, task.id)
1501
+ return {
1502
+ "kind": "question_answer_many",
1503
+ "task_id": task.id,
1504
+ "answered_question_ids": answered_ids,
1505
+ "answered": answered_questions,
1506
+ "required_open": status["required_open"],
1507
+ "required_open_questions": status["required_open_questions"],
1508
+ "plan_regeneration_needed": status["plan_regeneration_needed"],
1509
+ "next_action": status["next_action"],
1510
+ }
1511
+
1512
+
1513
+ def question_status(workspace_root: Path, task_ref: str) -> dict[str, object]:
1514
+ task = resolve_task(workspace_root, task_ref)
1515
+ questions = list_questions(workspace_root, task.id)
1516
+ required_open = _required_open_question_ids(questions)
1517
+ answered = [item for item in questions if item.status == "answered"]
1518
+ latest_plan = _latest_plan_or_none(workspace_root, task.id)
1519
+ answered_since_latest_plan = (
1520
+ _stale_answer_question_ids(questions, latest_plan)
1521
+ if latest_plan is not None
1522
+ else [item.id for item in answered]
1523
+ )
1524
+ regeneration_needed = bool(answered_since_latest_plan) and not required_open
1525
+ return {
1526
+ "kind": "question_status",
1527
+ "task_id": task.id,
1528
+ "required_open": len(required_open),
1529
+ "required_open_questions": required_open,
1530
+ "answered": len([item for item in questions if item.status == "answered"]),
1531
+ "answered_since_latest_plan": answered_since_latest_plan,
1532
+ "plan_regeneration_needed": regeneration_needed,
1533
+ "next_action": (
1534
+ "taskledger plan upsert --from-answers --file plan.md"
1535
+ if regeneration_needed
1536
+ else (
1537
+ "taskledger question answer-many --file answers.yaml"
1538
+ if required_open
1539
+ else "taskledger plan propose --file plan.md"
1540
+ )
1541
+ ),
1542
+ }
1543
+
1544
+
1545
+ def dismiss_question(
1546
+ workspace_root: Path,
1547
+ task_ref: str,
1548
+ question_id: str,
1549
+ ) -> QuestionRecord:
1550
+ task = resolve_task(workspace_root, task_ref)
1551
+ _enforce_decision(
1552
+ question_mutation_decision(
1553
+ task,
1554
+ _lock_for_mutation(workspace_root, task.id),
1555
+ actor_role="user",
1556
+ )
1557
+ )
1558
+ question = resolve_question(workspace_root, task.id, question_id)
1559
+ dismissed = replace(question, status="dismissed")
1560
+ save_question(workspace_root, dismissed)
1561
+ _append_event(
1562
+ resolve_v2_paths(workspace_root).project_dir,
1563
+ task.id,
1564
+ "question.dismissed",
1565
+ {"question_id": dismissed.id},
1566
+ )
1567
+ return dismissed
1568
+
1569
+
1570
+ def list_open_questions(workspace_root: Path, task_ref: str) -> dict[str, object]:
1571
+ task = resolve_task(workspace_root, task_ref)
1572
+ questions = [
1573
+ item.to_dict()
1574
+ for item in list_questions(workspace_root, task.id)
1575
+ if item.status == "open"
1576
+ ]
1577
+ return {"kind": "task_questions", "task_id": task.id, "questions": questions}
1578
+
1579
+
1580
+ def start_implementation(
1581
+ workspace_root: Path,
1582
+ task_ref: str,
1583
+ *,
1584
+ actor: ActorRef | None = None,
1585
+ harness: HarnessRef | None = None,
1586
+ ) -> dict[str, object]:
1587
+ task = resolve_task(workspace_root, task_ref)
1588
+ if task.status_stage not in IMPLEMENTABLE_TASK_STAGES:
1589
+ raise _cli_error(
1590
+ "Implementation requires approved or failed_validation state.",
1591
+ EXIT_CODE_INVALID_TRANSITION,
1592
+ )
1593
+ if task.accepted_plan_version is None:
1594
+ raise _cli_error(
1595
+ "Implementation requires an accepted plan version.",
1596
+ EXIT_CODE_APPROVAL_REQUIRED,
1597
+ )
1598
+ try:
1599
+ accepted_plan = resolve_plan(
1600
+ workspace_root,
1601
+ task.id,
1602
+ version=task.accepted_plan_version,
1603
+ )
1604
+ except LaunchError as exc:
1605
+ raise _cli_error(
1606
+ "Implementation requires a stored accepted plan record.",
1607
+ EXIT_CODE_APPROVAL_REQUIRED,
1608
+ ) from exc
1609
+ if accepted_plan.status != "accepted":
1610
+ raise _cli_error(
1611
+ "Implementation requires an accepted plan record.",
1612
+ EXIT_CODE_APPROVAL_REQUIRED,
1613
+ )
1614
+ _ensure_dependencies_done(workspace_root, task)
1615
+ run = _start_run(
1616
+ workspace_root,
1617
+ task,
1618
+ run_type="implementation",
1619
+ stage="implementing",
1620
+ actor=actor,
1621
+ harness=harness,
1622
+ )
1623
+ updated = replace(
1624
+ resolve_task(workspace_root, task.id),
1625
+ latest_implementation_run=run.run_id,
1626
+ status_stage="implementing",
1627
+ updated_at=utc_now_iso(),
1628
+ )
1629
+ save_task(workspace_root, updated)
1630
+ _append_event(
1631
+ resolve_v2_paths(workspace_root).project_dir,
1632
+ updated.id,
1633
+ "implementation.started",
1634
+ {"run_id": run.run_id},
1635
+ )
1636
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
1637
+ return _lifecycle_payload(
1638
+ "implement start",
1639
+ replace(updated, status_stage=task.status_stage),
1640
+ warnings=[],
1641
+ changed=True,
1642
+ run=run,
1643
+ lock=_require_lock(workspace_root, updated.id),
1644
+ )
1645
+
1646
+
1647
+ def restart_implementation(
1648
+ workspace_root: Path,
1649
+ task_ref: str,
1650
+ *,
1651
+ summary: str,
1652
+ actor: ActorRef | None = None,
1653
+ harness: HarnessRef | None = None,
1654
+ ) -> dict[str, object]:
1655
+ task = resolve_task(workspace_root, task_ref)
1656
+ if task.status_stage != "failed_validation":
1657
+ raise _cli_error(
1658
+ "Implementation restart requires failed_validation state.",
1659
+ EXIT_CODE_INVALID_TRANSITION,
1660
+ )
1661
+ if task.accepted_plan_version is None:
1662
+ raise _cli_error(
1663
+ "Implementation restart requires an accepted plan version.",
1664
+ EXIT_CODE_APPROVAL_REQUIRED,
1665
+ )
1666
+ try:
1667
+ accepted_plan = resolve_plan(
1668
+ workspace_root,
1669
+ task.id,
1670
+ version=task.accepted_plan_version,
1671
+ )
1672
+ except LaunchError as exc:
1673
+ raise _cli_error(
1674
+ "Implementation restart requires a stored accepted plan record.",
1675
+ EXIT_CODE_APPROVAL_REQUIRED,
1676
+ ) from exc
1677
+ if accepted_plan.status != "accepted":
1678
+ raise _cli_error(
1679
+ "Implementation restart requires an accepted plan record.",
1680
+ EXIT_CODE_APPROVAL_REQUIRED,
1681
+ )
1682
+ if task.latest_validation_run is None:
1683
+ raise _cli_error(
1684
+ "Implementation restart requires a failed validation run.",
1685
+ EXIT_CODE_INVALID_TRANSITION,
1686
+ )
1687
+ validation_run = _require_run(workspace_root, task, task.latest_validation_run)
1688
+ if (
1689
+ validation_run.run_type != "validation"
1690
+ or validation_run.status not in {"failed", "blocked"}
1691
+ or validation_run.result not in {"failed", "blocked"}
1692
+ ):
1693
+ raise _cli_error(
1694
+ (
1695
+ "Implementation restart requires the latest validation run "
1696
+ "to be failed or blocked."
1697
+ ),
1698
+ EXIT_CODE_INVALID_TRANSITION,
1699
+ )
1700
+ if task.latest_implementation_run is None:
1701
+ raise _cli_error(
1702
+ "Implementation restart requires a previous implementation run.",
1703
+ EXIT_CODE_INVALID_TRANSITION,
1704
+ )
1705
+ previous_run = _require_run(workspace_root, task, task.latest_implementation_run)
1706
+ if previous_run.run_type != "implementation":
1707
+ raise _cli_error(
1708
+ "Implementation restart requires a previous implementation run.",
1709
+ EXIT_CODE_INVALID_TRANSITION,
1710
+ )
1711
+ restart_summary = summary.strip()
1712
+ if not restart_summary:
1713
+ raise _cli_error(
1714
+ "Implementation restart requires a non-empty summary.",
1715
+ EXIT_CODE_BAD_INPUT,
1716
+ )
1717
+ _ensure_dependencies_done(workspace_root, task)
1718
+ run = _start_run(
1719
+ workspace_root,
1720
+ task,
1721
+ run_type="implementation",
1722
+ stage="implementing",
1723
+ actor=actor,
1724
+ harness=harness,
1725
+ )
1726
+ restarted = replace(
1727
+ run,
1728
+ resumes_run_id=previous_run.run_id,
1729
+ worklog=(
1730
+ f"Restart summary: {restart_summary}",
1731
+ (
1732
+ "Restarted after "
1733
+ f"validation run {validation_run.run_id} "
1734
+ f"({validation_run.result})."
1735
+ ),
1736
+ *run.worklog,
1737
+ ),
1738
+ )
1739
+ save_run(workspace_root, restarted)
1740
+ updated = replace(
1741
+ resolve_task(workspace_root, task.id),
1742
+ latest_implementation_run=restarted.run_id,
1743
+ status_stage="implementing",
1744
+ updated_at=utc_now_iso(),
1745
+ )
1746
+ save_task(workspace_root, updated)
1747
+ _append_event(
1748
+ resolve_v2_paths(workspace_root).project_dir,
1749
+ updated.id,
1750
+ "implementation.started",
1751
+ {
1752
+ "run_id": restarted.run_id,
1753
+ "restart": True,
1754
+ "summary": restart_summary,
1755
+ "after_validation_run": validation_run.run_id,
1756
+ "resumes_run_id": previous_run.run_id,
1757
+ },
1758
+ )
1759
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
1760
+ return _lifecycle_payload(
1761
+ "implement restart",
1762
+ replace(updated, status_stage=task.status_stage),
1763
+ warnings=[],
1764
+ changed=True,
1765
+ run=restarted,
1766
+ lock=_require_lock(workspace_root, updated.id),
1767
+ )
1768
+
1769
+
1770
+ def log_implementation(
1771
+ workspace_root: Path,
1772
+ task_ref: str,
1773
+ *,
1774
+ message: str,
1775
+ ) -> TaskRunRecord:
1776
+ task = resolve_task(workspace_root, task_ref)
1777
+ run = _require_running_run(
1778
+ workspace_root,
1779
+ task,
1780
+ task.latest_implementation_run,
1781
+ expected_type="implementation",
1782
+ )
1783
+ _enforce_decision(
1784
+ implementation_mutation_decision(
1785
+ task,
1786
+ _lock_for_mutation(workspace_root, task.id),
1787
+ run=run,
1788
+ action="log implementation work",
1789
+ )
1790
+ )
1791
+ updated = replace(run, worklog=tuple([*run.worklog, message.strip()]))
1792
+ save_run(workspace_root, updated)
1793
+ _append_event(
1794
+ resolve_v2_paths(workspace_root).project_dir,
1795
+ task.id,
1796
+ "implementation.logged",
1797
+ {"run_id": run.run_id, "message": message.strip()},
1798
+ )
1799
+ return updated
1800
+
1801
+
1802
+ def add_implementation_deviation(
1803
+ workspace_root: Path,
1804
+ task_ref: str,
1805
+ *,
1806
+ message: str,
1807
+ ) -> TaskRunRecord:
1808
+ task = resolve_task(workspace_root, task_ref)
1809
+ run = _require_running_run(
1810
+ workspace_root,
1811
+ task,
1812
+ task.latest_implementation_run,
1813
+ expected_type="implementation",
1814
+ )
1815
+ _enforce_decision(
1816
+ implementation_mutation_decision(
1817
+ task,
1818
+ _lock_for_mutation(workspace_root, task.id),
1819
+ run=run,
1820
+ action="record implementation deviations",
1821
+ )
1822
+ )
1823
+ updated = replace(
1824
+ run,
1825
+ deviations_from_plan=tuple([*run.deviations_from_plan, message.strip()]),
1826
+ )
1827
+ save_run(workspace_root, updated)
1828
+ _append_event(
1829
+ resolve_v2_paths(workspace_root).project_dir,
1830
+ task.id,
1831
+ "implementation.logged",
1832
+ {"run_id": run.run_id, "deviation": message.strip()},
1833
+ )
1834
+ return updated
1835
+
1836
+
1837
+ def add_implementation_artifact(
1838
+ workspace_root: Path,
1839
+ task_ref: str,
1840
+ *,
1841
+ path: str,
1842
+ summary: str,
1843
+ ) -> TaskRunRecord:
1844
+ task = resolve_task(workspace_root, task_ref)
1845
+ run = _require_running_run(
1846
+ workspace_root,
1847
+ task,
1848
+ task.latest_implementation_run,
1849
+ expected_type="implementation",
1850
+ )
1851
+ _enforce_decision(
1852
+ implementation_mutation_decision(
1853
+ task,
1854
+ _lock_for_mutation(workspace_root, task.id),
1855
+ run=run,
1856
+ action="record implementation artifacts",
1857
+ )
1858
+ )
1859
+ updated = replace(
1860
+ run,
1861
+ artifact_refs=tuple([*run.artifact_refs, f"{path}: {summary.strip()}"]),
1862
+ )
1863
+ save_run(workspace_root, updated)
1864
+ _append_event(
1865
+ resolve_v2_paths(workspace_root).project_dir,
1866
+ task.id,
1867
+ "implementation.logged",
1868
+ {"run_id": run.run_id, "artifact": path, "summary": summary.strip()},
1869
+ )
1870
+ return updated
1871
+
1872
+
1873
+ def add_change(
1874
+ workspace_root: Path,
1875
+ task_ref: str,
1876
+ *,
1877
+ path: str,
1878
+ kind: str,
1879
+ summary: str,
1880
+ git_commit: str | None = None,
1881
+ git_diff_stat: str | None = None,
1882
+ command: str | None = None,
1883
+ before_hash: str | None = None,
1884
+ after_hash: str | None = None,
1885
+ exit_code: int | None = None,
1886
+ artifact_refs: tuple[str, ...] = (),
1887
+ ) -> CodeChangeRecord:
1888
+ task = resolve_task(workspace_root, task_ref)
1889
+ run = _require_running_run(
1890
+ workspace_root,
1891
+ task,
1892
+ task.latest_implementation_run,
1893
+ expected_type="implementation",
1894
+ )
1895
+ _enforce_decision(
1896
+ implementation_mutation_decision(
1897
+ task,
1898
+ _lock_for_mutation(workspace_root, task.id),
1899
+ run=run,
1900
+ action="record code changes",
1901
+ )
1902
+ )
1903
+ change = CodeChangeRecord(
1904
+ change_id=next_project_id(
1905
+ "change",
1906
+ [item.change_id for item in list_changes(workspace_root, task.id)],
1907
+ ),
1908
+ task_id=task.id,
1909
+ implementation_run=run.run_id,
1910
+ timestamp=utc_now_iso(),
1911
+ kind=kind,
1912
+ path=path,
1913
+ summary=summary.strip(),
1914
+ git_commit=git_commit,
1915
+ git_diff_stat=git_diff_stat,
1916
+ command=command,
1917
+ before_hash=before_hash,
1918
+ after_hash=after_hash,
1919
+ exit_code=exit_code,
1920
+ )
1921
+ save_change(workspace_root, change)
1922
+ save_run(
1923
+ workspace_root,
1924
+ replace(
1925
+ run,
1926
+ change_refs=tuple([*run.change_refs, change.change_id]),
1927
+ artifact_refs=tuple([*run.artifact_refs, *artifact_refs]),
1928
+ ),
1929
+ )
1930
+ save_task(
1931
+ workspace_root,
1932
+ replace(
1933
+ task,
1934
+ code_change_log_refs=tuple([*task.code_change_log_refs, change.change_id]),
1935
+ updated_at=utc_now_iso(),
1936
+ ),
1937
+ )
1938
+ _append_event(
1939
+ resolve_v2_paths(workspace_root).project_dir,
1940
+ task.id,
1941
+ "change.logged",
1942
+ {"change_id": change.change_id, "path": path},
1943
+ )
1944
+ return change
1945
+
1946
+
1947
+ def scan_changes(
1948
+ workspace_root: Path,
1949
+ task_ref: str,
1950
+ *,
1951
+ from_git: bool,
1952
+ summary: str,
1953
+ ) -> CodeChangeRecord:
1954
+ if not from_git:
1955
+ raise _cli_error(
1956
+ "scan-changes currently requires --from-git.",
1957
+ EXIT_CODE_BAD_INPUT,
1958
+ )
1959
+ git_state = _git_change_state(workspace_root)
1960
+ diff_stat = "\n".join(
1961
+ [
1962
+ f"branch: {git_state['branch']}",
1963
+ "status:",
1964
+ git_state["status"] or "(clean)",
1965
+ "diff_stat:",
1966
+ git_state["diff_stat"] or "(no diff)",
1967
+ ]
1968
+ )
1969
+ return add_change(
1970
+ workspace_root,
1971
+ task_ref,
1972
+ path=".",
1973
+ kind="scan",
1974
+ summary=summary.strip() or "Scanned Git changes.",
1975
+ command="git branch --show-current && git status --short && git diff --stat",
1976
+ git_diff_stat=diff_stat,
1977
+ )
1978
+
1979
+
1980
+ def run_planning_command(
1981
+ workspace_root: Path,
1982
+ task_ref: str,
1983
+ *,
1984
+ argv: tuple[str, ...],
1985
+ ) -> dict[str, object]:
1986
+ if not argv:
1987
+ raise _cli_error("plan command requires a command to run.", EXIT_CODE_BAD_INPUT)
1988
+ task = resolve_task(workspace_root, task_ref)
1989
+ run = _require_running_run(
1990
+ workspace_root,
1991
+ task,
1992
+ task.latest_planning_run,
1993
+ expected_type="planning",
1994
+ )
1995
+ _enforce_decision(
1996
+ plan_command_decision(
1997
+ task,
1998
+ _lock_for_mutation(workspace_root, task.id),
1999
+ run=run,
2000
+ )
2001
+ )
2002
+ completed = subprocess.run(
2003
+ list(argv),
2004
+ cwd=workspace_root,
2005
+ capture_output=True,
2006
+ text=True,
2007
+ check=False,
2008
+ )
2009
+ output = _command_output(argv, completed.stdout, completed.stderr)
2010
+ artifact_ref: str | None = None
2011
+ if len(output) > 4000 or output.count("\n") > 50:
2012
+ artifact_ref = _write_command_artifact(
2013
+ workspace_root,
2014
+ task.id,
2015
+ run.run_id,
2016
+ output,
2017
+ )
2018
+ change = CodeChangeRecord(
2019
+ change_id=next_project_id(
2020
+ "change",
2021
+ [item.change_id for item in list_changes(workspace_root, task.id)],
2022
+ ),
2023
+ task_id=task.id,
2024
+ implementation_run=run.run_id,
2025
+ timestamp=utc_now_iso(),
2026
+ kind="command",
2027
+ path=".",
2028
+ summary=_command_summary(argv, completed.returncode, artifact_ref),
2029
+ command=shlex.join(argv),
2030
+ exit_code=completed.returncode,
2031
+ )
2032
+ save_change(workspace_root, change)
2033
+ save_run(
2034
+ workspace_root,
2035
+ replace(
2036
+ run,
2037
+ change_refs=tuple([*run.change_refs, change.change_id]),
2038
+ artifact_refs=tuple(
2039
+ [*run.artifact_refs, *((artifact_ref,) if artifact_ref else ())]
2040
+ ),
2041
+ ),
2042
+ )
2043
+ save_task(
2044
+ workspace_root,
2045
+ replace(
2046
+ task,
2047
+ code_change_log_refs=tuple([*task.code_change_log_refs, change.change_id]),
2048
+ updated_at=utc_now_iso(),
2049
+ ),
2050
+ )
2051
+ _append_event(
2052
+ resolve_v2_paths(workspace_root).project_dir,
2053
+ task.id,
2054
+ "change.logged",
2055
+ {"change_id": change.change_id, "path": "."},
2056
+ )
2057
+ return {
2058
+ "kind": "planning_command",
2059
+ "task_id": change.task_id,
2060
+ "change": change.to_dict(),
2061
+ "exit_code": completed.returncode,
2062
+ "artifact_path": artifact_ref,
2063
+ "stdout": completed.stdout,
2064
+ "stderr": completed.stderr,
2065
+ }
2066
+
2067
+
2068
+ def run_implementation_command(
2069
+ workspace_root: Path,
2070
+ task_ref: str,
2071
+ *,
2072
+ argv: tuple[str, ...],
2073
+ ) -> dict[str, object]:
2074
+ if not argv:
2075
+ raise _cli_error(
2076
+ "implement command requires a command to run.", EXIT_CODE_BAD_INPUT
2077
+ )
2078
+ task = resolve_task(workspace_root, task_ref)
2079
+ run = _require_running_run(
2080
+ workspace_root,
2081
+ task,
2082
+ task.latest_implementation_run,
2083
+ expected_type="implementation",
2084
+ )
2085
+ _enforce_decision(
2086
+ implementation_mutation_decision(
2087
+ task,
2088
+ _lock_for_mutation(workspace_root, task.id),
2089
+ run=run,
2090
+ action="record implementation commands",
2091
+ )
2092
+ )
2093
+ completed = subprocess.run(
2094
+ list(argv),
2095
+ cwd=workspace_root,
2096
+ capture_output=True,
2097
+ text=True,
2098
+ check=False,
2099
+ )
2100
+ output = _command_output(argv, completed.stdout, completed.stderr)
2101
+ artifact_ref: str | None = None
2102
+ if len(output) > 4000 or output.count("\n") > 50:
2103
+ artifact_ref = _write_command_artifact(
2104
+ workspace_root,
2105
+ task.id,
2106
+ run.run_id,
2107
+ output,
2108
+ )
2109
+ change = add_change(
2110
+ workspace_root,
2111
+ task_ref,
2112
+ path=".",
2113
+ kind="command",
2114
+ summary=_command_summary(argv, completed.returncode, artifact_ref),
2115
+ command=shlex.join(argv),
2116
+ exit_code=completed.returncode,
2117
+ artifact_refs=((artifact_ref,) if artifact_ref else ()),
2118
+ )
2119
+ return {
2120
+ "kind": "implementation_command",
2121
+ "task_id": change.task_id,
2122
+ "change": change.to_dict(),
2123
+ "exit_code": completed.returncode,
2124
+ "artifact_path": artifact_ref,
2125
+ "stdout": completed.stdout,
2126
+ "stderr": completed.stderr,
2127
+ }
2128
+
2129
+
2130
+ def _build_todo_gate_report(
2131
+ workspace_root: Path, task: TaskRecord
2132
+ ) -> dict[str, object]:
2133
+ """Build a report of todo completion status for finish gate validation."""
2134
+ task = _task_with_sidecars(workspace_root, task)
2135
+ todos = task.todos
2136
+ open_todos = [
2137
+ todo.id
2138
+ for todo in todos
2139
+ if not todo.done
2140
+ and todo.status not in {"done", "skipped"}
2141
+ and (
2142
+ not todo.mandatory
2143
+ or todo.active_at is not None
2144
+ or todo.source == "plan"
2145
+ or todo.source_plan_id is not None
2146
+ )
2147
+ ]
2148
+ blockers = [
2149
+ {
2150
+ "kind": "todo_open",
2151
+ "ref": todo_id,
2152
+ "message": f"Todo {todo_id} is not done.",
2153
+ "command_hint": f'taskledger todo done {todo_id} --evidence "..."',
2154
+ }
2155
+ for todo_id in open_todos
2156
+ ]
2157
+ return {
2158
+ "kind": "todo_gate_report",
2159
+ "task_id": task.id,
2160
+ "total": len(todos),
2161
+ "done": len(todos) - len(open_todos),
2162
+ "open_todos": open_todos,
2163
+ "blockers": blockers,
2164
+ "can_finish_implementation": not open_todos,
2165
+ }
2166
+
2167
+
2168
+ def _require_todos_complete_for_implementation_finish(
2169
+ workspace_root: Path, task: TaskRecord
2170
+ ) -> None:
2171
+ """Enforce that all todos are done before finishing implementation."""
2172
+ report = _build_todo_gate_report(workspace_root, task)
2173
+ if report["can_finish_implementation"]:
2174
+ return
2175
+ error = LaunchError("Cannot finish implementation because todos are incomplete.")
2176
+ error.taskledger_exit_code = EXIT_CODE_VALIDATION_FAILED
2177
+ error.taskledger_error_code = "IMPLEMENTATION_TODOS_INCOMPLETE"
2178
+ error.taskledger_data = report
2179
+ raise error
2180
+
2181
+
2182
+ def todo_status(workspace_root: Path, task_ref: str) -> dict[str, object]:
2183
+ """Get todo status and progress for a task."""
2184
+ task = resolve_task(workspace_root, task_ref)
2185
+ return _build_todo_gate_report(workspace_root, task)
2186
+
2187
+
2188
+ def next_todo(workspace_root: Path, task_ref: str) -> dict[str, object]:
2189
+ """Get the next unfinished todo for a task."""
2190
+ task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
2191
+ todos = task.todos
2192
+
2193
+ # Prefer active todos first, then first open todo
2194
+ for todo in todos:
2195
+ if not todo.done and hasattr(todo, "status") and todo.status == "active":
2196
+ return _next_todo_payload(task.id, todo)
2197
+
2198
+ for todo in todos:
2199
+ if not todo.done:
2200
+ return _next_todo_payload(task.id, todo)
2201
+
2202
+ return {
2203
+ "kind": "next_todo",
2204
+ "task_id": task.id,
2205
+ "next_todo_id": None,
2206
+ "next_todo": None,
2207
+ "commands": [],
2208
+ "can_finish_implementation": True,
2209
+ }
2210
+
2211
+
2212
+ def finish_implementation(
2213
+ workspace_root: Path,
2214
+ task_ref: str,
2215
+ *,
2216
+ summary: str,
2217
+ ) -> dict[str, object]:
2218
+ task = resolve_task(workspace_root, task_ref)
2219
+ run = _require_running_run(
2220
+ workspace_root,
2221
+ task,
2222
+ task.latest_implementation_run,
2223
+ expected_type="implementation",
2224
+ )
2225
+ _require_todos_complete_for_implementation_finish(workspace_root, task)
2226
+ finished = replace(
2227
+ run,
2228
+ status="finished",
2229
+ finished_at=utc_now_iso(),
2230
+ summary=summary.strip(),
2231
+ )
2232
+ save_run(workspace_root, finished)
2233
+ updated = replace(task, status_stage="implemented", updated_at=utc_now_iso())
2234
+ save_task(workspace_root, updated)
2235
+ _release_lock(
2236
+ workspace_root,
2237
+ task=updated,
2238
+ expected_stage="implementing",
2239
+ run_id=run.run_id,
2240
+ target_stage="implemented",
2241
+ event_name="stage.completed",
2242
+ )
2243
+ _append_event(
2244
+ resolve_v2_paths(workspace_root).project_dir,
2245
+ updated.id,
2246
+ "implementation.finished",
2247
+ {"run_id": run.run_id},
2248
+ )
2249
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
2250
+ return _lifecycle_payload(
2251
+ "implement finish",
2252
+ updated,
2253
+ warnings=[],
2254
+ changed=True,
2255
+ run=finished,
2256
+ )
2257
+
2258
+
2259
+ def start_validation(
2260
+ workspace_root: Path,
2261
+ task_ref: str,
2262
+ *,
2263
+ actor: ActorRef | None = None,
2264
+ harness: HarnessRef | None = None,
2265
+ ) -> dict[str, object]:
2266
+ task = resolve_task(workspace_root, task_ref)
2267
+ if task.status_stage != "implemented":
2268
+ raise _cli_error(
2269
+ "Validation requires implemented state.",
2270
+ EXIT_CODE_INVALID_TRANSITION,
2271
+ )
2272
+ impl_run = _require_run(workspace_root, task, task.latest_implementation_run)
2273
+ if impl_run.run_type != "implementation" or impl_run.status != "finished":
2274
+ raise _cli_error(
2275
+ "Validation requires a finished implementation run.",
2276
+ EXIT_CODE_INVALID_TRANSITION,
2277
+ )
2278
+ run = _start_run(
2279
+ workspace_root,
2280
+ task,
2281
+ run_type="validation",
2282
+ stage="validating",
2283
+ actor=actor,
2284
+ harness=harness,
2285
+ )
2286
+ updated_run = replace(run, based_on_implementation_run=impl_run.run_id)
2287
+ save_run(workspace_root, updated_run)
2288
+ updated = replace(
2289
+ resolve_task(workspace_root, task.id),
2290
+ latest_validation_run=updated_run.run_id,
2291
+ updated_at=utc_now_iso(),
2292
+ )
2293
+ save_task(workspace_root, updated)
2294
+ _append_event(
2295
+ resolve_v2_paths(workspace_root).project_dir,
2296
+ updated.id,
2297
+ "validation.started",
2298
+ {"run_id": updated_run.run_id},
2299
+ )
2300
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
2301
+ return _lifecycle_payload(
2302
+ "validate start",
2303
+ updated,
2304
+ warnings=[],
2305
+ changed=True,
2306
+ run=updated_run,
2307
+ lock=_require_lock(workspace_root, updated.id),
2308
+ )
2309
+
2310
+
2311
+ def _resolve_criterion_ref(plan: PlanRecord, criterion_ref: str) -> str:
2312
+ """Canonicalize criterion reference to the exact ID in the plan.
2313
+
2314
+ Accepts:
2315
+ - exact ID: ac-0001
2316
+ - different case: AC-0001
2317
+ - short AC form: ac-1 (should match ac-0001)
2318
+ - numeric form: 1 (should match ac-0001)
2319
+
2320
+ Raises LaunchError if criterion not found in plan.
2321
+ """
2322
+ if not plan.criteria:
2323
+ raise _cli_error(
2324
+ "No acceptance criteria defined in plan.",
2325
+ EXIT_CODE_BAD_INPUT,
2326
+ )
2327
+
2328
+ normalized_ref = criterion_ref.strip().lower()
2329
+
2330
+ for c in plan.criteria:
2331
+ c_id_lower = c.id.lower()
2332
+
2333
+ if c_id_lower == normalized_ref:
2334
+ return c.id
2335
+
2336
+ parts = c_id_lower.split("-")
2337
+ if len(parts) == 2:
2338
+ prefix, number = parts
2339
+
2340
+ if normalized_ref == f"{prefix}-{number}":
2341
+ return c.id
2342
+
2343
+ ref_parts = normalized_ref.split("-")
2344
+ if len(ref_parts) == 2:
2345
+ ref_prefix, ref_number = ref_parts
2346
+ if ref_prefix == prefix:
2347
+ try:
2348
+ if int(ref_number) == int(number):
2349
+ return c.id
2350
+ except ValueError:
2351
+ pass
2352
+
2353
+ if normalized_ref == number:
2354
+ return c.id
2355
+
2356
+ try:
2357
+ if int(normalized_ref) == int(number):
2358
+ return c.id
2359
+ except ValueError:
2360
+ pass
2361
+
2362
+ criterion_ids = ", ".join(sorted(c.id for c in plan.criteria))
2363
+ raise _cli_error(
2364
+ f"Unknown acceptance criterion: {criterion_ref}.\n"
2365
+ f"Known criteria: {criterion_ids}.",
2366
+ EXIT_CODE_BAD_INPUT,
2367
+ )
2368
+
2369
+
2370
+ def _build_validation_gate_report(
2371
+ workspace_root: Path,
2372
+ task: TaskRecord,
2373
+ run: TaskRunRecord | None = None,
2374
+ ) -> dict[str, object]:
2375
+ from taskledger.services.validation import build_validation_gate_report
2376
+
2377
+ return build_validation_gate_report(workspace_root, task, run)
2378
+
2379
+
2380
+ def validation_status(
2381
+ workspace_root: Path,
2382
+ task_ref: str,
2383
+ *,
2384
+ run_id: str | None = None,
2385
+ ) -> dict[str, object]:
2386
+ """Get validation status report for a task."""
2387
+ task = resolve_task(workspace_root, task_ref)
2388
+ run = None
2389
+ if run_id:
2390
+ from taskledger.storage.task_store import resolve_run
2391
+
2392
+ run = resolve_run(workspace_root, task.id, run_id)
2393
+
2394
+ report = _build_validation_gate_report(workspace_root, task, run)
2395
+ return {"kind": "validation_status", "result": report}
2396
+
2397
+
2398
+ def add_validation_check(
2399
+ workspace_root: Path,
2400
+ task_ref: str,
2401
+ *,
2402
+ name: str | None = None,
2403
+ criterion_id: str | None = None,
2404
+ status: str,
2405
+ details: str | None = None,
2406
+ evidence: tuple[str, ...] = (),
2407
+ ) -> TaskRunRecord:
2408
+ task = resolve_task(workspace_root, task_ref)
2409
+ run = _require_running_run(
2410
+ workspace_root,
2411
+ task,
2412
+ task.latest_validation_run,
2413
+ expected_type="validation",
2414
+ )
2415
+ _enforce_decision(
2416
+ validation_check_decision(
2417
+ task,
2418
+ _lock_for_mutation(workspace_root, task.id),
2419
+ run=run,
2420
+ )
2421
+ )
2422
+ normalized_status = normalize_validation_check_status(status)
2423
+ check_id = f"check-{len(run.checks) + 1:04d}"
2424
+ resolved_criterion = criterion_id.strip() if criterion_id else None
2425
+ if normalized_status != "not_run" and resolved_criterion is None:
2426
+ raise _cli_error(
2427
+ "Validation checks must reference --criterion unless status is not_run.",
2428
+ EXIT_CODE_BAD_INPUT,
2429
+ )
2430
+
2431
+ if resolved_criterion is not None:
2432
+ if task.accepted_plan_version is None:
2433
+ raise _cli_error(
2434
+ "Cannot add criterion check without an accepted plan. "
2435
+ "Accept a plan first with: task accept-plan",
2436
+ EXIT_CODE_BAD_INPUT,
2437
+ )
2438
+ accepted_plan = resolve_plan(
2439
+ workspace_root,
2440
+ task.id,
2441
+ version=task.accepted_plan_version,
2442
+ )
2443
+ resolved_criterion = _resolve_criterion_ref(accepted_plan, resolved_criterion)
2444
+
2445
+ check = ValidationCheck(
2446
+ name=(name or resolved_criterion or check_id).strip(),
2447
+ id=check_id,
2448
+ criterion_id=resolved_criterion,
2449
+ status=normalized_status,
2450
+ details=details.strip() if details else None,
2451
+ evidence=tuple(item.strip() for item in evidence if item.strip()),
2452
+ )
2453
+ updated = replace(run, checks=tuple([*run.checks, check]))
2454
+ save_run(workspace_root, updated)
2455
+ return updated
2456
+
2457
+
2458
+ def waive_criterion(
2459
+ workspace_root: Path,
2460
+ task_ref: str,
2461
+ *,
2462
+ criterion_id: str,
2463
+ reason: str,
2464
+ actor_name: str | None = None,
2465
+ ) -> TaskRunRecord:
2466
+ """Record a criterion waiver for a validation check."""
2467
+ task = resolve_task(workspace_root, task_ref)
2468
+ run = _require_running_run(
2469
+ workspace_root,
2470
+ task,
2471
+ task.latest_validation_run,
2472
+ expected_type="validation",
2473
+ )
2474
+ _enforce_decision(
2475
+ validation_check_decision(
2476
+ task,
2477
+ _lock_for_mutation(workspace_root, task.id),
2478
+ run=run,
2479
+ )
2480
+ )
2481
+
2482
+ if task.accepted_plan_version is None:
2483
+ raise _cli_error(
2484
+ "Cannot waive criterion without an accepted plan.",
2485
+ EXIT_CODE_BAD_INPUT,
2486
+ )
2487
+
2488
+ accepted_plan = resolve_plan(
2489
+ workspace_root,
2490
+ task.id,
2491
+ version=task.accepted_plan_version,
2492
+ )
2493
+ resolved_criterion = _resolve_criterion_ref(accepted_plan, criterion_id)
2494
+
2495
+ if not reason.strip():
2496
+ raise _cli_error("Waiver reason is required.", EXIT_CODE_BAD_INPUT)
2497
+
2498
+ waiver = CriterionWaiver(
2499
+ actor=ActorRef(
2500
+ actor_type="user",
2501
+ actor_name=(actor_name or getpass.getuser() or "user").strip(),
2502
+ tool="manual",
2503
+ ),
2504
+ reason=reason.strip(),
2505
+ )
2506
+
2507
+ check_id = f"check-{len(run.checks) + 1:04d}"
2508
+ check = ValidationCheck(
2509
+ name=resolved_criterion,
2510
+ id=check_id,
2511
+ criterion_id=resolved_criterion,
2512
+ status="pass",
2513
+ waiver=waiver,
2514
+ )
2515
+
2516
+ updated = replace(run, checks=tuple([*run.checks, check]))
2517
+ save_run(workspace_root, updated)
2518
+ return updated
2519
+
2520
+
2521
+ def finish_validation(
2522
+ workspace_root: Path,
2523
+ task_ref: str,
2524
+ *,
2525
+ result: str,
2526
+ summary: str,
2527
+ recommendation: str | None = None,
2528
+ ) -> dict[str, object]:
2529
+ task = resolve_task(workspace_root, task_ref)
2530
+ run = _require_running_run(
2531
+ workspace_root,
2532
+ task,
2533
+ task.latest_validation_run,
2534
+ expected_type="validation",
2535
+ )
2536
+ normalized_result = normalize_validation_result(result)
2537
+ if normalized_result == "passed":
2538
+ _ensure_validation_can_pass(workspace_root, task, run)
2539
+ target_stage: TaskStatusStage = (
2540
+ "done" if normalized_result == "passed" else "failed_validation"
2541
+ )
2542
+ if normalized_result == "passed":
2543
+ run_status = "finished"
2544
+ elif normalized_result == "blocked":
2545
+ run_status = "blocked"
2546
+ else:
2547
+ run_status = "failed"
2548
+ finished = replace(
2549
+ run,
2550
+ status=cast(
2551
+ Literal[
2552
+ "running",
2553
+ "paused",
2554
+ "finished",
2555
+ "passed",
2556
+ "failed",
2557
+ "blocked",
2558
+ "aborted",
2559
+ ],
2560
+ run_status,
2561
+ ),
2562
+ finished_at=utc_now_iso(),
2563
+ summary=summary.strip(),
2564
+ recommendation=recommendation,
2565
+ result=normalized_result,
2566
+ )
2567
+ save_run(workspace_root, finished)
2568
+ updated = replace(task, status_stage=target_stage, updated_at=utc_now_iso())
2569
+ save_task(workspace_root, updated)
2570
+ _release_lock(
2571
+ workspace_root,
2572
+ task=updated,
2573
+ expected_stage="validating",
2574
+ run_id=run.run_id,
2575
+ target_stage=target_stage,
2576
+ event_name="stage.completed"
2577
+ if normalized_result == "passed"
2578
+ else "stage.failed",
2579
+ extra_data={"result": normalized_result},
2580
+ )
2581
+ _append_event(
2582
+ resolve_v2_paths(workspace_root).project_dir,
2583
+ updated.id,
2584
+ "validation.finished",
2585
+ {"run_id": run.run_id, "result": normalized_result},
2586
+ )
2587
+ rebuild_v2_indexes(resolve_v2_paths(workspace_root))
2588
+ return _lifecycle_payload(
2589
+ "validate finish",
2590
+ updated,
2591
+ warnings=[],
2592
+ changed=True,
2593
+ run=finished,
2594
+ result=normalized_result,
2595
+ )
2596
+
2597
+
2598
+ def show_task_run(
2599
+ workspace_root: Path,
2600
+ task_ref: str,
2601
+ *,
2602
+ run_id: str | None = None,
2603
+ run_type: str,
2604
+ ) -> dict[str, object]:
2605
+ task = resolve_task(workspace_root, task_ref)
2606
+ selected_run_id = run_id
2607
+ if selected_run_id is None:
2608
+ if run_type == "implementation":
2609
+ selected_run_id = task.latest_implementation_run
2610
+ elif run_type == "validation":
2611
+ selected_run_id = task.latest_validation_run
2612
+ else:
2613
+ selected_run_id = task.latest_planning_run
2614
+ run = _require_run(workspace_root, task, selected_run_id)
2615
+ if run.run_type != run_type:
2616
+ raise _cli_error(
2617
+ f"Run {run.run_id} is {run.run_type}, not {run_type}.",
2618
+ EXIT_CODE_INVALID_TRANSITION,
2619
+ )
2620
+ return {"kind": "task_run", "task_id": task.id, "run": run.to_dict()}
2621
+
2622
+
2623
+ def show_lock(workspace_root: Path, task_ref: str) -> dict[str, object]:
2624
+ task = resolve_task(workspace_root, task_ref)
2625
+ lock = _current_lock(workspace_root, task.id)
2626
+ return {
2627
+ "kind": "task_lock",
2628
+ "task_id": task.id,
2629
+ "lock": lock.to_dict() if lock is not None else None,
2630
+ "status": lock_status(lock),
2631
+ }
2632
+
2633
+
2634
+ def break_lock(
2635
+ workspace_root: Path,
2636
+ task_ref: str,
2637
+ *,
2638
+ reason: str,
2639
+ ) -> dict[str, object]:
2640
+ task = resolve_task(workspace_root, task_ref)
2641
+ paths = resolve_v2_paths(workspace_root)
2642
+ lock_path = task_lock_path(paths, task.id)
2643
+ lock = read_lock(lock_path)
2644
+ if lock is None:
2645
+ raise _cli_error(
2646
+ "No active lock exists for the task. "
2647
+ "This is normal after plan propose, implement finish, or validate finish. "
2648
+ "Run `taskledger next-action` to see what to do next.",
2649
+ EXIT_CODE_MISSING,
2650
+ )
2651
+ broken_lock = replace(
2652
+ lock,
2653
+ broken_at=utc_now_iso(),
2654
+ broken_by=_default_actor(),
2655
+ broken_reason=reason.strip(),
2656
+ )
2657
+ audit_path = _write_broken_lock_audit(paths, task.id, broken_lock)
2658
+ _append_event(
2659
+ paths.project_dir,
2660
+ task.id,
2661
+ "lock.broken",
2662
+ {
2663
+ "lock_id": lock.lock_id,
2664
+ "reason": reason,
2665
+ "audit_path": str(audit_path.relative_to(paths.project_dir)),
2666
+ },
2667
+ )
2668
+ _append_event(
2669
+ paths.project_dir,
2670
+ task.id,
2671
+ "repair.lock_broken",
2672
+ {
2673
+ "lock_id": lock.lock_id,
2674
+ "reason": reason,
2675
+ "audit_path": str(audit_path.relative_to(paths.project_dir)),
2676
+ },
2677
+ )
2678
+ remove_lock(lock_path)
2679
+ rebuild_v2_indexes(paths)
2680
+ return {
2681
+ "ok": True,
2682
+ "command": "lock break",
2683
+ "task_id": task.id,
2684
+ "status_stage": task.status_stage,
2685
+ "changed": True,
2686
+ "warnings": [],
2687
+ "lock": broken_lock.to_dict(),
2688
+ "reason": reason,
2689
+ "audit_path": str(audit_path.relative_to(paths.project_dir)),
2690
+ }
2691
+
2692
+
2693
+ def list_locks(workspace_root: Path) -> dict[str, object]:
2694
+ locks = load_active_locks(workspace_root)
2695
+ return {
2696
+ "kind": "task_lock_list",
2697
+ "locks": [
2698
+ {
2699
+ **lock.to_dict(),
2700
+ "status": lock_status(lock),
2701
+ }
2702
+ for lock in locks
2703
+ ],
2704
+ }
2705
+
2706
+
2707
+ def next_action(workspace_root: Path, task_ref: str) -> dict[str, object]:
2708
+ from taskledger.services.navigation import next_action as navigation_next_action
2709
+
2710
+ return navigation_next_action(workspace_root, task_ref)
2711
+
2712
+
2713
+ def can_perform(workspace_root: Path, task_ref: str, action: str) -> dict[str, object]:
2714
+ from taskledger.services.navigation import can_perform as navigation_can_perform
2715
+
2716
+ return navigation_can_perform(workspace_root, task_ref, action)
2717
+
2718
+
2719
+ def task_dossier(
2720
+ workspace_root: Path,
2721
+ task_ref: str,
2722
+ *,
2723
+ format_name: str = "markdown",
2724
+ ) -> str | dict[str, object]:
2725
+ from taskledger.services.navigation import task_dossier as navigation_task_dossier
2726
+
2727
+ return navigation_task_dossier(
2728
+ workspace_root,
2729
+ task_ref,
2730
+ format_name=format_name,
2731
+ )
2732
+
2733
+
2734
+ def reindex(workspace_root: Path) -> dict[str, object]:
2735
+ paths = ensure_v2_layout(workspace_root)
2736
+ counts = rebuild_v2_indexes(paths)
2737
+ _append_event(
2738
+ paths.project_dir, "*", "repair.index", dict(cast(dict[str, object], counts))
2739
+ )
2740
+ return {"kind": "taskledger_reindex", "counts": counts}
2741
+
2742
+
2743
+ def repair_task_record(
2744
+ workspace_root: Path,
2745
+ task_ref: str,
2746
+ *,
2747
+ reason: str,
2748
+ ) -> dict[str, object]:
2749
+ if not reason.strip():
2750
+ raise _cli_error("Task repair requires --reason.", EXIT_CODE_BAD_INPUT)
2751
+ task = resolve_task(workspace_root, task_ref)
2752
+ paths = resolve_v2_paths(workspace_root)
2753
+ _append_event(
2754
+ paths.project_dir,
2755
+ task.id,
2756
+ "repair.task",
2757
+ {"reason": reason.strip()},
2758
+ )
2759
+ return {
2760
+ "kind": "task_repair",
2761
+ "task_id": task.id,
2762
+ "changed": False,
2763
+ "reason": reason.strip(),
2764
+ }
2765
+
2766
+
2767
+ def list_events(workspace_root: Path) -> list[dict[str, object]]:
2768
+ events_dir = resolve_v2_paths(workspace_root).events_dir
2769
+ return [item.to_dict() for item in load_events(events_dir)]
2770
+
2771
+
2772
+ def _start_run(
2773
+ workspace_root: Path,
2774
+ task: TaskRecord,
2775
+ *,
2776
+ run_type: str,
2777
+ stage: str,
2778
+ actor: ActorRef | None = None,
2779
+ harness: HarnessRef | None = None,
2780
+ ) -> TaskRunRecord:
2781
+ existing_lock = _current_lock(workspace_root, task.id)
2782
+ if existing_lock is not None:
2783
+ if lock_is_expired(existing_lock):
2784
+ raise _stale_lock_error(task.id, existing_lock)
2785
+ raise _cli_error(
2786
+ _lock_conflict_message(task.id, existing_lock),
2787
+ EXIT_CODE_LOCK_CONFLICT,
2788
+ )
2789
+ running_runs = [
2790
+ item for item in list_runs(workspace_root, task.id) if item.status == "running"
2791
+ ]
2792
+ if running_runs:
2793
+ raise _cli_error(
2794
+ f"Task {task.id} already has a running {running_runs[0].run_type} run.",
2795
+ EXIT_CODE_LOCK_CONFLICT,
2796
+ )
2797
+ resolved_actor = actor or _default_actor()
2798
+ run = TaskRunRecord(
2799
+ run_id=next_project_id(
2800
+ "run",
2801
+ [item.run_id for item in list_runs(workspace_root, task.id)],
2802
+ ),
2803
+ task_id=task.id,
2804
+ run_type=normalize_run_type(run_type),
2805
+ actor=resolved_actor,
2806
+ harness=harness,
2807
+ based_on_plan_version=task.accepted_plan_version or task.latest_plan_version,
2808
+ )
2809
+ save_run(workspace_root, run)
2810
+ _acquire_lock(
2811
+ workspace_root,
2812
+ task=task,
2813
+ stage=stage,
2814
+ run=run,
2815
+ reason={
2816
+ "planning": "plan task",
2817
+ "implementation": "implement approved plan",
2818
+ "validation": "validate implementation",
2819
+ }[run_type],
2820
+ actor=resolved_actor,
2821
+ harness=harness,
2822
+ )
2823
+ return run
2824
+
2825
+
2826
+ def _acquire_lock(
2827
+ workspace_root: Path,
2828
+ *,
2829
+ task: TaskRecord,
2830
+ stage: str,
2831
+ run: TaskRunRecord,
2832
+ reason: str,
2833
+ actor: ActorRef | None = None,
2834
+ harness: HarnessRef | None = None,
2835
+ ) -> TaskLock:
2836
+ if stage not in ACTIVE_TASK_STAGES:
2837
+ raise _cli_error("Only active stages can acquire locks.", EXIT_CODE_BAD_INPUT)
2838
+ paths = resolve_v2_paths(workspace_root)
2839
+ lock_path = task_lock_path(paths, task.id)
2840
+ existing = read_lock(lock_path)
2841
+ if existing is not None:
2842
+ if lock_is_expired(existing):
2843
+ raise _stale_lock_error(task.id, existing)
2844
+ if existing.run_id == run.run_id:
2845
+ return existing
2846
+ raise _cli_error(
2847
+ _lock_conflict_message(task.id, existing), EXIT_CODE_LOCK_CONFLICT
2848
+ )
2849
+ now = datetime.now(timezone.utc)
2850
+ resolved_actor = actor or _default_actor()
2851
+ lock = TaskLock(
2852
+ lock_id=_next_lock_id(workspace_root, now),
2853
+ task_id=task.id,
2854
+ stage=cast(Literal["planning", "implementing", "validating"], stage),
2855
+ run_id=run.run_id,
2856
+ created_at=now.isoformat(),
2857
+ expires_at=(now + timedelta(hours=2)).isoformat(),
2858
+ lease_seconds=7200,
2859
+ last_heartbeat_at=now.isoformat(),
2860
+ reason=reason,
2861
+ holder=resolved_actor,
2862
+ actor=resolved_actor,
2863
+ harness=harness,
2864
+ )
2865
+ try:
2866
+ write_lock(lock_path, lock)
2867
+ except LaunchError as exc:
2868
+ raise _cli_error(
2869
+ _lock_conflict_message(task.id, read_lock(lock_path) or lock),
2870
+ EXIT_CODE_LOCK_CONFLICT,
2871
+ ) from exc
2872
+ _append_event(paths.project_dir, task.id, "lock.acquired", lock.to_dict())
2873
+ _append_event(
2874
+ paths.project_dir,
2875
+ task.id,
2876
+ "stage.entered",
2877
+ {"stage": stage, "run_id": run.run_id},
2878
+ )
2879
+ return lock
2880
+
2881
+
2882
+ def _release_lock(
2883
+ workspace_root: Path,
2884
+ *,
2885
+ task: TaskRecord,
2886
+ expected_stage: str,
2887
+ run_id: str,
2888
+ target_stage: TaskStatusStage,
2889
+ event_name: str,
2890
+ extra_data: dict[str, object] | None = None,
2891
+ delete_only: bool = False,
2892
+ ) -> None:
2893
+ paths = resolve_v2_paths(workspace_root)
2894
+ lock_path = task_lock_path(paths, task.id)
2895
+ lock = read_lock(lock_path)
2896
+ if lock is None:
2897
+ raise _cli_error(
2898
+ f"Task {task.id} has no active {expected_stage} lock to release.",
2899
+ EXIT_CODE_LOCK_CONFLICT,
2900
+ )
2901
+ if lock.stage != expected_stage or lock.run_id != run_id:
2902
+ raise _cli_error(
2903
+ "Active lock does not match the expected stage/run.",
2904
+ EXIT_CODE_LOCK_CONFLICT,
2905
+ )
2906
+ data = {"stage": expected_stage, "run_id": run_id, **(extra_data or {})}
2907
+ _append_event(paths.project_dir, task.id, event_name, data)
2908
+ remove_lock(lock_path)
2909
+ _append_event(
2910
+ paths.project_dir,
2911
+ task.id,
2912
+ "lock.released",
2913
+ {"lock_id": lock.lock_id, "stage": expected_stage},
2914
+ )
2915
+ if delete_only:
2916
+ return
2917
+ save_task(
2918
+ workspace_root,
2919
+ replace(task, status_stage=target_stage, updated_at=utc_now_iso()),
2920
+ )
2921
+
2922
+
2923
+ def _ensure_dependencies_done(workspace_root: Path, task: TaskRecord) -> None:
2924
+ blocked = []
2925
+ for requirement in load_requirements(workspace_root, task.id).requirements:
2926
+ if _has_user_waiver(requirement.waiver):
2927
+ continue
2928
+ required = resolve_task(workspace_root, requirement.task_id)
2929
+ if required.status_stage != "done":
2930
+ blocked.append(required.id)
2931
+ if blocked:
2932
+ raise _cli_error(
2933
+ "Implementation is blocked by incomplete requirements: "
2934
+ + ", ".join(blocked),
2935
+ EXIT_CODE_DEPENDENCY_BLOCKED,
2936
+ )
2937
+
2938
+
2939
+ def _require_lock(workspace_root: Path, task_id: str) -> TaskLock:
2940
+ lock = _current_lock(workspace_root, task_id)
2941
+ if lock is None:
2942
+ raise _cli_error("No active lock found.", EXIT_CODE_LOCK_CONFLICT)
2943
+ if lock_is_expired(lock):
2944
+ raise _stale_lock_error(task_id, lock)
2945
+ return lock
2946
+
2947
+
2948
+ def _require_run(
2949
+ workspace_root: Path,
2950
+ task: TaskRecord,
2951
+ run_id: str | None,
2952
+ ) -> TaskRunRecord:
2953
+ if run_id is None:
2954
+ raise _cli_error("No active run is recorded for the task.", EXIT_CODE_MISSING)
2955
+ return resolve_run(workspace_root, task.id, run_id)
2956
+
2957
+
2958
+ def _optional_run(
2959
+ workspace_root: Path,
2960
+ task: TaskRecord,
2961
+ run_id: str | None,
2962
+ ) -> TaskRunRecord | None:
2963
+ if run_id is None:
2964
+ return None
2965
+ try:
2966
+ return resolve_run(workspace_root, task.id, run_id)
2967
+ except LaunchError:
2968
+ return None
2969
+
2970
+
2971
+ def _require_running_run(
2972
+ workspace_root: Path,
2973
+ task: TaskRecord,
2974
+ run_id: str | None,
2975
+ *,
2976
+ expected_type: str,
2977
+ ) -> TaskRunRecord:
2978
+ run = _require_run(workspace_root, task, run_id)
2979
+ if run.run_type != expected_type or run.status != "running":
2980
+ raise _cli_error(
2981
+ f"Task does not have a running {expected_type} run.",
2982
+ EXIT_CODE_INVALID_TRANSITION,
2983
+ )
2984
+ return run
2985
+
2986
+
2987
+ def _current_lock(workspace_root: Path, task_id: str) -> TaskLock | None:
2988
+ return read_lock(task_lock_path(resolve_v2_paths(workspace_root), task_id))
2989
+
2990
+
2991
+ def _lock_for_mutation(workspace_root: Path, task_id: str) -> TaskLock | None:
2992
+ lock = _current_lock(workspace_root, task_id)
2993
+ if lock is not None and lock_is_expired(lock):
2994
+ raise _stale_lock_error(task_id, lock)
2995
+ return lock
2996
+
2997
+
2998
+ def _dependency_blockers(
2999
+ workspace_root: Path, task: TaskRecord
3000
+ ) -> list[dict[str, str]]:
3001
+ blockers: list[dict[str, str]] = []
3002
+ for requirement in load_requirements(workspace_root, task.id).requirements:
3003
+ if _has_user_waiver(requirement.waiver):
3004
+ continue
3005
+ required = resolve_task(workspace_root, requirement.task_id)
3006
+ if required.status_stage != "done":
3007
+ blockers.append(
3008
+ {
3009
+ "kind": "dependency",
3010
+ "message": (
3011
+ f"Requirement {required.id} is still {required.status_stage}."
3012
+ ),
3013
+ }
3014
+ )
3015
+ return blockers
3016
+
3017
+
3018
+ def _lock_conflict_message(task_id: str, lock: TaskLock) -> str:
3019
+ if lock_is_expired(lock):
3020
+ return (
3021
+ f"Task {task_id} has an expired {lock.stage} lock from {lock.run_id}. "
3022
+ "Break it explicitly with: "
3023
+ f'taskledger lock break --task {task_id} --reason "..."'
3024
+ )
3025
+ return f"Task {task_id} is locked by {lock.run_id} for {lock.stage}."
3026
+
3027
+
3028
+ def _enforce_decision(decision: Decision) -> None:
3029
+ if decision.ok:
3030
+ return
3031
+ raise _cli_error(decision.reason, decision.exit_code)
3032
+
3033
+
3034
+ def _default_actor() -> ActorRef:
3035
+ return ActorRef(
3036
+ actor_type="agent",
3037
+ actor_name=getpass.getuser() or "taskledger",
3038
+ host=socket.gethostname(),
3039
+ pid=os.getpid(),
3040
+ )
3041
+
3042
+
3043
+ def _default_harness() -> HarnessRef:
3044
+ return HarnessRef(
3045
+ harness_id="harness-unknown",
3046
+ name=os.getenv("TASKLEDGER_HARNESS") or "unknown",
3047
+ kind="unknown",
3048
+ session_id=os.getenv("TASKLEDGER_SESSION_ID"),
3049
+ working_directory=os.getcwd(),
3050
+ )
3051
+
3052
+
3053
+ def _append_event(
3054
+ project_dir: Path,
3055
+ task_id: str,
3056
+ event_name: str,
3057
+ data: dict[str, object],
3058
+ ) -> None:
3059
+ timestamp = utc_now_iso()
3060
+ append_event(
3061
+ project_dir / "events",
3062
+ TaskEvent(
3063
+ ts=timestamp,
3064
+ event=event_name,
3065
+ task_id=task_id,
3066
+ actor=_default_actor(),
3067
+ harness=_default_harness(),
3068
+ event_id=next_event_id(project_dir / "events", timestamp),
3069
+ data=data,
3070
+ ),
3071
+ )
3072
+
3073
+
3074
+ def _summary_line(text: str | None) -> str | None:
3075
+ if text is None:
3076
+ return None
3077
+ stripped = " ".join(text.split())
3078
+ return stripped[:117] + "..." if len(stripped) > 120 else stripped
3079
+
3080
+
3081
+ def _git_change_state(workspace_root: Path) -> dict[str, str]:
3082
+ inside = _run_command(
3083
+ workspace_root,
3084
+ ("git", "rev-parse", "--is-inside-work-tree"),
3085
+ not_git_message="Git change scan requires a Git work tree.",
3086
+ )
3087
+ if inside.strip() != "true":
3088
+ raise _cli_error(
3089
+ "Git change scan requires a Git work tree.", EXIT_CODE_BAD_INPUT
3090
+ )
3091
+ branch = _run_command(workspace_root, ("git", "branch", "--show-current")).strip()
3092
+ status = _run_command(workspace_root, ("git", "status", "--short")).strip()
3093
+ diff_stat = _run_command(workspace_root, ("git", "diff", "--stat")).strip()
3094
+ return {
3095
+ "branch": branch or "(detached)",
3096
+ "status": status,
3097
+ "diff_stat": diff_stat,
3098
+ }
3099
+
3100
+
3101
+ def _run_command(
3102
+ workspace_root: Path,
3103
+ argv: tuple[str, ...],
3104
+ *,
3105
+ not_git_message: str | None = None,
3106
+ ) -> str:
3107
+ completed = subprocess.run(
3108
+ list(argv),
3109
+ cwd=workspace_root,
3110
+ capture_output=True,
3111
+ text=True,
3112
+ check=False,
3113
+ )
3114
+ if completed.returncode == 0:
3115
+ return completed.stdout
3116
+ if not_git_message and "not a git repository" in completed.stderr.lower():
3117
+ raise _cli_error(not_git_message, EXIT_CODE_BAD_INPUT)
3118
+ raise _cli_error(
3119
+ completed.stderr.strip() or f"Command failed: {' '.join(argv)}",
3120
+ EXIT_CODE_GENERIC_FAILURE,
3121
+ )
3122
+
3123
+
3124
+ def _command_output(
3125
+ argv: tuple[str, ...],
3126
+ stdout: str,
3127
+ stderr: str,
3128
+ ) -> str:
3129
+ return (
3130
+ f"$ {shlex.join(argv)}\n\n"
3131
+ f"stdout:\n{stdout or '(empty)'}\n\n"
3132
+ f"stderr:\n{stderr or '(empty)'}\n"
3133
+ )
3134
+
3135
+
3136
+ def _command_summary(
3137
+ argv: tuple[str, ...],
3138
+ exit_code: int,
3139
+ artifact_ref: str | None,
3140
+ ) -> str:
3141
+ summary = f"Ran {shlex.join(argv)} (exit {exit_code})"
3142
+ if artifact_ref is not None:
3143
+ summary += f" output: @{artifact_ref}"
3144
+ return summary
3145
+
3146
+
3147
+ def _write_command_artifact(
3148
+ workspace_root: Path,
3149
+ task_id: str,
3150
+ run_id: str,
3151
+ output: str,
3152
+ ) -> str:
3153
+ paths = resolve_v2_paths(workspace_root)
3154
+ artifact_dir = task_artifacts_dir(paths, task_id)
3155
+ artifact_dir.mkdir(parents=True, exist_ok=True)
3156
+ index = len(list(artifact_dir.glob(f"{run_id}-command-*.log"))) + 1
3157
+ artifact_path = artifact_dir / f"{run_id}-command-{index:04d}.log"
3158
+ atomic_write_text(artifact_path, output)
3159
+ return str(artifact_path.relative_to(paths.project_dir))
3160
+
3161
+
3162
+ def _parse_plan_front_matter(body: str) -> tuple[dict[str, object], str]:
3163
+ lines = body.splitlines()
3164
+ if not lines or lines[0].strip() != "---":
3165
+ return {}, body
3166
+ for index in range(1, len(lines)):
3167
+ if lines[index].strip() != "---":
3168
+ continue
3169
+ front_matter = yaml.safe_load("\n".join(lines[1:index])) or {}
3170
+ if not isinstance(front_matter, dict):
3171
+ raise _cli_error(
3172
+ "Plan front matter must be a YAML mapping.",
3173
+ EXIT_CODE_BAD_INPUT,
3174
+ )
3175
+ return front_matter, "\n".join(lines[index + 1 :])
3176
+ raise _cli_error("Unterminated plan front matter.", EXIT_CODE_BAD_INPUT)
3177
+
3178
+
3179
+ def _criteria_from_plan_input(
3180
+ front_matter: dict[str, object],
3181
+ criteria: tuple[str, ...],
3182
+ ) -> tuple[AcceptanceCriterion, ...]:
3183
+ raw_criteria = front_matter.get("acceptance_criteria", front_matter.get("criteria"))
3184
+ items: list[AcceptanceCriterion] = []
3185
+ if raw_criteria is not None:
3186
+ if not isinstance(raw_criteria, list):
3187
+ raise _cli_error(
3188
+ "Plan criteria front matter must be a list.",
3189
+ EXIT_CODE_BAD_INPUT,
3190
+ )
3191
+ for index, item in enumerate(raw_criteria, start=1):
3192
+ if isinstance(item, str):
3193
+ text = item.strip()
3194
+ if not text:
3195
+ continue
3196
+ items.append(AcceptanceCriterion(id=_criterion_id(index), text=text))
3197
+ continue
3198
+ if not isinstance(item, dict):
3199
+ raise _cli_error(
3200
+ "Plan criteria must be strings or mappings.",
3201
+ EXIT_CODE_BAD_INPUT,
3202
+ )
3203
+ text = str(item.get("text") or "").strip()
3204
+ if not text:
3205
+ # Accept single-key shorthand: {ac-0001: "some text"}
3206
+ if len(item) == 1:
3207
+ criterion_key, text_value = next(iter(item.items()))
3208
+ text = str(text_value).strip()
3209
+ if not text:
3210
+ raise _cli_error(
3211
+ "Plan criteria mappings must include non-empty text.",
3212
+ EXIT_CODE_BAD_INPUT,
3213
+ )
3214
+ items.append(
3215
+ AcceptanceCriterion(
3216
+ id=str(criterion_key).strip(),
3217
+ text=text,
3218
+ mandatory=True,
3219
+ )
3220
+ )
3221
+ continue
3222
+ raise _cli_error(
3223
+ "Plan criteria mappings must include text.",
3224
+ EXIT_CODE_BAD_INPUT,
3225
+ )
3226
+ criterion_id = str(item.get("id") or _criterion_id(index)).strip()
3227
+ items.append(
3228
+ AcceptanceCriterion(
3229
+ id=criterion_id,
3230
+ text=text,
3231
+ mandatory=bool(item.get("mandatory", True)),
3232
+ )
3233
+ )
3234
+ else:
3235
+ for index, item in enumerate(criteria, start=1):
3236
+ text = item.strip()
3237
+ if text:
3238
+ items.append(AcceptanceCriterion(id=_criterion_id(index), text=text))
3239
+ ids = [item.id for item in items]
3240
+ if len(ids) != len(set(ids)):
3241
+ raise _cli_error("Plan criteria ids must be unique.", EXIT_CODE_BAD_INPUT)
3242
+ return tuple(items)
3243
+
3244
+
3245
+ def _todos_from_plan_input(front_matter: dict[str, object]) -> tuple[TaskTodo, ...]:
3246
+ raw_todos = front_matter.get("todos")
3247
+ if raw_todos is None:
3248
+ return ()
3249
+ if not isinstance(raw_todos, list):
3250
+ raise _cli_error("Plan todos front matter must be a list.", EXIT_CODE_BAD_INPUT)
3251
+ items: list[TaskTodo] = []
3252
+ for index, item in enumerate(raw_todos, start=1):
3253
+ if isinstance(item, str):
3254
+ text = item.strip()
3255
+ if not text:
3256
+ continue
3257
+ items.append(
3258
+ TaskTodo(
3259
+ id=f"plan-todo-{index:04d}",
3260
+ text=text,
3261
+ mandatory=True,
3262
+ source="plan",
3263
+ )
3264
+ )
3265
+ continue
3266
+ if not isinstance(item, dict):
3267
+ raise _cli_error(
3268
+ "Plan todos must be strings or mappings.",
3269
+ EXIT_CODE_BAD_INPUT,
3270
+ )
3271
+ text = str(item.get("text") or "").strip()
3272
+ if not text:
3273
+ raise _cli_error(
3274
+ "Plan todo mappings must include text.",
3275
+ EXIT_CODE_BAD_INPUT,
3276
+ )
3277
+ items.append(
3278
+ TaskTodo(
3279
+ id=str(
3280
+ item.get("id") or item.get("id_hint") or f"plan-todo-{index:04d}"
3281
+ ),
3282
+ text=text,
3283
+ mandatory=bool(item.get("mandatory", True)),
3284
+ source="plan",
3285
+ validation_hint=_optional_string_value(item.get("validation_hint")),
3286
+ )
3287
+ )
3288
+ return tuple(items)
3289
+
3290
+
3291
+ def _answer_snapshot_hash(questions: list[QuestionRecord]) -> str | None:
3292
+ answered = [
3293
+ f"{item.id}\0{item.answer or ''}"
3294
+ for item in questions
3295
+ if item.status == "answered"
3296
+ ]
3297
+ if not answered:
3298
+ return None
3299
+ digest = hashlib.sha256("\n".join(sorted(answered)).encode("utf-8")).hexdigest()
3300
+ return f"sha256:{digest}"
3301
+
3302
+
3303
+ def _required_open_question_ids(questions: list[QuestionRecord]) -> list[str]:
3304
+ return [
3305
+ item.id
3306
+ for item in questions
3307
+ if item.status == "open" and item.required_for_plan
3308
+ ]
3309
+
3310
+
3311
+ def _latest_plan_or_none(workspace_root: Path, task_id: str) -> PlanRecord | None:
3312
+ plans = list_plans(workspace_root, task_id)
3313
+ return plans[-1] if plans else None
3314
+
3315
+
3316
+ def _stale_answer_question_ids(
3317
+ questions: list[QuestionRecord],
3318
+ plan: PlanRecord,
3319
+ ) -> list[str]:
3320
+ answered = [
3321
+ item
3322
+ for item in questions
3323
+ if item.status == "answered" and item.required_for_plan
3324
+ ]
3325
+ if not answered:
3326
+ return []
3327
+ current_hash = _answer_snapshot_hash(questions)
3328
+ if (
3329
+ plan.generation_reason == "after_questions"
3330
+ and plan.based_on_answer_hash == current_hash
3331
+ ):
3332
+ return []
3333
+ return [item.id for item in answered]
3334
+
3335
+
3336
+ def _question_next_item(question: QuestionRecord) -> dict[str, object]:
3337
+ return {
3338
+ "kind": "question",
3339
+ "id": question.id,
3340
+ "text": question.question,
3341
+ "status": question.status,
3342
+ "required_for_plan": question.required_for_plan,
3343
+ "plan_version": question.plan_version,
3344
+ }
3345
+
3346
+
3347
+ def _answered_question_next_item(question: QuestionRecord) -> dict[str, object]:
3348
+ return {
3349
+ "kind": "answered_question",
3350
+ "id": question.id,
3351
+ "text": question.question,
3352
+ "status": question.status,
3353
+ "answer": question.answer,
3354
+ "answered_at": question.answered_at,
3355
+ "required_for_plan": question.required_for_plan,
3356
+ "plan_version": question.plan_version,
3357
+ }
3358
+
3359
+
3360
+ def _todo_next_item(todo: TaskTodo) -> dict[str, object]:
3361
+ return {
3362
+ "kind": "todo",
3363
+ "id": todo.id,
3364
+ "text": todo.text,
3365
+ "status": todo.status,
3366
+ "mandatory": todo.mandatory,
3367
+ "source": todo.source,
3368
+ "done": todo.done,
3369
+ "validation_hint": todo.validation_hint,
3370
+ "done_command_hint": _todo_done_command(todo.id),
3371
+ }
3372
+
3373
+
3374
+ def _next_todo_payload(task_id: str, todo: TaskTodo) -> dict[str, object]:
3375
+ return {
3376
+ "kind": "next_todo",
3377
+ "task_id": task_id,
3378
+ "next_todo_id": todo.id,
3379
+ "next_todo": todo.to_dict(),
3380
+ "commands": _todo_command_hints(todo.id),
3381
+ "can_finish_implementation": False,
3382
+ }
3383
+
3384
+
3385
+ def _criterion_next_item(criterion_report: Mapping[str, object]) -> dict[str, object]:
3386
+ return {
3387
+ "kind": "criterion",
3388
+ "id": criterion_report.get("id"),
3389
+ "text": criterion_report.get("text"),
3390
+ "mandatory": criterion_report.get("mandatory"),
3391
+ "latest_status": criterion_report.get("latest_status"),
3392
+ "satisfied": criterion_report.get("satisfied"),
3393
+ }
3394
+
3395
+
3396
+ def _plan_next_item(plan: PlanRecord) -> dict[str, object]:
3397
+ return {
3398
+ "kind": "plan",
3399
+ "id": f"plan-v{plan.plan_version}",
3400
+ "version": plan.plan_version,
3401
+ "status": plan.status,
3402
+ }
3403
+
3404
+
3405
+ def _task_next_item(task: TaskRecord) -> dict[str, object]:
3406
+ return {
3407
+ "kind": "task",
3408
+ "id": task.id,
3409
+ "status_stage": task.status_stage,
3410
+ }
3411
+
3412
+
3413
+ def _lock_next_item(task: TaskRecord, lock: TaskLock) -> dict[str, object]:
3414
+ return {
3415
+ "kind": "lock",
3416
+ "id": lock.lock_id,
3417
+ "task_id": task.id,
3418
+ "stage": lock.stage,
3419
+ "run_id": lock.run_id,
3420
+ "expired": lock_is_expired(lock),
3421
+ }
3422
+
3423
+
3424
+ def _command(
3425
+ kind: str,
3426
+ label: str,
3427
+ command: str,
3428
+ *,
3429
+ primary: bool = False,
3430
+ ) -> dict[str, object]:
3431
+ return {
3432
+ "kind": kind,
3433
+ "label": label,
3434
+ "command": command,
3435
+ "primary": primary,
3436
+ }
3437
+
3438
+
3439
+ def _todo_done_command(todo_id: str) -> str:
3440
+ return f'taskledger todo done {todo_id} --evidence "..."'
3441
+
3442
+
3443
+ def _todo_command_hints(todo_id: str) -> list[dict[str, object]]:
3444
+ return [
3445
+ _command(
3446
+ "inspect",
3447
+ "Show next todo",
3448
+ f"taskledger todo show {todo_id}",
3449
+ primary=True,
3450
+ ),
3451
+ _command(
3452
+ "complete",
3453
+ "Mark todo done after evidence exists",
3454
+ _todo_done_command(todo_id),
3455
+ ),
3456
+ ]
3457
+
3458
+
3459
+ def _first_question_by_ids(
3460
+ questions: Sequence[QuestionRecord],
3461
+ ids: Sequence[str],
3462
+ ) -> QuestionRecord | None:
3463
+ wanted = set(ids)
3464
+ for question in questions:
3465
+ if question.id in wanted:
3466
+ return question
3467
+ return None
3468
+
3469
+
3470
+ def _first_open_todo_from_report(
3471
+ workspace_root: Path,
3472
+ task: TaskRecord,
3473
+ open_ids: Sequence[str],
3474
+ ) -> TaskTodo | None:
3475
+ task = _task_with_sidecars(workspace_root, task)
3476
+ wanted = set(open_ids)
3477
+ for todo in task.todos:
3478
+ if todo.id in wanted and todo.status == "active" and not todo.done:
3479
+ return todo
3480
+ for todo in task.todos:
3481
+ if todo.id in wanted and not todo.done:
3482
+ return todo
3483
+ return None
3484
+
3485
+
3486
+ def _criterion_report_by_id(
3487
+ gate_report: Mapping[str, object],
3488
+ criterion_id: str,
3489
+ ) -> dict[str, object] | None:
3490
+ criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
3491
+ for criterion in criteria:
3492
+ if criterion.get("id") == criterion_id:
3493
+ return criterion
3494
+ return None
3495
+
3496
+
3497
+ def _compact_next_action_blockers(
3498
+ blockers: Sequence[Mapping[str, object]],
3499
+ ) -> list[dict[str, object]]:
3500
+ compact: list[dict[str, object]] = []
3501
+ for blocker in blockers:
3502
+ item: dict[str, object] = {
3503
+ "kind": str(blocker.get("kind", "blocker")),
3504
+ "message": str(blocker.get("message", "Next-action blocker")),
3505
+ }
3506
+ ref = blocker.get("ref")
3507
+ if isinstance(ref, str) and ref:
3508
+ item["ref"] = ref
3509
+ command_hint = _optional_string_value(blocker.get("command_hint"))
3510
+ if command_hint is not None:
3511
+ item["command_hint"] = command_hint
3512
+ compact.append(item)
3513
+ return compact
3514
+
3515
+
3516
+ def _validation_progress(gate_report: Mapping[str, object]) -> dict[str, object]:
3517
+ criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
3518
+ satisfied = sum(1 for criterion in criteria if criterion.get("satisfied") is True)
3519
+ blocking_ids: list[str] = []
3520
+ for blocker in cast(list[dict[str, object]], gate_report.get("blockers", [])):
3521
+ ref = blocker.get("ref")
3522
+ if isinstance(ref, str) and ref and ref not in blocking_ids:
3523
+ blocking_ids.append(ref)
3524
+ return {
3525
+ "total": len(criteria),
3526
+ "satisfied": satisfied,
3527
+ "remaining": max(len(blocking_ids), len(criteria) - satisfied),
3528
+ "blocking_ids": blocking_ids,
3529
+ }
3530
+
3531
+
3532
+ def _next_validation_item(
3533
+ workspace_root: Path,
3534
+ task: TaskRecord,
3535
+ gate_report: Mapping[str, object],
3536
+ blockers: Sequence[Mapping[str, object]],
3537
+ ) -> dict[str, object] | None:
3538
+ priority = (
3539
+ "criterion_fail",
3540
+ "criterion_missing",
3541
+ "criterion_unsatisfied",
3542
+ "todo_open",
3543
+ "no_finished_implementation",
3544
+ "dependency_blocker",
3545
+ "no_accepted_plan",
3546
+ "plan_not_accepted",
3547
+ )
3548
+ for kind in priority:
3549
+ for blocker in blockers:
3550
+ if blocker.get("kind") != kind:
3551
+ continue
3552
+ ref = blocker.get("ref")
3553
+ if kind.startswith("criterion_") and isinstance(ref, str):
3554
+ criterion = _criterion_report_by_id(gate_report, ref)
3555
+ if criterion is not None:
3556
+ return _criterion_next_item(criterion)
3557
+ if kind == "todo_open" and isinstance(ref, str):
3558
+ todo = _first_open_todo_from_report(workspace_root, task, (ref,))
3559
+ if todo is not None:
3560
+ return _todo_next_item(todo)
3561
+ if kind == "dependency_blocker" and isinstance(ref, str):
3562
+ return {"kind": "dependency", "id": ref}
3563
+ if kind == "no_finished_implementation":
3564
+ return _task_next_item(task)
3565
+ if kind in {"no_accepted_plan", "plan_not_accepted"}:
3566
+ plan = _latest_plan_or_none(workspace_root, task.id)
3567
+ if plan is not None:
3568
+ return _plan_next_item(plan)
3569
+ return _task_next_item(task)
3570
+
3571
+ for criterion in cast(list[dict[str, object]], gate_report.get("criteria", [])):
3572
+ criterion_blockers = criterion.get("blockers")
3573
+ if isinstance(criterion_blockers, list) and criterion_blockers:
3574
+ return _criterion_next_item(criterion)
3575
+ return None
3576
+
3577
+
3578
+ def _next_action_command(action: str) -> str | None:
3579
+ return {
3580
+ "plan": "taskledger plan start",
3581
+ "plan-propose": "taskledger plan upsert --file plan.md",
3582
+ "question-answer": "taskledger question answer-many --file answers.yaml",
3583
+ "plan-regenerate": "taskledger plan upsert --from-answers --file plan.md",
3584
+ "plan-approve": "taskledger plan approve --version VERSION --actor user",
3585
+ "implement": "taskledger implement start",
3586
+ "todo-work": "taskledger implement checklist",
3587
+ "implement-finish": "taskledger implement finish --summary SUMMARY",
3588
+ "validate": "taskledger validate start",
3589
+ "validate-check": (
3590
+ "taskledger validate check --criterion CRITERION "
3591
+ '--status pass --evidence "..."'
3592
+ ),
3593
+ "validate-finish": (
3594
+ "taskledger validate finish --result passed --summary SUMMARY"
3595
+ ),
3596
+ "repair-lock": "taskledger lock show",
3597
+ }.get(action)
3598
+
3599
+
3600
+ def _primary_command_for_next_item(
3601
+ action: str,
3602
+ next_item: dict[str, object] | None,
3603
+ ) -> str | None:
3604
+ if not next_item:
3605
+ return _next_action_command(action)
3606
+
3607
+ kind = next_item.get("kind")
3608
+ item_id = next_item.get("id")
3609
+
3610
+ if kind == "question" and isinstance(item_id, str):
3611
+ return f'taskledger question answer {item_id} --text "..."'
3612
+ if kind == "todo" and isinstance(item_id, str):
3613
+ return f"taskledger todo show {item_id}"
3614
+ if kind == "criterion" and isinstance(item_id, str):
3615
+ return (
3616
+ f"taskledger validate check --criterion {item_id} "
3617
+ '--status pass --evidence "..."'
3618
+ )
3619
+ if kind == "plan":
3620
+ version = next_item.get("version")
3621
+ if isinstance(version, int):
3622
+ return f"taskledger plan show --version {version}"
3623
+ if kind == "lock":
3624
+ task_id = next_item.get("task_id")
3625
+ if isinstance(task_id, str):
3626
+ return f'taskledger lock break --task {task_id} --reason "..."'
3627
+
3628
+ return _next_action_command(action)
3629
+
3630
+
3631
+ def _commands_for_next_item(
3632
+ action: str,
3633
+ next_item: dict[str, object] | None,
3634
+ ) -> list[dict[str, object]]:
3635
+ if next_item is None:
3636
+ primary = _primary_command_for_next_item(action, next_item)
3637
+ if primary is None:
3638
+ return []
3639
+ label = {
3640
+ "plan": "Start planning",
3641
+ "plan-propose": "Propose plan",
3642
+ "plan-regenerate": "Regenerate plan from answers",
3643
+ "plan-approve": "Approve plan",
3644
+ "implement": "Start implementation",
3645
+ "todo-work": "Show implementation checklist",
3646
+ "implement-finish": "Finish implementation",
3647
+ "validate": "Start validation",
3648
+ "validate-check": "Record validation check",
3649
+ "validate-finish": "Finish validation",
3650
+ "repair-lock": "Show current lock",
3651
+ }.get(action, "Show next action")
3652
+ command_kind = {
3653
+ "plan": "start",
3654
+ "plan-propose": "regenerate",
3655
+ "plan-regenerate": "regenerate",
3656
+ "plan-approve": "approve",
3657
+ "implement": "start",
3658
+ "todo-work": "context",
3659
+ "implement-finish": "finish",
3660
+ "validate": "start",
3661
+ "validate-check": "check",
3662
+ "validate-finish": "finish",
3663
+ "repair-lock": "inspect",
3664
+ }.get(action, "context")
3665
+ return [_command(command_kind, label, primary, primary=True)]
3666
+
3667
+ item_kind = next_item.get("kind")
3668
+ item_id = next_item.get("id")
3669
+ if item_kind == "question" and isinstance(item_id, str):
3670
+ return [
3671
+ _command(
3672
+ "answer",
3673
+ "Answer required question",
3674
+ f'taskledger question answer {item_id} --text "..."',
3675
+ primary=True,
3676
+ ),
3677
+ _command("context", "Show question status", "taskledger question status"),
3678
+ ]
3679
+ if item_kind == "answered_question":
3680
+ return [
3681
+ _command(
3682
+ "regenerate",
3683
+ "Regenerate plan from answers",
3684
+ "taskledger plan upsert --from-answers --file plan.md",
3685
+ primary=True,
3686
+ ),
3687
+ _command(
3688
+ "context",
3689
+ "Show answered questions",
3690
+ "taskledger question answers",
3691
+ ),
3692
+ ]
3693
+ if item_kind == "todo" and isinstance(item_id, str):
3694
+ return [
3695
+ *_todo_command_hints(item_id),
3696
+ _command(
3697
+ "context",
3698
+ "Show implementation checklist",
3699
+ "taskledger implement checklist",
3700
+ ),
3701
+ ]
3702
+ if item_kind == "criterion" and isinstance(item_id, str):
3703
+ return [
3704
+ _command(
3705
+ "check",
3706
+ "Record validation check",
3707
+ (
3708
+ f"taskledger validate check --criterion {item_id} "
3709
+ '--status pass --evidence "..."'
3710
+ ),
3711
+ primary=True,
3712
+ ),
3713
+ _command("context", "Show validation status", "taskledger validate status"),
3714
+ ]
3715
+ if item_kind == "plan":
3716
+ version = next_item.get("version")
3717
+ if isinstance(version, int):
3718
+ commands = [
3719
+ _command(
3720
+ "inspect",
3721
+ "Show proposed plan",
3722
+ f"taskledger plan show --version {version}",
3723
+ primary=True,
3724
+ )
3725
+ ]
3726
+ if action == "plan-approve":
3727
+ commands.append(
3728
+ _command(
3729
+ "approve",
3730
+ "Approve plan",
3731
+ f"taskledger plan approve --version {version} --actor user",
3732
+ )
3733
+ )
3734
+ return commands
3735
+ if item_kind == "lock":
3736
+ task_id = next_item.get("task_id")
3737
+ if isinstance(task_id, str):
3738
+ return [
3739
+ _command(
3740
+ "repair",
3741
+ "Break stale lock",
3742
+ f'taskledger lock break --task {task_id} --reason "..."',
3743
+ primary=True,
3744
+ ),
3745
+ _command("inspect", "Show current lock", "taskledger lock show"),
3746
+ ]
3747
+
3748
+ primary = _primary_command_for_next_item(action, next_item)
3749
+ if primary is None:
3750
+ return []
3751
+ label = {
3752
+ "implement": "Start implementation",
3753
+ "implement-finish": "Finish implementation",
3754
+ "validate": "Start validation",
3755
+ "validate-finish": "Finish validation",
3756
+ }.get(action, "Show next action")
3757
+ kind_name = {
3758
+ "implement": "start",
3759
+ "implement-finish": "finish",
3760
+ "validate": "start",
3761
+ "validate-finish": "finish",
3762
+ }.get(action, "context")
3763
+ commands = [_command(kind_name, label, primary, primary=True)]
3764
+ if action == "implement-finish":
3765
+ commands.append(
3766
+ _command(
3767
+ "context",
3768
+ "Show implementation checklist",
3769
+ "taskledger implement checklist",
3770
+ )
3771
+ )
3772
+ if action == "validate-finish":
3773
+ commands.append(
3774
+ _command(
3775
+ "context",
3776
+ "Show validation status",
3777
+ "taskledger validate status",
3778
+ )
3779
+ )
3780
+ return commands
3781
+
3782
+
3783
+ def _normalize_todo_text(text: str) -> str:
3784
+ return " ".join(text.casefold().split())
3785
+
3786
+
3787
+ def _optional_front_matter_string(
3788
+ front_matter: dict[str, object],
3789
+ key: str,
3790
+ ) -> str | None:
3791
+ return _optional_string_value(front_matter.get(key))
3792
+
3793
+
3794
+ def _optional_string_value(value: object) -> str | None:
3795
+ return value if isinstance(value, str) and value.strip() else None
3796
+
3797
+
3798
+ def _string_tuple_from_front_matter(
3799
+ front_matter: dict[str, object], key: str
3800
+ ) -> tuple[str, ...]:
3801
+ raw = front_matter.get(key)
3802
+ if raw is None:
3803
+ return ()
3804
+ if not isinstance(raw, list):
3805
+ raise _cli_error(
3806
+ f"Plan front matter '{key}' must be a list.", EXIT_CODE_BAD_INPUT
3807
+ )
3808
+ items: list[str] = []
3809
+ for item in raw:
3810
+ if not isinstance(item, str) or not item.strip():
3811
+ raise _cli_error(
3812
+ f"Plan front matter '{key}' must contain non-empty strings.",
3813
+ EXIT_CODE_BAD_INPUT,
3814
+ )
3815
+ items.append(item.strip())
3816
+ return tuple(items)
3817
+
3818
+
3819
+ def _criterion_id(index: int) -> str:
3820
+ return f"ac-{index:04d}"
3821
+
3822
+
3823
+ def _normalize_local_id(ref: str, prefix: str) -> str:
3824
+ raw_prefix = f"{prefix}-"
3825
+ if not ref.startswith(raw_prefix):
3826
+ return ref
3827
+ suffix = ref.removeprefix(raw_prefix)
3828
+ if not suffix.isdigit():
3829
+ return ref
3830
+ return f"{prefix}-{int(suffix):04d}"
3831
+
3832
+
3833
+ def _next_lock_id(workspace_root: Path, now: datetime) -> str:
3834
+ paths = resolve_v2_paths(workspace_root)
3835
+ prefix = now.strftime("lock-%Y%m%dT%H%M%SZ")
3836
+ existing = [item.lock_id for item in load_active_locks(workspace_root)]
3837
+ existing.extend(
3838
+ path.stem.removeprefix("broken-")
3839
+ for path in paths.tasks_dir.glob("task-*/audit/broken-lock-*.yaml")
3840
+ )
3841
+ sequence = sum(1 for item in existing if item.startswith(prefix)) + 1
3842
+ return f"{prefix}-{sequence:04d}"
3843
+
3844
+
3845
+ def _has_user_waiver(waiver: DependencyWaiver | None) -> bool:
3846
+ return waiver is not None and waiver.actor.actor_type == "user"
3847
+
3848
+
3849
+ def _ensure_validation_can_pass(
3850
+ workspace_root: Path,
3851
+ task: TaskRecord,
3852
+ run: TaskRunRecord,
3853
+ ) -> None:
3854
+ report = _build_validation_gate_report(workspace_root, task, run)
3855
+
3856
+ if not cast(bool, report["can_finish_passed"]):
3857
+ blockers = cast(list[dict[str, object]], report["blockers"])
3858
+ missing_criteria = []
3859
+ failing_criteria = []
3860
+ open_todos = []
3861
+ dependency_blockers = []
3862
+
3863
+ for blocker in blockers:
3864
+ kind = blocker.get("kind")
3865
+ if kind == "criterion_missing":
3866
+ missing_criteria.append(blocker.get("ref"))
3867
+ elif kind == "criterion_fail":
3868
+ failing_criteria.append(blocker.get("ref"))
3869
+ elif kind == "todo_open":
3870
+ open_todos.append(blocker.get("ref"))
3871
+ elif kind == "dependency_blocker":
3872
+ dependency_blockers.append(blocker.get("ref"))
3873
+
3874
+ raise _validation_incomplete(
3875
+ "Cannot mark validation passed because "
3876
+ "mandatory validation gates are incomplete.",
3877
+ {
3878
+ "missing_criteria": missing_criteria,
3879
+ "failing_criteria": failing_criteria,
3880
+ "open_mandatory_todos": open_todos,
3881
+ "dependency_blockers": dependency_blockers,
3882
+ "blockers": blockers,
3883
+ },
3884
+ )
3885
+
3886
+
3887
+ def _criterion_has_user_waiver(check: ValidationCheck) -> bool:
3888
+ return check.waiver is not None and check.waiver.actor.actor_type == "user"
3889
+
3890
+
3891
+ def _validation_incomplete(message: str, details: dict[str, object]) -> LaunchError:
3892
+ error = LaunchError(message)
3893
+ error.taskledger_exit_code = EXIT_CODE_VALIDATION_FAILED
3894
+ error.taskledger_error_code = "VALIDATION_INCOMPLETE"
3895
+ error.taskledger_data = details
3896
+ return error
3897
+
3898
+
3899
+ def _render_validation_status(payload: dict[str, object]) -> str: # noqa: C901
3900
+ """Render the validation gate report in human-readable text."""
3901
+ lines: list[str] = []
3902
+
3903
+ task_slug = payload.get("task_slug", payload.get("task_id", "unknown"))
3904
+ task_id = payload.get("task_id", "")
3905
+ lines.append(f"# Validation Status: {task_slug}")
3906
+ if task_id:
3907
+ lines.append(f"Task ID: {task_id}")
3908
+ lines.append("")
3909
+
3910
+ status_stage = payload.get("status_stage", "unknown")
3911
+ run_id = payload.get("run_id")
3912
+ lines.append(f"**Status Stage:** {status_stage}")
3913
+ if run_id:
3914
+ lines.append(f"**Run ID:** {run_id}")
3915
+ lines.append("")
3916
+
3917
+ active_stage = payload.get("active_stage")
3918
+ if active_stage:
3919
+ lines.append(f"**Active Stage:** {active_stage}")
3920
+ lines.append("")
3921
+
3922
+ accepted_plan = payload.get("accepted_plan", {})
3923
+ if isinstance(accepted_plan, dict):
3924
+ if accepted_plan:
3925
+ plan_version = accepted_plan.get("version")
3926
+ plan_status = accepted_plan.get("status", "unknown")
3927
+ lines.append(
3928
+ f"**Accepted Plan:** Version {plan_version}, Status: {plan_status}"
3929
+ )
3930
+ else:
3931
+ lines.append("**Accepted Plan:** None")
3932
+ lines.append("")
3933
+
3934
+ implementation = payload.get("implementation", {})
3935
+ if isinstance(implementation, dict):
3936
+ if implementation:
3937
+ impl_run_id = implementation.get("run_id")
3938
+ impl_status = implementation.get("status", "unknown")
3939
+ impl_satisfied = implementation.get("satisfied", False)
3940
+ lines.append(
3941
+ f"**Implementation:** Run {impl_run_id}, Status: {impl_status}"
3942
+ )
3943
+ lines.append(f" Satisfied: {'✓' if impl_satisfied else '✗'}")
3944
+ else:
3945
+ lines.append("**Implementation:** None")
3946
+ lines.append("")
3947
+
3948
+ criteria = cast(list[dict[str, object]], payload.get("criteria", []))
3949
+ if criteria:
3950
+ lines.append("## Acceptance Criteria")
3951
+ for criterion in criteria:
3952
+ if isinstance(criterion, dict):
3953
+ criterion_id = criterion.get("id", "unknown")
3954
+ text = str(criterion.get("text", ""))
3955
+ mandatory = criterion.get("mandatory", False)
3956
+ satisfied = criterion.get("satisfied", False)
3957
+ has_waiver = criterion.get("has_waiver", False)
3958
+ latest_status = criterion.get("latest_status", "unknown")
3959
+
3960
+ checkbox = "☒" if satisfied else "☐"
3961
+ mandatory_marker = " (mandatory)" if mandatory else ""
3962
+ lines.append(f" {checkbox} {criterion_id}{mandatory_marker}")
3963
+ if text:
3964
+ lines.append(f" {text[:80]}...")
3965
+ lines.append(f" Status: {latest_status}")
3966
+ if has_waiver:
3967
+ lines.append(" ✓ Waived")
3968
+ lines.append("")
3969
+
3970
+ todos_obj = payload.get("todos", {})
3971
+ if isinstance(todos_obj, dict):
3972
+ open_todos = todos_obj.get("open_mandatory", [])
3973
+ if open_todos:
3974
+ lines.append("## Open Mandatory Todos")
3975
+ for todo_id in open_todos:
3976
+ lines.append(f" - {todo_id}")
3977
+ lines.append("")
3978
+
3979
+ dependencies_obj = payload.get("dependencies", {})
3980
+ if isinstance(dependencies_obj, dict):
3981
+ dep_blockers = dependencies_obj.get("blockers", [])
3982
+ if dep_blockers:
3983
+ lines.append("## Dependency Blockers")
3984
+ for blocker_id in dep_blockers:
3985
+ lines.append(f" - {blocker_id}")
3986
+ lines.append("")
3987
+
3988
+ can_finish_passed = payload.get("can_finish_passed", False)
3989
+ lines.append("## Result")
3990
+ lines.append(f"**Can Finish Passed:** {'✓ Yes' if can_finish_passed else '✗ No'}")
3991
+
3992
+ blockers = cast(list[dict[str, object]], payload.get("blockers", []))
3993
+ if blockers and not can_finish_passed:
3994
+ lines.append("")
3995
+ lines.append("### Blocking Issues")
3996
+ for blocker in blockers:
3997
+ if isinstance(blocker, dict):
3998
+ kind = blocker.get("kind", "unknown")
3999
+ message = blocker.get("message", "")
4000
+ lines.append(f" - **{kind}**: {message}")
4001
+ hint = blocker.get("command_hint")
4002
+ if hint:
4003
+ lines.append(f" Hint: `{hint}`")
4004
+
4005
+ return "\n".join(lines)
4006
+
4007
+
4008
+ def _approval_actor(
4009
+ *,
4010
+ actor_type: str,
4011
+ actor_name: str | None,
4012
+ note: str | None,
4013
+ allow_agent_approval: bool,
4014
+ reason: str | None,
4015
+ ) -> ActorRef:
4016
+ normalized_actor = actor_type.strip()
4017
+ if normalized_actor == "user":
4018
+ if not (note or "").strip():
4019
+ raise _cli_error("Plan approval requires --note.", EXIT_CODE_BAD_INPUT)
4020
+ return ActorRef(
4021
+ actor_type="user",
4022
+ actor_name=(actor_name or getpass.getuser() or "user").strip(),
4023
+ tool="manual",
4024
+ )
4025
+ if normalized_actor == "agent":
4026
+ if not allow_agent_approval or not (reason or "").strip():
4027
+ raise _cli_error(
4028
+ "Agent approval requires --allow-agent-approval and --reason.",
4029
+ EXIT_CODE_APPROVAL_REQUIRED,
4030
+ )
4031
+ return ActorRef(
4032
+ actor_type="agent",
4033
+ actor_name=(actor_name or getpass.getuser() or "taskledger").strip(),
4034
+ tool="taskledger",
4035
+ host=socket.gethostname(),
4036
+ pid=os.getpid(),
4037
+ )
4038
+ raise _cli_error(
4039
+ f"Unsupported approval actor: {actor_type}",
4040
+ EXIT_CODE_BAD_INPUT,
4041
+ )
4042
+
4043
+
4044
+ def _unique_slug(existing: list, value: str) -> str:
4045
+ base = slugify_project_ref(value, empty="task")
4046
+ taken = {item.slug for item in existing}
4047
+ if base not in taken:
4048
+ return base
4049
+ suffix = 2
4050
+ while f"{base}-{suffix}" in taken:
4051
+ suffix += 1
4052
+ return f"{base}-{suffix}"
4053
+
4054
+
4055
+ def _lifecycle_payload(
4056
+ command: str,
4057
+ task: TaskRecord,
4058
+ *,
4059
+ warnings: list[str],
4060
+ changed: bool,
4061
+ plan_version: int | None = None,
4062
+ run: TaskRunRecord | None = None,
4063
+ lock: TaskLock | None = None,
4064
+ result: str | None = None,
4065
+ ) -> dict[str, object]:
4066
+ active_stage = (
4067
+ derive_active_stage(lock, (run,))
4068
+ if lock is not None and run is not None
4069
+ else None
4070
+ )
4071
+ payload: dict[str, object] = {
4072
+ "ok": True,
4073
+ "command": command,
4074
+ "task_id": task.id,
4075
+ "status": task.status_stage,
4076
+ "status_stage": task.status_stage,
4077
+ "active_stage": active_stage,
4078
+ "changed": changed,
4079
+ "warnings": warnings,
4080
+ "lock": lock.to_dict() if lock is not None else None,
4081
+ }
4082
+ if plan_version is not None:
4083
+ payload["plan_version"] = plan_version
4084
+ if run is not None:
4085
+ payload["run_id"] = run.run_id
4086
+ payload["run"] = run.to_dict()
4087
+ if result is not None:
4088
+ payload["result"] = result
4089
+ return payload
4090
+
4091
+
4092
+ def _cli_error(message: str, exit_code: int) -> LaunchError:
4093
+ error = LaunchError(message)
4094
+ error.taskledger_exit_code = exit_code
4095
+ return error
4096
+
4097
+
4098
+ def _stale_lock_error(task_id: str, lock: TaskLock) -> LaunchError:
4099
+ error = LaunchError(
4100
+ f"Task {task_id} has an expired {lock.stage} lock from {lock.run_id}. "
4101
+ "Break it explicitly before continuing."
4102
+ )
4103
+ error.taskledger_exit_code = EXIT_CODE_STALE_LOCK_REQUIRES_BREAK
4104
+ error.taskledger_error_type = "StaleLockRequiresBreak"
4105
+ error.taskledger_remediation = [
4106
+ (
4107
+ f"taskledger lock break --task {task_id} "
4108
+ f'--reason "recover stale {lock.stage} lock"'
4109
+ )
4110
+ ]
4111
+ error.taskledger_data = {
4112
+ "task_id": task_id,
4113
+ "lock": lock.to_dict(),
4114
+ }
4115
+ return error
4116
+
4117
+
4118
+ def _task_with_sidecars(workspace_root: Path, task: TaskRecord) -> TaskRecord:
4119
+ return replace(
4120
+ task,
4121
+ requirements=_task_requirements(workspace_root, task),
4122
+ file_links=load_links(workspace_root, task.id).links,
4123
+ todos=load_todos(workspace_root, task.id).todos,
4124
+ )
4125
+
4126
+
4127
+ def _task_payload(task: TaskRecord, *, active_stage: str | None) -> dict[str, object]:
4128
+ payload = task.to_dict()
4129
+ payload["active_stage"] = active_stage
4130
+ return payload
4131
+
4132
+
4133
+ def _active_task_payload(
4134
+ workspace_root: Path,
4135
+ task: TaskRecord,
4136
+ *,
4137
+ state: ActiveTaskState,
4138
+ changed: bool,
4139
+ previous_task_id: str | None,
4140
+ active: bool = True,
4141
+ ) -> dict[str, object]:
4142
+ return {
4143
+ "kind": "active_task",
4144
+ "task_id": task.id,
4145
+ "slug": task.slug,
4146
+ "title": task.title,
4147
+ "status_stage": task.status_stage,
4148
+ "active_stage": _task_active_stage(workspace_root, task) if active else None,
4149
+ "active": active,
4150
+ "changed": changed,
4151
+ "previous_task_id": previous_task_id,
4152
+ "state": state.to_dict(),
4153
+ }
4154
+
4155
+
4156
+ def _actor_for_active_task(actor_type: str) -> ActorRef:
4157
+ if actor_type not in {"agent", "user", "system"}:
4158
+ raise _cli_error(
4159
+ f"Unsupported actor type: {actor_type}",
4160
+ EXIT_CODE_BAD_INPUT,
4161
+ )
4162
+ base = _default_actor()
4163
+ return replace(
4164
+ base,
4165
+ actor_type=cast(Literal["agent", "user", "system"], actor_type),
4166
+ )
4167
+
4168
+
4169
+ def _ensure_active_switch_allowed(
4170
+ workspace_root: Path,
4171
+ task_id: str,
4172
+ *,
4173
+ force: bool,
4174
+ reason: str | None,
4175
+ ) -> None:
4176
+ lock = _current_lock(workspace_root, task_id)
4177
+ if lock is None or lock_is_expired(lock):
4178
+ return
4179
+ if force and reason and reason.strip():
4180
+ return
4181
+ raise LockConflict(
4182
+ f"Active task {task_id} has a live {lock.stage} lock from {lock.run_id}. "
4183
+ "Pass --force with --reason to switch or clear the active task.",
4184
+ task_id=task_id,
4185
+ details={"lock": lock.to_dict()},
4186
+ remediation=[
4187
+ f"taskledger lock show --task {task_id}",
4188
+ (
4189
+ 'Pass --force --reason "..." only if you intend to leave '
4190
+ "the lock in place."
4191
+ ),
4192
+ ],
4193
+ )
4194
+
4195
+
4196
+ def _task_active_stage(
4197
+ workspace_root: Path,
4198
+ task: TaskRecord,
4199
+ *,
4200
+ lock: TaskLock | None = None,
4201
+ runs: list[TaskRunRecord] | None = None,
4202
+ ) -> str | None:
4203
+ current_lock = lock or _current_lock(workspace_root, task.id)
4204
+ if current_lock is None or lock_is_expired(current_lock):
4205
+ return None
4206
+ task_runs = runs if runs is not None else list_runs(workspace_root, task.id)
4207
+ return derive_active_stage(current_lock, task_runs)
4208
+
4209
+
4210
+ def _task_requirements(workspace_root: Path, task: TaskRecord) -> tuple[str, ...]:
4211
+ return tuple(
4212
+ item.task_id for item in load_requirements(workspace_root, task.id).requirements
4213
+ )
4214
+
4215
+
4216
+ def _write_broken_lock_audit(paths: V2Paths, task_id: str, lock: TaskLock) -> Path:
4217
+ timestamp = lock.broken_at or utc_now_iso()
4218
+ filename = timestamp.replace(":", "").replace("-", "").replace("+00:00", "Z")
4219
+ path = task_audit_dir(paths, task_id) / f"broken-lock-{filename}.yaml"
4220
+ atomic_write_text(
4221
+ path,
4222
+ yaml.safe_dump(lock.to_dict(), sort_keys=False, allow_unicode=True),
4223
+ )
4224
+ return path