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,836 @@
1
+ # Derived index files
2
+ #
3
+ # Index files under .taskledger/indexes/ are derived caches.
4
+ # They are rebuilt from canonical Markdown/YAML records by 'taskledger reindex'.
5
+ # They may be plain JSON arrays with no version metadata.
6
+ # They are never the authoritative source of truth.
7
+ # 'doctor indexes' checks staleness but not schema mismatches as migration blockers.
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import TypeVar
15
+
16
+ import yaml
17
+
18
+ from taskledger.domain.models import (
19
+ ActiveActorState,
20
+ ActiveHarnessState,
21
+ ActiveTaskState,
22
+ CodeChangeRecord,
23
+ DependencyRequirement,
24
+ FileLink,
25
+ IntroductionRecord,
26
+ LinkCollection,
27
+ PlanRecord,
28
+ QuestionRecord,
29
+ RequirementCollection,
30
+ TaskHandoffRecord,
31
+ TaskLock,
32
+ TaskRecord,
33
+ TaskRunRecord,
34
+ TaskTodo,
35
+ TodoCollection,
36
+ )
37
+ from taskledger.domain.states import (
38
+ TASKLEDGER_SCHEMA_VERSION,
39
+ TASKLEDGER_V2_FILE_VERSION,
40
+ )
41
+ from taskledger.errors import ActiveTaskNotFound, LaunchError, NoActiveTask
42
+ from taskledger.storage.atomic import atomic_write_text
43
+ from taskledger.storage.frontmatter import (
44
+ normalize_front_matter_newlines,
45
+ read_markdown_front_matter,
46
+ write_markdown_front_matter,
47
+ )
48
+ from taskledger.storage.locks import read_lock, update_lock, write_lock
49
+ from taskledger.storage.paths import resolve_taskledger_root
50
+ from taskledger.timeutils import utc_now_iso
51
+
52
+ T = TypeVar("T")
53
+
54
+
55
+ def _link_id_from_path(path: str) -> str:
56
+ """Generate a deterministic link id from the path."""
57
+ import hashlib
58
+
59
+ digest = hashlib.sha256(path.encode("utf-8")).hexdigest()[:8]
60
+ return f"link-{digest}"
61
+
62
+
63
+ def _requirement_id_from_task(task_id: str) -> str:
64
+ """Generate a deterministic requirement id from the required task id."""
65
+ import re
66
+
67
+ match = re.match(r"task-(\d+)", task_id)
68
+ if match:
69
+ return f"req-{match.group(1)}"
70
+ return f"req-{task_id}"
71
+
72
+
73
+ @dataclass(slots=True, frozen=True)
74
+ class V2Paths:
75
+ workspace_root: Path
76
+ project_dir: Path
77
+ introductions_dir: Path
78
+ tasks_dir: Path
79
+ plans_dir: Path
80
+ questions_dir: Path
81
+ runs_dir: Path
82
+ changes_dir: Path
83
+ events_dir: Path
84
+ indexes_dir: Path
85
+ active_task_path: Path
86
+ actor_path: Path
87
+ harness_path: Path
88
+ active_locks_index_path: Path
89
+ dependencies_index_path: Path
90
+ introductions_index_path: Path
91
+
92
+
93
+ def resolve_v2_paths(workspace_root: Path) -> V2Paths:
94
+ project_dir = resolve_taskledger_root(workspace_root)
95
+ indexes_dir = project_dir / "indexes"
96
+ return V2Paths(
97
+ workspace_root=workspace_root,
98
+ project_dir=project_dir,
99
+ introductions_dir=project_dir / "intros",
100
+ tasks_dir=project_dir / "tasks",
101
+ plans_dir=project_dir / "plans",
102
+ questions_dir=project_dir / "questions",
103
+ runs_dir=project_dir / "runs",
104
+ changes_dir=project_dir / "changes",
105
+ events_dir=project_dir / "events",
106
+ indexes_dir=indexes_dir,
107
+ active_task_path=project_dir / "active-task.yaml",
108
+ actor_path=project_dir / "actor.yaml",
109
+ harness_path=project_dir / "harness.yaml",
110
+ active_locks_index_path=indexes_dir / "active_locks.json",
111
+ dependencies_index_path=indexes_dir / "dependencies.json",
112
+ introductions_index_path=indexes_dir / "introductions.json",
113
+ )
114
+
115
+
116
+ def ensure_v2_layout(workspace_root: Path) -> V2Paths:
117
+ paths = resolve_v2_paths(workspace_root)
118
+ for directory in (
119
+ paths.project_dir,
120
+ paths.introductions_dir,
121
+ paths.tasks_dir,
122
+ paths.events_dir,
123
+ paths.indexes_dir,
124
+ ):
125
+ directory.mkdir(parents=True, exist_ok=True)
126
+ for index_path in (
127
+ paths.active_locks_index_path,
128
+ paths.dependencies_index_path,
129
+ paths.introductions_index_path,
130
+ ):
131
+ if index_path.exists():
132
+ continue
133
+ atomic_write_text(index_path, "[]\n")
134
+ return paths
135
+
136
+
137
+ def list_tasks(workspace_root: Path) -> list[TaskRecord]:
138
+ paths = ensure_v2_layout(workspace_root)
139
+ return sorted(
140
+ [_load_task(path) for path in paths.tasks_dir.glob("task-*/task.md")],
141
+ key=lambda item: item.id,
142
+ )
143
+
144
+
145
+ def resolve_task(workspace_root: Path, ref: str) -> TaskRecord:
146
+ normalized_ref = ref.strip().lower()
147
+ normalized_id = _normalize_numeric_ref(normalized_ref, "task")
148
+ for task in list_tasks(workspace_root):
149
+ if task.id == ref or task.id == normalized_id or task.slug == normalized_ref:
150
+ return task
151
+ raise LaunchError(f"Task not found: {ref}")
152
+
153
+
154
+ def load_active_task_state(workspace_root: Path) -> ActiveTaskState | None:
155
+ paths = ensure_v2_layout(workspace_root)
156
+ if not paths.active_task_path.exists():
157
+ return None
158
+ try:
159
+ payload = yaml.safe_load(paths.active_task_path.read_text(encoding="utf-8"))
160
+ except yaml.YAMLError as exc:
161
+ raise LaunchError(f"Invalid active task state: {exc}") from exc
162
+ if not isinstance(payload, dict):
163
+ raise LaunchError("Invalid active task state: expected mapping.")
164
+ return ActiveTaskState.from_dict(payload)
165
+
166
+
167
+ def save_active_task_state(
168
+ workspace_root: Path,
169
+ state: ActiveTaskState,
170
+ ) -> ActiveTaskState:
171
+ paths = ensure_v2_layout(workspace_root)
172
+ _write_yaml(paths.active_task_path, state.to_dict())
173
+ return state
174
+
175
+
176
+ def clear_active_task_state(workspace_root: Path) -> ActiveTaskState | None:
177
+ paths = ensure_v2_layout(workspace_root)
178
+ state = load_active_task_state(workspace_root)
179
+ if paths.active_task_path.exists():
180
+ paths.active_task_path.unlink()
181
+ return state
182
+
183
+
184
+ def load_actor_state(workspace_root: Path) -> ActiveActorState | None:
185
+ paths = ensure_v2_layout(workspace_root)
186
+ if not paths.actor_path.exists():
187
+ return None
188
+ try:
189
+ payload = yaml.safe_load(paths.actor_path.read_text(encoding="utf-8"))
190
+ except yaml.YAMLError as exc:
191
+ raise LaunchError(f"Invalid actor state: {exc}") from exc
192
+ if not isinstance(payload, dict):
193
+ raise LaunchError("Invalid actor state: expected mapping.")
194
+ return ActiveActorState.from_dict(payload)
195
+
196
+
197
+ def save_actor_state(
198
+ workspace_root: Path,
199
+ state: ActiveActorState,
200
+ ) -> ActiveActorState:
201
+ paths = ensure_v2_layout(workspace_root)
202
+ _write_yaml(paths.actor_path, state.to_dict())
203
+ return state
204
+
205
+
206
+ def clear_actor_state(workspace_root: Path) -> ActiveActorState | None:
207
+ paths = ensure_v2_layout(workspace_root)
208
+ state = load_actor_state(workspace_root)
209
+ if paths.actor_path.exists():
210
+ paths.actor_path.unlink()
211
+ return state
212
+
213
+
214
+ def load_harness_state(workspace_root: Path) -> ActiveHarnessState | None:
215
+ paths = ensure_v2_layout(workspace_root)
216
+ if not paths.harness_path.exists():
217
+ return None
218
+ try:
219
+ payload = yaml.safe_load(paths.harness_path.read_text(encoding="utf-8"))
220
+ except yaml.YAMLError as exc:
221
+ raise LaunchError(f"Invalid harness state: {exc}") from exc
222
+ if not isinstance(payload, dict):
223
+ raise LaunchError("Invalid harness state: expected mapping.")
224
+ return ActiveHarnessState.from_dict(payload)
225
+
226
+
227
+ def save_harness_state(
228
+ workspace_root: Path,
229
+ state: ActiveHarnessState,
230
+ ) -> ActiveHarnessState:
231
+ paths = ensure_v2_layout(workspace_root)
232
+ _write_yaml(paths.harness_path, state.to_dict())
233
+ return state
234
+
235
+
236
+ def clear_harness_state(workspace_root: Path) -> ActiveHarnessState | None:
237
+ paths = ensure_v2_layout(workspace_root)
238
+ state = load_harness_state(workspace_root)
239
+ if paths.harness_path.exists():
240
+ paths.harness_path.unlink()
241
+ return state
242
+
243
+
244
+ def resolve_active_task(workspace_root: Path) -> TaskRecord:
245
+ state = load_active_task_state(workspace_root)
246
+ if state is None:
247
+ raise NoActiveTask()
248
+ try:
249
+ return resolve_task(workspace_root, state.task_id)
250
+ except LaunchError as exc:
251
+ raise ActiveTaskNotFound(
252
+ f"Active task points to missing task: {state.task_id}",
253
+ details={"task_id": state.task_id},
254
+ task_id=state.task_id,
255
+ ) from exc
256
+
257
+
258
+ def resolve_task_or_active(
259
+ workspace_root: Path,
260
+ ref: str | None = None,
261
+ ) -> TaskRecord:
262
+ if ref is not None and ref.strip():
263
+ return resolve_task(workspace_root, ref)
264
+ return resolve_active_task(workspace_root)
265
+
266
+
267
+ def save_task(workspace_root: Path, task: TaskRecord) -> TaskRecord:
268
+ paths = ensure_v2_layout(workspace_root)
269
+ _ensure_task_bundle(paths, task.id)
270
+ path = task_markdown_path(paths, task.id)
271
+ if path.parent.name != task.id:
272
+ raise LaunchError(f"Task id/path mismatch for {task.id}")
273
+ metadata = task.to_dict()
274
+ metadata.pop("todos", None)
275
+ metadata.pop("file_links", None)
276
+ metadata.pop("requirements", None)
277
+ _write_markdown_record(path, metadata, task.body)
278
+ return task
279
+
280
+
281
+ def list_introductions(workspace_root: Path) -> list[IntroductionRecord]:
282
+ paths = ensure_v2_layout(workspace_root)
283
+ return sorted(
284
+ [_load_intro(path) for path in paths.introductions_dir.glob("intro-*.md")],
285
+ key=lambda item: item.id,
286
+ )
287
+
288
+
289
+ def resolve_introduction(workspace_root: Path, ref: str) -> IntroductionRecord:
290
+ normalized_ref = ref.strip().lower()
291
+ for intro in list_introductions(workspace_root):
292
+ if intro.id == ref or intro.slug == normalized_ref:
293
+ return intro
294
+ raise LaunchError(f"Introduction not found: {ref}")
295
+
296
+
297
+ def save_introduction(
298
+ workspace_root: Path, introduction: IntroductionRecord
299
+ ) -> IntroductionRecord:
300
+ paths = ensure_v2_layout(workspace_root)
301
+ path = paths.introductions_dir / f"{introduction.id}.md"
302
+ _write_markdown_record(path, introduction.to_dict(), introduction.body)
303
+ return introduction
304
+
305
+
306
+ def list_plans(workspace_root: Path, task_id: str) -> list[PlanRecord]:
307
+ paths = ensure_v2_layout(workspace_root)
308
+ directory = task_plans_dir(paths, task_id)
309
+ return sorted(
310
+ [_load_plan(path) for path in directory.glob("plan-v*.md")],
311
+ key=lambda item: item.plan_version,
312
+ )
313
+
314
+
315
+ def save_plan(workspace_root: Path, plan: PlanRecord) -> PlanRecord:
316
+ paths = ensure_v2_layout(workspace_root)
317
+ path = plan_markdown_path(paths, plan.task_id, plan.plan_version)
318
+ if path.exists():
319
+ raise LaunchError(
320
+ f"Plan version already exists: {plan.task_id} v{plan.plan_version}"
321
+ )
322
+ _write_markdown_record(path, plan.to_dict(), plan.body)
323
+ return plan
324
+
325
+
326
+ def overwrite_plan(workspace_root: Path, plan: PlanRecord) -> PlanRecord:
327
+ paths = ensure_v2_layout(workspace_root)
328
+ path = plan_markdown_path(paths, plan.task_id, plan.plan_version)
329
+ _write_markdown_record(path, plan.to_dict(), plan.body)
330
+ return plan
331
+
332
+
333
+ def resolve_plan(
334
+ workspace_root: Path,
335
+ task_id: str,
336
+ *,
337
+ version: int | None = None,
338
+ ) -> PlanRecord:
339
+ plans = list_plans(workspace_root, task_id)
340
+ if not plans:
341
+ raise LaunchError(f"No plans found for task {task_id}")
342
+ if version is None:
343
+ return plans[-1]
344
+ for plan in plans:
345
+ if plan.plan_version == version:
346
+ return plan
347
+ raise LaunchError(f"Plan version not found for task {task_id}: {version}")
348
+
349
+
350
+ def list_questions(workspace_root: Path, task_id: str) -> list[QuestionRecord]:
351
+ paths = ensure_v2_layout(workspace_root)
352
+ directory = task_questions_dir(paths, task_id)
353
+ return sorted(
354
+ [_load_question(path) for path in directory.glob("q-*.md")],
355
+ key=lambda item: item.id,
356
+ )
357
+
358
+
359
+ def resolve_question(
360
+ workspace_root: Path, task_id: str, question_id: str
361
+ ) -> QuestionRecord:
362
+ normalized_id = _normalize_numeric_ref(question_id, "q")
363
+ for question in list_questions(workspace_root, task_id):
364
+ if question.id == question_id or question.id == normalized_id:
365
+ return question
366
+ raise LaunchError(f"Question not found: {question_id}")
367
+
368
+
369
+ def save_question(workspace_root: Path, question: QuestionRecord) -> QuestionRecord:
370
+ paths = ensure_v2_layout(workspace_root)
371
+ path = question_markdown_path(paths, question.task_id, question.id)
372
+ _write_markdown_record(path, question.to_dict(), _render_question_body(question))
373
+ return question
374
+
375
+
376
+ def list_runs(workspace_root: Path, task_id: str) -> list[TaskRunRecord]:
377
+ paths = ensure_v2_layout(workspace_root)
378
+ directory = task_runs_dir(paths, task_id)
379
+ return sorted(
380
+ [_load_run(path) for path in directory.glob("*.md")],
381
+ key=lambda item: item.run_id,
382
+ )
383
+
384
+
385
+ def resolve_run(workspace_root: Path, task_id: str, run_id: str) -> TaskRunRecord:
386
+ normalized_id = _normalize_numeric_ref(run_id, "run")
387
+ for run in list_runs(workspace_root, task_id):
388
+ if run.run_id == run_id or run.run_id == normalized_id:
389
+ return run
390
+ raise LaunchError(f"Run not found: {run_id}")
391
+
392
+
393
+ def save_run(workspace_root: Path, run: TaskRunRecord) -> TaskRunRecord:
394
+ paths = ensure_v2_layout(workspace_root)
395
+ path = run_markdown_path(paths, run.task_id, run.run_id)
396
+ _write_markdown_record(path, run.to_dict(), _render_run_body(run))
397
+ return run
398
+
399
+
400
+ def list_changes(workspace_root: Path, task_id: str) -> list[CodeChangeRecord]:
401
+ paths = ensure_v2_layout(workspace_root)
402
+ directory = task_changes_dir(paths, task_id)
403
+ return sorted(
404
+ [_load_change(path) for path in directory.glob("change-*.md")],
405
+ key=lambda item: item.change_id,
406
+ )
407
+
408
+
409
+ def save_change(workspace_root: Path, change: CodeChangeRecord) -> CodeChangeRecord:
410
+ paths = ensure_v2_layout(workspace_root)
411
+ path = change_markdown_path(paths, change.task_id, change.change_id)
412
+ _write_markdown_record(path, change.to_dict(), change.summary)
413
+ return change
414
+
415
+
416
+ def resolve_change(
417
+ workspace_root: Path, task_id: str, change_id: str
418
+ ) -> CodeChangeRecord:
419
+ normalized_id = _normalize_numeric_ref(change_id, "change")
420
+ for change in list_changes(workspace_root, task_id):
421
+ if change.change_id == change_id or change.change_id == normalized_id:
422
+ return change
423
+ raise LaunchError(f"Change not found: {change_id}")
424
+
425
+
426
+ def load_active_locks(workspace_root: Path) -> list[TaskLock]:
427
+ paths = ensure_v2_layout(workspace_root)
428
+ locks: list[TaskLock] = []
429
+ for path in sorted(paths.tasks_dir.glob("task-*/lock.yaml")):
430
+ lock = read_lock(path)
431
+ if lock is not None:
432
+ locks.append(lock)
433
+ return locks
434
+
435
+
436
+ def load_todos(workspace_root: Path, task_id: str) -> TodoCollection:
437
+ paths = ensure_v2_layout(workspace_root)
438
+ directory = task_todos_dir(paths, task_id)
439
+ records = sorted(
440
+ [_load_record(p, TaskTodo.from_dict) for p in directory.glob("todo-*.md")],
441
+ key=lambda t: t.id,
442
+ )
443
+ return TodoCollection(task_id=task_id, todos=tuple(records))
444
+
445
+
446
+ def save_todos(workspace_root: Path, collection: TodoCollection) -> TodoCollection:
447
+ paths = ensure_v2_layout(workspace_root)
448
+ _ensure_task_bundle(paths, collection.task_id)
449
+ directory = task_todos_dir(paths, collection.task_id)
450
+ directory.mkdir(parents=True, exist_ok=True)
451
+ keep_ids = set()
452
+ now = utc_now_iso()
453
+ for todo in collection.todos:
454
+ keep_ids.add(todo.id)
455
+ metadata = todo.to_dict()
456
+ metadata["task_id"] = collection.task_id
457
+ metadata["file_version"] = TASKLEDGER_V2_FILE_VERSION
458
+ metadata["schema_version"] = TASKLEDGER_SCHEMA_VERSION
459
+ metadata["object_type"] = "todo"
460
+ if "updated_at" not in metadata or metadata["updated_at"] is None:
461
+ metadata["updated_at"] = now
462
+ body = todo.text
463
+ path = todo_markdown_path(paths, collection.task_id, todo.id)
464
+ _write_markdown_record(path, metadata, body)
465
+ # Remove stale files
466
+ for path in directory.glob("todo-*.md"):
467
+ if path.stem not in keep_ids:
468
+ path.unlink()
469
+ return collection
470
+
471
+
472
+ def load_links(workspace_root: Path, task_id: str) -> LinkCollection:
473
+ paths = ensure_v2_layout(workspace_root)
474
+ directory = task_links_dir(paths, task_id)
475
+ records = sorted(
476
+ [_load_record(p, FileLink.from_dict) for p in directory.glob("link-*.md")],
477
+ key=lambda lk: lk.id or "",
478
+ )
479
+ return LinkCollection(task_id=task_id, links=tuple(records))
480
+
481
+
482
+ def save_links(workspace_root: Path, collection: LinkCollection) -> LinkCollection:
483
+ paths = ensure_v2_layout(workspace_root)
484
+ _ensure_task_bundle(paths, collection.task_id)
485
+ directory = task_links_dir(paths, collection.task_id)
486
+ directory.mkdir(parents=True, exist_ok=True)
487
+ keep_ids = set()
488
+ now = utc_now_iso()
489
+ for link in collection.links:
490
+ link_id = link.id or _link_id_from_path(link.path)
491
+ keep_ids.add(link_id)
492
+ metadata = link.to_dict()
493
+ metadata["id"] = link_id
494
+ metadata["task_id"] = collection.task_id
495
+ metadata["file_version"] = TASKLEDGER_V2_FILE_VERSION
496
+ metadata["schema_version"] = TASKLEDGER_SCHEMA_VERSION
497
+ metadata["object_type"] = "link"
498
+ if metadata.get("created_at") is None:
499
+ metadata["created_at"] = now
500
+ if metadata.get("updated_at") is None:
501
+ metadata["updated_at"] = now
502
+ body = link.path
503
+ path = link_markdown_path(paths, collection.task_id, link_id)
504
+ _write_markdown_record(path, metadata, body)
505
+ # Remove stale files
506
+ for path in directory.glob("link-*.md"):
507
+ if path.stem not in keep_ids:
508
+ path.unlink()
509
+ return collection
510
+
511
+
512
+ def load_requirements(workspace_root: Path, task_id: str) -> RequirementCollection:
513
+ paths = ensure_v2_layout(workspace_root)
514
+ directory = task_requirements_dir(paths, task_id)
515
+ records = sorted(
516
+ [
517
+ _load_record(p, DependencyRequirement.from_dict)
518
+ for p in directory.glob("req-*.md")
519
+ ],
520
+ key=lambda r: r.id or "",
521
+ )
522
+ return RequirementCollection(task_id=task_id, requirements=tuple(records))
523
+
524
+
525
+ def save_requirements(
526
+ workspace_root: Path, collection: RequirementCollection
527
+ ) -> RequirementCollection:
528
+ paths = ensure_v2_layout(workspace_root)
529
+ _ensure_task_bundle(paths, collection.task_id)
530
+ directory = task_requirements_dir(paths, collection.task_id)
531
+ directory.mkdir(parents=True, exist_ok=True)
532
+ keep_ids = set()
533
+ now = utc_now_iso()
534
+ for req in collection.requirements:
535
+ req_id = req.id or _requirement_id_from_task(req.task_id)
536
+ keep_ids.add(req_id)
537
+ metadata = req.to_dict()
538
+ metadata["id"] = req_id
539
+ metadata["task_id"] = collection.task_id
540
+ metadata["required_task_id"] = req.required_task_id or req.task_id
541
+ metadata["file_version"] = TASKLEDGER_V2_FILE_VERSION
542
+ metadata["schema_version"] = TASKLEDGER_SCHEMA_VERSION
543
+ metadata["object_type"] = "requirement"
544
+ if metadata.get("created_at") is None:
545
+ metadata["created_at"] = now
546
+ if metadata.get("updated_at") is None:
547
+ metadata["updated_at"] = now
548
+ body = (
549
+ f"Requires {req.required_task_id or req.task_id}"
550
+ f" to be {req.required_status}."
551
+ )
552
+ path = requirement_markdown_path(paths, collection.task_id, req_id)
553
+ _write_markdown_record(path, metadata, body)
554
+ # Remove stale files
555
+ for path in directory.glob("req-*.md"):
556
+ if path.stem not in keep_ids:
557
+ path.unlink()
558
+ return collection
559
+
560
+
561
+ def task_dir(paths: V2Paths, task_id: str) -> Path:
562
+ return paths.tasks_dir / task_id
563
+
564
+
565
+ def task_markdown_path(paths: V2Paths, task_id: str) -> Path:
566
+ return task_dir(paths, task_id) / "task.md"
567
+
568
+
569
+ def task_lock_path(paths: V2Paths, task_id: str) -> Path:
570
+ return task_dir(paths, task_id) / "lock.yaml"
571
+
572
+
573
+ def task_todos_dir(paths: V2Paths, task_id: str) -> Path:
574
+ return task_dir(paths, task_id) / "todos"
575
+
576
+
577
+ def task_links_dir(paths: V2Paths, task_id: str) -> Path:
578
+ return task_dir(paths, task_id) / "links"
579
+
580
+
581
+ def task_requirements_dir(paths: V2Paths, task_id: str) -> Path:
582
+ return task_dir(paths, task_id) / "requirements"
583
+
584
+
585
+ def task_todos_path(paths: V2Paths, task_id: str) -> Path:
586
+ return task_dir(paths, task_id) / "todos.yaml"
587
+
588
+
589
+ def task_links_path(paths: V2Paths, task_id: str) -> Path:
590
+ return task_dir(paths, task_id) / "links.yaml"
591
+
592
+
593
+ def task_requirements_path(paths: V2Paths, task_id: str) -> Path:
594
+ return task_dir(paths, task_id) / "requirements.yaml"
595
+
596
+
597
+ def todo_markdown_path(paths: V2Paths, task_id: str, todo_id: str) -> Path:
598
+ return task_todos_dir(paths, task_id) / f"{todo_id}.md"
599
+
600
+
601
+ def link_markdown_path(paths: V2Paths, task_id: str, link_id: str) -> Path:
602
+ return task_links_dir(paths, task_id) / f"{link_id}.md"
603
+
604
+
605
+ def requirement_markdown_path(paths: V2Paths, task_id: str, req_id: str) -> Path:
606
+ return task_requirements_dir(paths, task_id) / f"{req_id}.md"
607
+
608
+
609
+ def task_plans_dir(paths: V2Paths, task_id: str) -> Path:
610
+ return task_dir(paths, task_id) / "plans"
611
+
612
+
613
+ def task_questions_dir(paths: V2Paths, task_id: str) -> Path:
614
+ return task_dir(paths, task_id) / "questions"
615
+
616
+
617
+ def task_runs_dir(paths: V2Paths, task_id: str) -> Path:
618
+ return task_dir(paths, task_id) / "runs"
619
+
620
+
621
+ def task_changes_dir(paths: V2Paths, task_id: str) -> Path:
622
+ return task_dir(paths, task_id) / "changes"
623
+
624
+
625
+ def task_artifacts_dir(paths: V2Paths, task_id: str) -> Path:
626
+ return task_dir(paths, task_id) / "artifacts"
627
+
628
+
629
+ def task_audit_dir(paths: V2Paths, task_id: str) -> Path:
630
+ return task_dir(paths, task_id) / "audit"
631
+
632
+
633
+ def task_handoffs_dir(paths: V2Paths, task_id: str) -> Path:
634
+ return task_dir(paths, task_id) / "handoffs"
635
+
636
+
637
+ def handoff_markdown_path(paths: V2Paths, task_id: str, handoff_id: str) -> Path:
638
+ return task_handoffs_dir(paths, task_id) / f"{handoff_id}.md"
639
+
640
+
641
+ def plan_markdown_path(paths: V2Paths, task_id: str, version: int) -> Path:
642
+ return task_plans_dir(paths, task_id) / f"plan-v{version}.md"
643
+
644
+
645
+ def question_markdown_path(paths: V2Paths, task_id: str, question_id: str) -> Path:
646
+ return task_questions_dir(paths, task_id) / f"{question_id}.md"
647
+
648
+
649
+ def run_markdown_path(paths: V2Paths, task_id: str, run_id: str) -> Path:
650
+ return task_runs_dir(paths, task_id) / f"{run_id}.md"
651
+
652
+
653
+ def change_markdown_path(paths: V2Paths, task_id: str, change_id: str) -> Path:
654
+ return task_changes_dir(paths, task_id) / f"{change_id}.md"
655
+
656
+
657
+ def _load_task(path: Path) -> TaskRecord:
658
+ return _load_record(path, TaskRecord.from_dict)
659
+
660
+
661
+ def _load_intro(path: Path) -> IntroductionRecord:
662
+ return _load_record(path, IntroductionRecord.from_dict)
663
+
664
+
665
+ def _load_plan(path: Path) -> PlanRecord:
666
+ return _load_record(path, PlanRecord.from_dict)
667
+
668
+
669
+ def _load_question(path: Path) -> QuestionRecord:
670
+ return _load_record(path, QuestionRecord.from_dict)
671
+
672
+
673
+ def _load_run(path: Path) -> TaskRunRecord:
674
+ return _load_record(path, TaskRunRecord.from_dict)
675
+
676
+
677
+ def _load_change(path: Path) -> CodeChangeRecord:
678
+ return _load_record(path, CodeChangeRecord.from_dict)
679
+
680
+
681
+ def _load_record(path: Path, parser: Callable[[dict[str, object]], T]) -> T:
682
+ metadata, body = read_markdown_front_matter(path)
683
+ metadata["body"] = normalize_front_matter_newlines(body).rstrip("\n")
684
+ metadata = _ensure_schema_compat(metadata)
685
+ return parser(metadata)
686
+
687
+
688
+ def _ensure_schema_compat(record: dict) -> dict:
689
+ """Ensure record schema is compatible."""
690
+ version = record.get("schema_version", 1)
691
+ if version > TASKLEDGER_SCHEMA_VERSION:
692
+ raise LaunchError(
693
+ f"Record schema too new: {version} "
694
+ f"(current max: {TASKLEDGER_SCHEMA_VERSION}). "
695
+ "Please upgrade taskledger."
696
+ )
697
+ return record
698
+
699
+
700
+ def _write_markdown_record(path: Path, metadata: dict[str, object], body: str) -> None:
701
+ metadata = dict(metadata)
702
+ metadata.pop("body", None)
703
+ path.parent.mkdir(parents=True, exist_ok=True)
704
+ write_markdown_front_matter(path, metadata, body.rstrip() + "\n")
705
+
706
+
707
+ def _ensure_task_bundle(paths: V2Paths, task_id: str) -> None:
708
+ for directory in (
709
+ task_dir(paths, task_id),
710
+ task_plans_dir(paths, task_id),
711
+ task_questions_dir(paths, task_id),
712
+ task_todos_dir(paths, task_id),
713
+ task_links_dir(paths, task_id),
714
+ task_requirements_dir(paths, task_id),
715
+ task_runs_dir(paths, task_id),
716
+ task_changes_dir(paths, task_id),
717
+ task_artifacts_dir(paths, task_id),
718
+ task_audit_dir(paths, task_id),
719
+ task_handoffs_dir(paths, task_id),
720
+ ):
721
+ directory.mkdir(parents=True, exist_ok=True)
722
+
723
+
724
+ def _write_yaml(path: Path, payload: dict[str, object]) -> None:
725
+ path.parent.mkdir(parents=True, exist_ok=True)
726
+ atomic_write_text(path, yaml.safe_dump(payload, sort_keys=False))
727
+
728
+
729
+ def _normalize_numeric_ref(ref: str, prefix: str) -> str:
730
+ raw_prefix = f"{prefix}-"
731
+ if not ref.startswith(raw_prefix):
732
+ return ref
733
+ suffix = ref.removeprefix(raw_prefix)
734
+ if not suffix.isdigit():
735
+ return ref
736
+ return f"{prefix}-{int(suffix):04d}"
737
+
738
+
739
+ def _render_question_body(question: QuestionRecord) -> str:
740
+ lines = ["## Question", "", question.question.strip()]
741
+ lines.extend(["", "## Answer", "", (question.answer or "").strip()])
742
+ return "\n".join(lines).rstrip() + "\n"
743
+
744
+
745
+ def _render_run_body(run: TaskRunRecord) -> str:
746
+ lines: list[str] = ["## Summary", "", (run.summary or "").strip()]
747
+ if run.run_type == "validation":
748
+ lines.extend(["", "## Checks", ""])
749
+ for check in run.checks:
750
+ mark = "x" if check.status == "pass" else " "
751
+ lines.append(f"- [{mark}] {check.name}")
752
+ lines.extend(["", "## Evidence", ""])
753
+ for entry in run.evidence:
754
+ lines.append(f"- {entry}")
755
+ lines.extend(["", "## Recommendation", "", (run.recommendation or "").strip()])
756
+ return "\n".join(lines).rstrip() + "\n"
757
+
758
+
759
+ def list_handoffs(workspace_root: Path, task_id: str) -> list[TaskHandoffRecord]:
760
+ handoffs, errors = list_handoffs_with_errors(workspace_root, task_id)
761
+ if errors:
762
+ raise LaunchError(errors[0])
763
+ return handoffs
764
+
765
+
766
+ def list_handoffs_with_errors(
767
+ workspace_root: Path,
768
+ task_id: str,
769
+ ) -> tuple[list[TaskHandoffRecord], list[str]]:
770
+ paths = resolve_v2_paths(workspace_root)
771
+ handoffs_dir = task_handoffs_dir(paths, task_id)
772
+ if not handoffs_dir.exists():
773
+ return [], []
774
+ result: list[TaskHandoffRecord] = []
775
+ errors: list[str] = []
776
+ for md_file in handoffs_dir.glob("*.md"):
777
+ try:
778
+ metadata, _ = read_markdown_front_matter(md_file)
779
+ metadata = dict(metadata)
780
+ metadata["context_body"] = ""
781
+ handoff = TaskHandoffRecord.from_dict(metadata)
782
+ result.append(handoff)
783
+ except Exception as exc:
784
+ label = _path_label(workspace_root, md_file)
785
+ errors.append(f"Malformed handoff record {label}: {exc}")
786
+ return sorted(result, key=lambda h: h.created_at), errors
787
+
788
+
789
+ def resolve_handoff(
790
+ workspace_root: Path, task_id: str, handoff_ref: str
791
+ ) -> TaskHandoffRecord:
792
+ paths = resolve_v2_paths(workspace_root)
793
+ path = handoff_markdown_path(paths, task_id, handoff_ref)
794
+ if not path.exists():
795
+ raise LaunchError(f"Handoff not found: {handoff_ref}")
796
+ metadata, body = read_markdown_front_matter(path)
797
+ metadata = dict(metadata)
798
+ metadata["context_body"] = body or str(metadata.get("context_body") or "")
799
+ return TaskHandoffRecord.from_dict(metadata)
800
+
801
+
802
+ def _path_label(workspace_root: Path, path: Path) -> str:
803
+ try:
804
+ return str(path.relative_to(workspace_root))
805
+ except ValueError:
806
+ return str(path)
807
+
808
+
809
+ def save_handoff(workspace_root: Path, handoff: TaskHandoffRecord) -> Path:
810
+ paths = resolve_v2_paths(workspace_root)
811
+ handoffs_dir = task_handoffs_dir(paths, handoff.task_id)
812
+ handoffs_dir.mkdir(parents=True, exist_ok=True)
813
+ path = handoff_markdown_path(paths, handoff.task_id, handoff.handoff_id)
814
+ metadata = handoff.to_dict()
815
+ metadata.pop("context_body", None)
816
+ content = handoff.context_body or ""
817
+ _write_markdown_record(path, metadata, content)
818
+ return path
819
+
820
+
821
+ def resolve_lock(workspace_root: Path, task_id: str) -> TaskLock | None:
822
+ """Resolve a lock by task ID."""
823
+ paths = resolve_v2_paths(workspace_root)
824
+ lock_path = task_lock_path(paths, task_id)
825
+ return read_lock(lock_path)
826
+
827
+
828
+ def save_lock(workspace_root: Path, task_id: str, lock: TaskLock) -> Path:
829
+ """Save a lock record (creates if new, updates if exists)."""
830
+ paths = resolve_v2_paths(workspace_root)
831
+ lock_path = task_lock_path(paths, task_id)
832
+ if lock_path.exists():
833
+ update_lock(lock_path, lock)
834
+ else:
835
+ write_lock(lock_path, lock)
836
+ return lock_path