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.
- taskledger/__init__.py +5 -0
- taskledger/__main__.py +6 -0
- taskledger/_version.py +24 -0
- taskledger/api/__init__.py +13 -0
- taskledger/api/handoff.py +247 -0
- taskledger/api/introductions.py +9 -0
- taskledger/api/locks.py +4 -0
- taskledger/api/plans.py +31 -0
- taskledger/api/project.py +185 -0
- taskledger/api/questions.py +19 -0
- taskledger/api/search.py +87 -0
- taskledger/api/task_runs.py +38 -0
- taskledger/api/tasks.py +61 -0
- taskledger/cli.py +600 -0
- taskledger/cli_actor.py +196 -0
- taskledger/cli_common.py +617 -0
- taskledger/cli_implement.py +409 -0
- taskledger/cli_migrate.py +328 -0
- taskledger/cli_misc.py +984 -0
- taskledger/cli_plan.py +478 -0
- taskledger/cli_question.py +350 -0
- taskledger/cli_task.py +257 -0
- taskledger/cli_validate.py +285 -0
- taskledger/command_inventory.py +125 -0
- taskledger/domain/__init__.py +2 -0
- taskledger/domain/models.py +1697 -0
- taskledger/domain/policies.py +542 -0
- taskledger/domain/states.py +320 -0
- taskledger/errors.py +165 -0
- taskledger/exchange.py +343 -0
- taskledger/ids.py +19 -0
- taskledger/py.typed +0 -0
- taskledger/search.py +349 -0
- taskledger/services/__init__.py +1 -0
- taskledger/services/actors.py +245 -0
- taskledger/services/dashboard.py +306 -0
- taskledger/services/doctor.py +435 -0
- taskledger/services/handoff.py +1029 -0
- taskledger/services/handoff_lifecycle.py +154 -0
- taskledger/services/navigation.py +930 -0
- taskledger/services/phase5_lock_transfer.py +96 -0
- taskledger/services/plan_lint.py +397 -0
- taskledger/services/serve_read_model.py +852 -0
- taskledger/services/tasks.py +4224 -0
- taskledger/services/validation.py +221 -0
- taskledger/services/web_dashboard.py +1742 -0
- taskledger/storage/__init__.py +39 -0
- taskledger/storage/atomic.py +57 -0
- taskledger/storage/common.py +90 -0
- taskledger/storage/events.py +98 -0
- taskledger/storage/frontmatter.py +57 -0
- taskledger/storage/indexes.py +42 -0
- taskledger/storage/init.py +187 -0
- taskledger/storage/locks.py +83 -0
- taskledger/storage/meta.py +103 -0
- taskledger/storage/migrations.py +207 -0
- taskledger/storage/paths.py +166 -0
- taskledger/storage/project_config.py +393 -0
- taskledger/storage/repos.py +256 -0
- taskledger/storage/task_store.py +836 -0
- taskledger/timeutils.py +7 -0
- taskledger-0.1.0.dist-info/METADATA +411 -0
- taskledger-0.1.0.dist-info/RECORD +67 -0
- taskledger-0.1.0.dist-info/WHEEL +5 -0
- taskledger-0.1.0.dist-info/entry_points.txt +2 -0
- taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|