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
taskledger/exchange.py ADDED
@@ -0,0 +1,343 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from taskledger.domain.models import (
8
+ ActiveTaskState,
9
+ CodeChangeRecord,
10
+ DependencyRequirement,
11
+ FileLink,
12
+ IntroductionRecord,
13
+ LinkCollection,
14
+ PlanRecord,
15
+ QuestionRecord,
16
+ RequirementCollection,
17
+ TaskEvent,
18
+ TaskHandoffRecord,
19
+ TaskLock,
20
+ TaskRecord,
21
+ TaskRunRecord,
22
+ TaskTodo,
23
+ TodoCollection,
24
+ )
25
+ from taskledger.errors import LaunchError
26
+ from taskledger.storage.events import append_event, load_events
27
+ from taskledger.storage.indexes import rebuild_v2_indexes
28
+ from taskledger.storage.locks import write_lock
29
+ from taskledger.storage.task_store import (
30
+ V2Paths,
31
+ ensure_v2_layout,
32
+ load_active_locks,
33
+ load_active_task_state,
34
+ overwrite_plan,
35
+ plan_markdown_path,
36
+ resolve_v2_paths,
37
+ save_active_task_state,
38
+ save_change,
39
+ save_handoff,
40
+ save_introduction,
41
+ save_links,
42
+ save_plan,
43
+ save_question,
44
+ save_requirements,
45
+ save_run,
46
+ save_task,
47
+ save_todos,
48
+ task_lock_path,
49
+ )
50
+ from taskledger.storage.task_store import list_changes as list_v2_changes
51
+ from taskledger.storage.task_store import list_handoffs as list_v2_handoffs
52
+ from taskledger.storage.task_store import list_introductions as list_v2_introductions
53
+ from taskledger.storage.task_store import list_plans as list_v2_plans
54
+ from taskledger.storage.task_store import list_questions as list_v2_questions
55
+ from taskledger.storage.task_store import list_runs as list_v2_runs
56
+ from taskledger.storage.task_store import list_tasks as list_v2_tasks
57
+ from taskledger.storage.task_store import load_links as load_v2_links
58
+ from taskledger.storage.task_store import load_requirements as load_v2_requirements
59
+ from taskledger.storage.task_store import load_todos as load_v2_todos
60
+ from taskledger.timeutils import utc_now_iso
61
+
62
+
63
+ def export_project_payload(
64
+ workspace_root: Path,
65
+ *,
66
+ include_bodies: bool = False,
67
+ include_run_artifacts: bool = False,
68
+ ) -> dict[str, object]:
69
+ v2_payload = _export_v2_payload(workspace_root)
70
+ return {
71
+ "kind": "taskledger_export",
72
+ "version": 2,
73
+ "schema_version": 2,
74
+ "generated_at": utc_now_iso(),
75
+ "project_dir": str(resolve_v2_paths(workspace_root).project_dir),
76
+ "options": {
77
+ "include_bodies": include_bodies,
78
+ "include_run_artifacts": include_run_artifacts,
79
+ },
80
+ "counts": {
81
+ key: len(_dict_list(value))
82
+ for key, value in v2_payload.items()
83
+ if isinstance(value, list)
84
+ },
85
+ "v2": v2_payload,
86
+ }
87
+
88
+
89
+ def parse_project_import_payload(text: str, *, format_name: str) -> dict[str, object]:
90
+ if format_name != "json":
91
+ raise LaunchError(f"Unsupported project import format: {format_name}")
92
+ try:
93
+ payload = json.loads(text)
94
+ except json.JSONDecodeError as exc:
95
+ raise LaunchError(f"Invalid project import JSON: {exc}") from exc
96
+ if not isinstance(payload, dict):
97
+ raise LaunchError("Project import JSON must be an object.")
98
+ result: dict[str, object] = payload
99
+ if payload.get("success") is True and isinstance(payload.get("data"), dict):
100
+ candidate = payload["data"]
101
+ if candidate.get("kind") in {"taskledger_export", "project_export"}:
102
+ result = candidate
103
+ if payload.get("ok") is True and isinstance(payload.get("result"), dict):
104
+ candidate = payload["result"]
105
+ if candidate.get("kind") in {"taskledger_export", "project_export"}:
106
+ result = candidate
107
+ if result.get("kind") not in {None, "taskledger_export", "project_export"}:
108
+ raise LaunchError("Unsupported project import payload kind.")
109
+ return result
110
+
111
+
112
+ def import_project_payload(
113
+ workspace_root: Path,
114
+ *,
115
+ payload: dict[str, object],
116
+ replace: bool,
117
+ ) -> dict[str, object]:
118
+ paths = ensure_v2_layout(workspace_root)
119
+ if replace:
120
+ _clear_v2_state(paths)
121
+ _import_v2_payload(workspace_root, payload)
122
+ counts = rebuild_v2_indexes(paths)
123
+ return {
124
+ "kind": "taskledger_import",
125
+ "replace": replace,
126
+ "counts": counts,
127
+ }
128
+
129
+
130
+ def write_project_snapshot(
131
+ workspace_root: Path,
132
+ *,
133
+ output_dir: Path,
134
+ include_bodies: bool,
135
+ include_run_artifacts: bool,
136
+ ) -> dict[str, object]:
137
+ payload = export_project_payload(
138
+ workspace_root,
139
+ include_bodies=include_bodies,
140
+ include_run_artifacts=include_run_artifacts,
141
+ )
142
+ timestamp = utc_now_iso().replace(":", "-").replace("+00:00", "Z")
143
+ snapshot_dir = output_dir / f"taskledger-snapshot-{timestamp}"
144
+ snapshot_dir.mkdir(parents=True, exist_ok=False)
145
+ export_path = snapshot_dir / "taskledger-export.json"
146
+ export_path.write_text(
147
+ json.dumps(payload, indent=2, sort_keys=True) + "\n",
148
+ encoding="utf-8",
149
+ )
150
+ return {
151
+ "kind": "taskledger_snapshot",
152
+ "snapshot_dir": str(snapshot_dir),
153
+ "export_path": str(export_path),
154
+ "include_bodies": include_bodies,
155
+ "include_run_artifacts": include_run_artifacts,
156
+ }
157
+
158
+
159
+ def _dict_list(value: object) -> list[dict[str, object]]:
160
+ if not isinstance(value, list):
161
+ return []
162
+ return [item for item in value if isinstance(item, dict)]
163
+
164
+
165
+ def _export_v2_payload(workspace_root: Path) -> dict[str, object]:
166
+ tasks = list_v2_tasks(workspace_root)
167
+ introductions = list_v2_introductions(workspace_root)
168
+ return {
169
+ "tasks": [item.to_dict() for item in tasks],
170
+ "active_task": (
171
+ active_state.to_dict()
172
+ if (active_state := load_active_task_state(workspace_root)) is not None
173
+ else None
174
+ ),
175
+ "introductions": [item.to_dict() for item in introductions],
176
+ "plans": [
177
+ plan.to_dict()
178
+ for task in tasks
179
+ for plan in list_v2_plans(workspace_root, task.id)
180
+ ],
181
+ "questions": [
182
+ question.to_dict()
183
+ for task in tasks
184
+ for question in list_v2_questions(workspace_root, task.id)
185
+ ],
186
+ "runs": [
187
+ run.to_dict()
188
+ for task in tasks
189
+ for run in list_v2_runs(workspace_root, task.id)
190
+ ],
191
+ "changes": [
192
+ change.to_dict()
193
+ for task in tasks
194
+ for change in list_v2_changes(workspace_root, task.id)
195
+ ],
196
+ "handoffs": [
197
+ handoff.to_dict()
198
+ for task in tasks
199
+ for handoff in list_v2_handoffs(workspace_root, task.id)
200
+ ],
201
+ "todos": [
202
+ todo.to_dict()
203
+ for task in tasks
204
+ for todo in load_v2_todos(workspace_root, task.id).todos
205
+ ],
206
+ "links": [
207
+ link.to_dict()
208
+ for task in tasks
209
+ for link in load_v2_links(workspace_root, task.id).links
210
+ ],
211
+ "requirements": [
212
+ req.to_dict()
213
+ for task in tasks
214
+ for req in load_v2_requirements(workspace_root, task.id).requirements
215
+ ],
216
+ "locks": [item.to_dict() for item in load_active_locks(workspace_root)],
217
+ "events": [
218
+ item.to_dict()
219
+ for item in load_events(resolve_v2_paths(workspace_root).events_dir)
220
+ ],
221
+ }
222
+
223
+
224
+ def _import_standalone_collections(
225
+ raw_v2: dict[str, object], workspace_root: Path
226
+ ) -> None:
227
+ """Import per-record collections from newer exports."""
228
+ todos_by_task: dict[str, list] = {}
229
+ for item in _dict_list(raw_v2.get("todos")):
230
+ tid = str(item.get("task_id") or "")
231
+ todos_by_task.setdefault(tid, []).append(item)
232
+ for tid, items in todos_by_task.items():
233
+ save_todos(
234
+ workspace_root,
235
+ TodoCollection(
236
+ task_id=tid,
237
+ todos=tuple(TaskTodo.from_dict(i) for i in items),
238
+ ),
239
+ )
240
+ links_by_task: dict[str, list] = {}
241
+ for item in _dict_list(raw_v2.get("links")):
242
+ tid = str(item.get("task_id") or "")
243
+ links_by_task.setdefault(tid, []).append(item)
244
+ for tid, items in links_by_task.items():
245
+ save_links(
246
+ workspace_root,
247
+ LinkCollection(
248
+ task_id=tid,
249
+ links=tuple(FileLink.from_dict(i) for i in items),
250
+ ),
251
+ )
252
+ reqs_by_task: dict[str, list] = {}
253
+ for item in _dict_list(raw_v2.get("requirements")):
254
+ tid = str(item.get("task_id") or item.get("parent_task_id") or "")
255
+ reqs_by_task.setdefault(tid, []).append(item)
256
+ for tid, items in reqs_by_task.items():
257
+ save_requirements(
258
+ workspace_root,
259
+ RequirementCollection(
260
+ task_id=tid,
261
+ requirements=tuple(DependencyRequirement.from_dict(i) for i in items),
262
+ ),
263
+ )
264
+
265
+
266
+ def _import_v2_payload(workspace_root: Path, payload: dict[str, object]) -> None:
267
+ raw_v2 = payload.get("v2")
268
+ if not isinstance(raw_v2, dict):
269
+ raise LaunchError("Import payload is missing v2 task state.")
270
+ paths = resolve_v2_paths(workspace_root)
271
+ for item in _dict_list(raw_v2.get("tasks")):
272
+ task = TaskRecord.from_dict(item)
273
+ save_task(workspace_root, task)
274
+ # Import per-record collections from embedded task data
275
+ if task.todos:
276
+ save_todos(
277
+ workspace_root,
278
+ TodoCollection(task_id=task.id, todos=task.todos),
279
+ )
280
+ if task.file_links:
281
+ save_links(
282
+ workspace_root,
283
+ LinkCollection(task_id=task.id, links=task.file_links),
284
+ )
285
+ if task.requirements:
286
+ save_requirements(
287
+ workspace_root,
288
+ RequirementCollection(
289
+ task_id=task.id,
290
+ requirements=tuple(
291
+ DependencyRequirement(task_id=r) for r in task.requirements
292
+ ),
293
+ ),
294
+ )
295
+ # Import standalone per-record collections (from newer exports)
296
+ _import_standalone_collections(raw_v2, workspace_root)
297
+ active_task = raw_v2.get("active_task")
298
+ if active_task is not None:
299
+ state = ActiveTaskState.from_dict(active_task)
300
+ if not any(task.id == state.task_id for task in list_v2_tasks(workspace_root)):
301
+ raise LaunchError(
302
+ f"Import active task points to missing task: {state.task_id}"
303
+ )
304
+ save_active_task_state(workspace_root, state)
305
+ for item in _dict_list(raw_v2.get("introductions")):
306
+ save_introduction(workspace_root, IntroductionRecord.from_dict(item))
307
+ for item in _dict_list(raw_v2.get("plans")):
308
+ plan = PlanRecord.from_dict(item)
309
+ if plan_markdown_path(paths, plan.task_id, plan.plan_version).exists():
310
+ overwrite_plan(workspace_root, plan)
311
+ else:
312
+ save_plan(workspace_root, plan)
313
+ for item in _dict_list(raw_v2.get("questions")):
314
+ save_question(workspace_root, QuestionRecord.from_dict(item))
315
+ for item in _dict_list(raw_v2.get("runs")):
316
+ save_run(workspace_root, TaskRunRecord.from_dict(item))
317
+ for item in _dict_list(raw_v2.get("changes")):
318
+ save_change(workspace_root, CodeChangeRecord.from_dict(item))
319
+ for item in _dict_list(raw_v2.get("handoffs")):
320
+ save_handoff(workspace_root, TaskHandoffRecord.from_dict(item))
321
+ for item in _dict_list(raw_v2.get("locks")):
322
+ lock = TaskLock.from_dict(item)
323
+ write_lock(task_lock_path(paths, lock.task_id), lock)
324
+ if paths.events_dir.exists():
325
+ for path in paths.events_dir.glob("*.ndjson"):
326
+ path.unlink()
327
+ for item in _dict_list(raw_v2.get("events")):
328
+ append_event(paths.events_dir, TaskEvent.from_dict(item))
329
+
330
+
331
+ def _clear_v2_state(paths: V2Paths) -> None:
332
+ for directory in paths.tasks_dir.glob("task-*"):
333
+ if directory.is_dir():
334
+ shutil.rmtree(directory)
335
+ for directory in (
336
+ paths.introductions_dir,
337
+ paths.events_dir,
338
+ ):
339
+ if directory.exists():
340
+ shutil.rmtree(directory)
341
+ directory.mkdir(parents=True, exist_ok=True)
342
+ if paths.active_task_path.exists():
343
+ paths.active_task_path.unlink()
taskledger/ids.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ def next_project_id(prefix: str, existing_ids: list[str]) -> str:
7
+ pattern = re.compile(rf"^{re.escape(prefix)}-(\d+)$")
8
+ max_value = 0
9
+ for item in existing_ids:
10
+ match = pattern.match(item)
11
+ if match is None:
12
+ continue
13
+ max_value = max(max_value, int(match.group(1)))
14
+ return f"{prefix}-{max_value + 1:04d}"
15
+
16
+
17
+ def slugify_project_ref(value: str, *, empty: str = "item") -> str:
18
+ normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
19
+ return normalized or empty
taskledger/py.typed ADDED
File without changes