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,306 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from taskledger.domain.models import PlanRecord
6
+ from taskledger.domain.policies import derive_active_stage
7
+ from taskledger.services.validation import build_validation_gate_report
8
+ from taskledger.storage.locks import lock_is_expired
9
+ from taskledger.storage.task_store import (
10
+ list_changes,
11
+ list_plans,
12
+ list_questions,
13
+ list_runs,
14
+ load_todos,
15
+ read_lock,
16
+ resolve_task,
17
+ resolve_task_or_active,
18
+ resolve_v2_paths,
19
+ task_lock_path,
20
+ )
21
+
22
+
23
+ def dashboard(
24
+ workspace_root: Path,
25
+ *,
26
+ ref: str | None = None,
27
+ ) -> dict[str, object]:
28
+ if ref is not None:
29
+ task = resolve_task(workspace_root, ref)
30
+ else:
31
+ task = resolve_task_or_active(workspace_root)
32
+
33
+ paths = resolve_v2_paths(workspace_root)
34
+ lock = read_lock(task_lock_path(paths, task.id))
35
+ plans = list_plans(workspace_root, task.id)
36
+ questions = list_questions(workspace_root, task.id)
37
+ runs = list_runs(workspace_root, task.id)
38
+ changes = list_changes(workspace_root, task.id)
39
+
40
+ active_stage: str | None = None
41
+ if lock is not None and not lock_is_expired(lock):
42
+ active_stage = derive_active_stage(lock, runs)
43
+
44
+ # next action
45
+ from taskledger.services.navigation import next_action
46
+
47
+ action_info = next_action(workspace_root, task.id)
48
+
49
+ # todos
50
+ todo_collection = load_todos(workspace_root, task.id)
51
+ todos_total = len(todo_collection.todos)
52
+ todos_done = sum(1 for t in todo_collection.todos if t.done)
53
+
54
+ # files
55
+ files = task.file_links
56
+
57
+ return {
58
+ "kind": "dashboard",
59
+ "task": {
60
+ "id": task.id,
61
+ "slug": task.slug,
62
+ "title": task.title,
63
+ "status_stage": task.status_stage,
64
+ "active_stage": active_stage,
65
+ "created_at": task.created_at,
66
+ "updated_at": task.updated_at,
67
+ "description_summary": task.description_summary,
68
+ "priority": task.priority,
69
+ "labels": list(task.labels),
70
+ "owner": task.owner,
71
+ },
72
+ "plan": _plan_summary(plans),
73
+ "plans": [plan.to_dict() for plan in plans],
74
+ "next_action": action_info,
75
+ "questions": {
76
+ "total": len(questions),
77
+ "open": sum(1 for q in questions if q.status == "open"),
78
+ "items": [question.to_dict() for question in questions],
79
+ },
80
+ "todos": {
81
+ "total": todos_total,
82
+ "done": todos_done,
83
+ "items": [t.to_dict() for t in todo_collection.todos],
84
+ },
85
+ "files": {
86
+ "total": len(files),
87
+ "links": [fl.to_dict() for fl in files],
88
+ },
89
+ "runs": [r.to_dict() for r in runs],
90
+ "changes": [c.to_dict() for c in changes],
91
+ "validation": build_validation_gate_report(workspace_root, task),
92
+ "events": _recent_task_events(workspace_root, task.id),
93
+ "lock": lock.to_dict() if lock is not None else None,
94
+ }
95
+
96
+
97
+ def render_dashboard_text(payload: dict[str, object]) -> str: # noqa: C901
98
+ lines: list[str] = []
99
+
100
+ task = payload["task"]
101
+ assert isinstance(task, dict)
102
+ lines.append(
103
+ f"Task: {task['slug']} ({task['id']})\n"
104
+ f"Title: {task['title']}\n"
105
+ f"Stage: {task['status_stage']} Active: {task.get('active_stage') or 'none'}\n"
106
+ f"Created: {_ts(task.get('created_at'))} "
107
+ f"Updated: {_ts(task.get('updated_at'))}",
108
+ )
109
+
110
+ desc = task.get("description_summary")
111
+ if desc:
112
+ lines.append(f"Description: {desc}")
113
+
114
+ priority = task.get("priority")
115
+ if priority:
116
+ lines.append(f"Priority: {priority}")
117
+
118
+ labels = task.get("labels")
119
+ if labels and isinstance(labels, list | tuple) and len(labels) > 0:
120
+ lines.append(f"Labels: {', '.join(str(x) for x in labels)}")
121
+
122
+ owner = task.get("owner")
123
+ if owner:
124
+ lines.append(f"Owner: {owner}")
125
+
126
+ lines.append("")
127
+
128
+ # plan
129
+ plan = payload.get("plan")
130
+ assert isinstance(plan, dict | type(None))
131
+ if plan is not None:
132
+ version = plan.get("version")
133
+ status = plan.get("status", "none")
134
+ lines.append(f"Plan (v{version}): {status}")
135
+ criteria = plan.get("criteria")
136
+ if isinstance(criteria, list | tuple):
137
+ for ac in criteria:
138
+ assert isinstance(ac, dict)
139
+ lines.append(f" {ac.get('id', '?')}: {ac.get('text', '')}")
140
+ else:
141
+ lines.append("Plan: none")
142
+
143
+ lines.append("")
144
+
145
+ # next action
146
+ na = payload.get("next_action")
147
+ assert isinstance(na, dict | type(None))
148
+ if na is not None:
149
+ lines.append(f"Next action: {na.get('action', 'none')}")
150
+ reason = na.get("reason")
151
+ if reason:
152
+ lines.append(f" {reason}")
153
+ next_item = na.get("next_item")
154
+ if isinstance(next_item, dict):
155
+ kind = next_item.get("kind")
156
+ item_id = next_item.get("id")
157
+ text = next_item.get("text")
158
+ if kind and item_id and text:
159
+ lines.append(f" next {kind}: {item_id} {text}")
160
+ elif kind and item_id:
161
+ lines.append(f" next {kind}: {item_id}")
162
+ validation_hint = next_item.get("validation_hint")
163
+ if isinstance(validation_hint, str) and validation_hint:
164
+ lines.append(f" validation: {validation_hint}")
165
+ done_command = next_item.get("done_command_hint")
166
+ if isinstance(done_command, str) and done_command:
167
+ lines.append(f" when done: {done_command}")
168
+ next_command = na.get("next_command")
169
+ if next_command:
170
+ lines.append(f" command: {next_command}")
171
+ progress = na.get("progress")
172
+ if isinstance(progress, dict):
173
+ todos = progress.get("todos")
174
+ if isinstance(todos, dict):
175
+ lines.append(
176
+ f" progress: {todos.get('done', 0)}/"
177
+ f"{todos.get('total', 0)} todos done"
178
+ )
179
+ blockers = na.get("blocking")
180
+ if isinstance(blockers, list | tuple) and len(blockers) > 0:
181
+ for b in blockers:
182
+ assert isinstance(b, dict)
183
+ lines.append(f" blocker: {b.get('message', '')}")
184
+
185
+ lines.append("")
186
+
187
+ # questions
188
+ q = payload.get("questions")
189
+ assert isinstance(q, dict)
190
+ lines.append(f"Questions: {q.get('open', 0)} open / {q.get('total', 0)} total")
191
+
192
+ # todos
193
+ t = payload.get("todos")
194
+ assert isinstance(t, dict)
195
+ lines.append(f"Todos: {t.get('done', 0)}/{t.get('total', 0)} done")
196
+ items = t.get("items")
197
+ if isinstance(items, list | tuple):
198
+ for item in items:
199
+ assert isinstance(item, dict)
200
+ mark = "x" if item.get("done") else " "
201
+ lines.append(f" [{mark}] {item.get('id', '?')} {item.get('text', '')}")
202
+
203
+ # files
204
+ f = payload.get("files")
205
+ assert isinstance(f, dict)
206
+ lines.append(f"Files: {f.get('total', 0)} linked")
207
+
208
+ lines.append("")
209
+
210
+ # runs
211
+ runs = payload.get("runs")
212
+ if isinstance(runs, list | tuple) and len(runs) > 0:
213
+ lines.append("Runs:")
214
+ for r in runs:
215
+ assert isinstance(r, dict)
216
+ rid = r.get("run_id", "?")
217
+ rtype = r.get("run_type", "?")
218
+ rstatus = r.get("status", "?")
219
+ finished = r.get("finished_at")
220
+ summary = r.get("summary")
221
+ line = f" {rid} {rtype} {rstatus}"
222
+ if finished:
223
+ line += f" ({_ts(finished)})"
224
+ if summary:
225
+ line += f"\n {summary}"
226
+ # validation result
227
+ result = r.get("result")
228
+ if result:
229
+ line += f" [{result}]"
230
+ lines.append(line)
231
+ else:
232
+ lines.append("Runs: none")
233
+
234
+ lines.append("")
235
+
236
+ # changes
237
+ changes = payload.get("changes")
238
+ if isinstance(changes, list | tuple) and len(changes) > 0:
239
+ lines.append(f"Changes: {len(changes)}")
240
+ for c in changes:
241
+ assert isinstance(c, dict)
242
+ cid = c.get("change_id", "?")
243
+ cpath = c.get("path", "?")
244
+ ckind = c.get("kind", "?")
245
+ csum = c.get("summary", "")
246
+ lines.append(f" {cid} {cpath} ({ckind})")
247
+ if csum:
248
+ lines.append(f" {csum}")
249
+ else:
250
+ lines.append("Changes: none")
251
+
252
+ lines.append("")
253
+
254
+ # lock
255
+ lock = payload.get("lock")
256
+ if lock is not None and isinstance(lock, dict):
257
+ lines.append(f"Lock: {lock.get('stage', '?')} ({lock.get('run_id', '?')})")
258
+ else:
259
+ lines.append("Lock: none")
260
+
261
+ return "\n".join(lines)
262
+
263
+
264
+ def _plan_summary(plans: list[PlanRecord]) -> dict[str, object] | None:
265
+ if not plans:
266
+ return None
267
+ latest: PlanRecord | None = None
268
+ for p in plans:
269
+ if latest is None or p.plan_version > latest.plan_version:
270
+ latest = p
271
+ if latest is None:
272
+ return None
273
+ return {
274
+ "version": latest.plan_version,
275
+ "status": latest.status,
276
+ "criteria": [ac.to_dict() for ac in latest.criteria],
277
+ "body": latest.body,
278
+ }
279
+
280
+
281
+ def _recent_task_events(
282
+ workspace_root: Path,
283
+ task_id: str,
284
+ *,
285
+ limit: int = 50,
286
+ ) -> list[dict[str, object]]:
287
+ from taskledger.services.tasks import list_events
288
+
289
+ events = [
290
+ event
291
+ for event in list_events(workspace_root)
292
+ if event.get("task_id") == task_id
293
+ ]
294
+ if limit <= 0:
295
+ return []
296
+ return events[-limit:]
297
+
298
+
299
+ def _ts(value: object) -> str:
300
+ if value is None:
301
+ return "-"
302
+ s = str(value)
303
+ # trim to datetime portion (drop seconds timezone noise for compactness)
304
+ if len(s) >= 10:
305
+ return s[:10]
306
+ return s