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,435 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ from taskledger.domain.policies import derive_active_stage
7
+ from taskledger.domain.states import TASKLEDGER_STORAGE_LAYOUT_VERSION
8
+ from taskledger.storage.events import load_events
9
+ from taskledger.storage.locks import lock_is_expired
10
+ from taskledger.storage.migrations import inspect_records_for_migration
11
+ from taskledger.storage.paths import (
12
+ DEFAULT_TASKLEDGER_DIR_NAME,
13
+ PROJECT_CONFIG_FILENAMES,
14
+ load_project_locator,
15
+ resolve_project_paths,
16
+ )
17
+ from taskledger.storage.project_config import load_project_config_document
18
+ from taskledger.storage.task_store import (
19
+ ensure_v2_layout,
20
+ list_changes,
21
+ list_handoffs_with_errors,
22
+ list_plans,
23
+ list_questions,
24
+ list_runs,
25
+ list_tasks,
26
+ load_active_locks,
27
+ load_active_task_state,
28
+ load_requirements,
29
+ load_todos,
30
+ resolve_introduction,
31
+ resolve_run,
32
+ task_dir,
33
+ )
34
+
35
+
36
+ def inspect_v2_project(workspace_root: Path) -> dict[str, object]: # noqa: C901
37
+ resolved_paths = resolve_project_paths(workspace_root)
38
+ locator = load_project_locator(workspace_root)
39
+ errors: list[str] = []
40
+ warnings: list[str] = []
41
+ repair_hints: list[str] = []
42
+ config_candidates = [
43
+ resolved_paths.workspace_root / filename
44
+ for filename in PROJECT_CONFIG_FILENAMES
45
+ ]
46
+ if all(candidate.exists() for candidate in config_candidates):
47
+ warnings.append(
48
+ "Both taskledger.toml and .taskledger.toml exist; using .taskledger.toml."
49
+ )
50
+ if (
51
+ locator.source == "legacy"
52
+ and (resolved_paths.taskledger_dir / "project.toml").exists()
53
+ ):
54
+ warnings.append(
55
+ "Legacy config location: .taskledger/project.toml. "
56
+ "Move it to taskledger.toml before release."
57
+ )
58
+ if resolved_paths.config_path.exists():
59
+ try:
60
+ load_project_config_document(resolved_paths.config_path)
61
+ except Exception as exc:
62
+ errors.append(str(exc))
63
+ if not resolved_paths.taskledger_dir.exists():
64
+ errors.append(
65
+ "Configured taskledger_dir does not exist: "
66
+ f"{resolved_paths.taskledger_dir}."
67
+ )
68
+ storage_meta_path = resolved_paths.taskledger_dir / "storage.yaml"
69
+ if resolved_paths.taskledger_dir.exists() and not storage_meta_path.exists():
70
+ errors.append(
71
+ f"Missing storage.yaml in taskledger_dir: {resolved_paths.taskledger_dir}."
72
+ )
73
+ nested_storage_dir = resolved_paths.taskledger_dir / DEFAULT_TASKLEDGER_DIR_NAME
74
+ if (
75
+ resolved_paths.taskledger_dir
76
+ != resolved_paths.workspace_root / DEFAULT_TASKLEDGER_DIR_NAME
77
+ and nested_storage_dir.exists()
78
+ ):
79
+ warnings.append(
80
+ "Configured taskledger_dir contains a nested .taskledger directory."
81
+ )
82
+ repair_hints.append(
83
+ "Move taskledger state to the configured root and remove the nested "
84
+ ".taskledger directory."
85
+ )
86
+
87
+ ensure_v2_layout(workspace_root)
88
+ tasks = list_tasks(workspace_root)
89
+ task_map = {task.id: task for task in tasks}
90
+ locks = load_active_locks(workspace_root)
91
+ broken_links: list[dict[str, object]] = []
92
+ expired_locks: list[dict[str, object]] = []
93
+
94
+ task_runs = {task.id: list_runs(workspace_root, task.id) for task in tasks}
95
+ run_map = {
96
+ (task_id, run.run_id): run
97
+ for task_id, runs in task_runs.items()
98
+ for run in runs
99
+ }
100
+
101
+ try:
102
+ active_state = load_active_task_state(workspace_root)
103
+ except Exception as exc:
104
+ active_state = None
105
+ errors.append(f"Active task state is invalid: {exc}")
106
+ if active_state is not None:
107
+ active_task = task_map.get(active_state.task_id)
108
+ if active_task is None:
109
+ errors.append(f"Active task points to missing task {active_state.task_id}.")
110
+ elif active_task.status_stage in {"cancelled", "done"}:
111
+ warnings.append(
112
+ f"Active task {active_task.id} is {active_task.status_stage}."
113
+ )
114
+
115
+ for task in tasks:
116
+ plans = list_plans(workspace_root, task.id)
117
+ accepted = [plan for plan in plans if plan.status == "accepted"]
118
+ if task.introduction_ref:
119
+ try:
120
+ resolve_introduction(workspace_root, task.introduction_ref)
121
+ except Exception:
122
+ broken_links.append(
123
+ {
124
+ "task_id": task.id,
125
+ "kind": "introduction",
126
+ "ref": task.introduction_ref,
127
+ }
128
+ )
129
+ for requirement in (
130
+ item.task_id
131
+ for item in load_requirements(workspace_root, task.id).requirements
132
+ ):
133
+ if requirement not in task_map:
134
+ broken_links.append(
135
+ {"task_id": task.id, "kind": "requirement", "ref": requirement}
136
+ )
137
+ _handoffs, handoff_errors = list_handoffs_with_errors(workspace_root, task.id)
138
+ errors.extend(handoff_errors)
139
+ if task.accepted_plan_version is not None and not any(
140
+ plan.plan_version == task.accepted_plan_version for plan in plans
141
+ ):
142
+ errors.append(
143
+ f"Task {task.id} points to missing accepted plan "
144
+ f"v{task.accepted_plan_version}."
145
+ )
146
+ if len(accepted) > 1:
147
+ errors.append(f"Task {task.id} has multiple accepted plans.")
148
+ if task.accepted_plan_version is not None and len(accepted) != 1:
149
+ errors.append(
150
+ f"Task {task.id} must have exactly one accepted plan "
151
+ "for accepted_plan_version."
152
+ )
153
+ todos = load_todos(workspace_root, task.id).todos
154
+ if len({todo.id for todo in todos}) != len(todos):
155
+ errors.append(f"Task {task.id} contains duplicate todo ids.")
156
+
157
+ active_lock = next(
158
+ (
159
+ lock
160
+ for lock in locks
161
+ if lock.task_id == task.id and not lock_is_expired(lock)
162
+ ),
163
+ None,
164
+ )
165
+ running_runs = [run for run in task_runs[task.id] if run.status == "running"]
166
+ if task.status_stage in {"planning", "implementing", "validating"}:
167
+ errors.append(
168
+ f"Task {task.id} persists transient stage "
169
+ f"{task.status_stage} as status."
170
+ )
171
+ if len(running_runs) > 1:
172
+ errors.append(f"Task {task.id} has multiple running runs.")
173
+ active_stage = derive_active_stage(active_lock, running_runs)
174
+ if running_runs and active_stage is None:
175
+ errors.append(
176
+ f"Task {task.id} has a running run without a matching active lock."
177
+ )
178
+ repair_hints.append(
179
+ "Inspect the run/lock pair and either repair it or break the lock "
180
+ f"for task {task.id} explicitly."
181
+ )
182
+ if active_lock is not None and active_stage is None and not running_runs:
183
+ errors.append(
184
+ f"Task {task.id} has a {active_lock.stage} lock without a running run."
185
+ )
186
+ repair_hints.append(
187
+ "Break the stale lock with "
188
+ f'`taskledger lock break --task {task.id} --reason "..."`.'
189
+ )
190
+
191
+ for change in list_changes(workspace_root, task.id):
192
+ run = run_map.get((task.id, change.implementation_run))
193
+ if run is None:
194
+ errors.append(
195
+ f"Change {change.change_id} references missing "
196
+ f"implementation run {change.implementation_run}."
197
+ )
198
+ elif run.run_type != "implementation":
199
+ errors.append(
200
+ f"Change {change.change_id} references "
201
+ f"non-implementation run {change.implementation_run}."
202
+ )
203
+
204
+ for run in task_runs[task.id]:
205
+ if run.run_type == "validation" and run.based_on_implementation_run:
206
+ linked = run_map.get((task.id, run.based_on_implementation_run))
207
+ if linked is None:
208
+ errors.append(
209
+ f"Validation run {run.run_id} references missing "
210
+ "implementation run "
211
+ f"{run.based_on_implementation_run}."
212
+ )
213
+ elif linked.run_type != "implementation":
214
+ errors.append(
215
+ f"Validation run {run.run_id} references "
216
+ "non-implementation run "
217
+ f"{run.based_on_implementation_run}."
218
+ )
219
+
220
+ for lock in locks:
221
+ lock_task = task_map.get(lock.task_id)
222
+ if lock_task is None:
223
+ errors.append(
224
+ f"Lock {lock.lock_id} references missing task {lock.task_id}."
225
+ )
226
+ continue
227
+ try:
228
+ if lock_is_expired(lock):
229
+ expired_locks.append(lock.to_dict())
230
+ except Exception as exc:
231
+ errors.append(str(exc))
232
+ try:
233
+ run = resolve_run(workspace_root, lock.task_id, lock.run_id)
234
+ except Exception:
235
+ errors.append(
236
+ f"Lock {lock.lock_id} references missing run {lock.run_id} "
237
+ f"for task {lock.task_id}."
238
+ )
239
+ continue
240
+ if run.status != "running":
241
+ errors.append(
242
+ f"Lock {lock.lock_id} references non-running run {run.run_id}."
243
+ )
244
+ expected_stage = {
245
+ "planning": "planning",
246
+ "implementation": "implementing",
247
+ "validation": "validating",
248
+ }[run.run_type]
249
+ if lock.stage != expected_stage:
250
+ errors.append(
251
+ f"Lock {lock.lock_id} stage {lock.stage} does not match "
252
+ f"run {run.run_id} type {run.run_type}."
253
+ )
254
+
255
+ # Detect orphan slug directories (empty dirs matching task slugs but not task-NNNN)
256
+ task_slugs = {task.slug for task in tasks if task.slug}
257
+ paths = ensure_v2_layout(workspace_root)
258
+ for child in paths.tasks_dir.iterdir():
259
+ if (
260
+ child.is_dir()
261
+ and not child.name.startswith("task-")
262
+ and child.name in task_slugs
263
+ and not (child / "task.md").exists()
264
+ ):
265
+ is_empty = not any(child.iterdir())
266
+ if is_empty:
267
+ warnings.append(f"Orphan empty slug directory: {child.name}/")
268
+ repair_hints.append(
269
+ "Remove orphan directory with `taskledger repair task-dirs`."
270
+ )
271
+ else:
272
+ warnings.append(
273
+ f"Legacy slug sidecar directory retained: {child.name}/"
274
+ )
275
+
276
+ # Detect unsupported pre-release legacy YAML sidecars.
277
+ legacy_sidecar_found = False
278
+ for task in tasks:
279
+ sidecar_dirs = [task_dir(paths, task.id)]
280
+ if task.slug and task.slug != task.id:
281
+ sidecar_dirs.append(paths.tasks_dir / task.slug)
282
+ for sidecar_dir in sidecar_dirs:
283
+ for legacy_name in ("todos.yaml", "links.yaml", "requirements.yaml"):
284
+ legacy_path = sidecar_dir / legacy_name
285
+ if not legacy_path.exists():
286
+ continue
287
+ legacy_sidecar_found = True
288
+ warnings.append(
289
+ f"Unsupported pre-release legacy sidecar retained: {legacy_path}."
290
+ )
291
+ if legacy_sidecar_found:
292
+ repair_hints.append(
293
+ "Run a one-off migration script for pre-release sidecars or remove the "
294
+ "legacy YAML sidecars after confirming their contents are obsolete."
295
+ )
296
+
297
+ if broken_links:
298
+ errors.append("V2 task records contain broken references.")
299
+ if expired_locks:
300
+ warnings.append("Expired task locks require explicit resolution.")
301
+ repair_hints.append(
302
+ "Break stale locks explicitly with "
303
+ '`taskledger lock break <task> --reason "..."`.'
304
+ )
305
+
306
+ return {
307
+ "kind": "taskledger_doctor",
308
+ "counts": {
309
+ "tasks": len(tasks),
310
+ "plans": sum(len(list_plans(workspace_root, task.id)) for task in tasks),
311
+ "questions": sum(
312
+ len(list_questions(workspace_root, task.id)) for task in tasks
313
+ ),
314
+ "runs": sum(len(task_runs[task.id]) for task in tasks),
315
+ "changes": sum(
316
+ len(list_changes(workspace_root, task.id)) for task in tasks
317
+ ),
318
+ "locks": len(locks),
319
+ "active_task": 1 if active_state is not None else 0,
320
+ },
321
+ "healthy": not errors,
322
+ "errors": errors,
323
+ "warnings": warnings,
324
+ "repair_hints": repair_hints,
325
+ "broken_links": broken_links,
326
+ "expired_locks": expired_locks,
327
+ }
328
+
329
+
330
+ def inspect_v2_locks(workspace_root: Path) -> dict[str, object]:
331
+ payload = inspect_v2_project(workspace_root)
332
+ expired_locks = list(cast(list[object], payload["expired_locks"]))
333
+ return {
334
+ "kind": "taskledger_lock_inspection",
335
+ "healthy": not expired_locks,
336
+ "expired_locks": expired_locks,
337
+ }
338
+
339
+
340
+ def inspect_v2_schema(workspace_root: Path) -> dict[str, object]:
341
+ try:
342
+ payload = inspect_v2_project(workspace_root)
343
+ schema_errors = [
344
+ item
345
+ for item in cast(list[str], payload["errors"])
346
+ if "schema" in item.lower() or "version" in item.lower()
347
+ ]
348
+ except Exception as exc:
349
+ schema_errors = [str(exc)]
350
+ needed, issues = inspect_records_for_migration(workspace_root)
351
+ schema_errors.extend(issue.message for issue in issues)
352
+ schema_errors.extend(
353
+ (
354
+ f"{item.object_type} record requires schema migration "
355
+ f"{item.current_version} -> {item.target_version}: {item.path}"
356
+ )
357
+ for item in needed
358
+ )
359
+ # Check storage.yaml layout version
360
+ try:
361
+ from taskledger.storage.meta import read_storage_meta
362
+
363
+ meta = read_storage_meta(workspace_root)
364
+ if meta is None:
365
+ schema_errors.append(
366
+ "Missing storage.yaml."
367
+ " Run 'taskledger init' or 'taskledger migrate apply'."
368
+ )
369
+ elif meta.storage_layout_version > TASKLEDGER_STORAGE_LAYOUT_VERSION:
370
+ schema_errors.append(
371
+ f"Storage layout {meta.storage_layout_version} is newer than "
372
+ f"supported {TASKLEDGER_STORAGE_LAYOUT_VERSION}. Upgrade taskledger."
373
+ )
374
+ elif meta.storage_layout_version < TASKLEDGER_STORAGE_LAYOUT_VERSION:
375
+ schema_errors.append(
376
+ f"Storage layout {meta.storage_layout_version}"
377
+ " requires migration to"
378
+ f" {TASKLEDGER_STORAGE_LAYOUT_VERSION}."
379
+ " Run 'taskledger migrate apply --backup'."
380
+ )
381
+ except Exception as exc:
382
+ schema_errors.append(f"Cannot read storage.yaml: {exc}")
383
+
384
+ return {
385
+ "kind": "taskledger_schema_inspection",
386
+ "healthy": not schema_errors,
387
+ "errors": schema_errors,
388
+ }
389
+
390
+
391
+ def inspect_v2_indexes(workspace_root: Path) -> dict[str, object]:
392
+ paths = ensure_v2_layout(workspace_root)
393
+ missing = [
394
+ str(path.relative_to(paths.project_dir))
395
+ for path in (
396
+ paths.active_locks_index_path,
397
+ paths.dependencies_index_path,
398
+ paths.introductions_index_path,
399
+ )
400
+ if not path.exists()
401
+ ]
402
+ event_errors: list[str] = []
403
+ try:
404
+ load_events(paths.events_dir)
405
+ except Exception as exc:
406
+ event_errors.append(str(exc))
407
+ return {
408
+ "kind": "taskledger_index_inspection",
409
+ "healthy": not missing and not event_errors,
410
+ "missing_indexes": missing,
411
+ "event_errors": event_errors,
412
+ }
413
+
414
+
415
+ def cleanup_orphan_slug_dirs(workspace_root: Path) -> dict[str, object]:
416
+ """Remove empty slug-named directories under tasks/ that have no task.md."""
417
+ paths = ensure_v2_layout(workspace_root)
418
+ tasks = list_tasks(workspace_root)
419
+ task_slugs = {task.slug for task in tasks if task.slug}
420
+ removed: list[str] = []
421
+ for child in sorted(paths.tasks_dir.iterdir()):
422
+ if (
423
+ child.is_dir()
424
+ and not child.name.startswith("task-")
425
+ and child.name in task_slugs
426
+ and not (child / "task.md").exists()
427
+ and not any(child.iterdir())
428
+ ):
429
+ child.rmdir()
430
+ removed.append(child.name)
431
+ return {
432
+ "kind": "taskledger_repair_task_dirs",
433
+ "removed": removed,
434
+ "count": len(removed),
435
+ }