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,1029 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import cast
6
+
7
+ from taskledger.domain.models import CodeChangeRecord, TaskRunRecord, TaskTodo
8
+ from taskledger.domain.policies import derive_active_stage
9
+ from taskledger.domain.states import (
10
+ ContextFor,
11
+ ContextScope,
12
+ HandoffMode,
13
+ normalize_context_for,
14
+ normalize_context_format,
15
+ normalize_context_scope,
16
+ normalize_handoff_mode,
17
+ )
18
+ from taskledger.errors import LaunchError
19
+ from taskledger.storage.locks import lock_is_expired, lock_status, read_lock
20
+ from taskledger.storage.task_store import (
21
+ list_changes,
22
+ list_plans,
23
+ list_questions,
24
+ list_runs,
25
+ load_links,
26
+ load_requirements,
27
+ load_todos,
28
+ resolve_introduction,
29
+ resolve_plan,
30
+ resolve_run,
31
+ resolve_task,
32
+ resolve_v2_paths,
33
+ task_lock_path,
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ContextRequest:
39
+ mode: HandoffMode
40
+ context_for: ContextFor
41
+ scope: ContextScope = "task"
42
+ todo_id: str | None = None
43
+ focus_run_id: str | None = None
44
+ format_name: str = "markdown"
45
+
46
+
47
+ def render_handoff(
48
+ workspace_root: Path,
49
+ task_ref: str,
50
+ *,
51
+ mode: str | None = None,
52
+ context_for: str | None = None,
53
+ scope: str | None = None,
54
+ todo_id: str | None = None,
55
+ focus_run_id: str | None = None,
56
+ format_name: str = "markdown",
57
+ ) -> str | dict[str, object]:
58
+ request = build_context_request(
59
+ mode=mode,
60
+ context_for=context_for,
61
+ scope=scope,
62
+ todo_id=todo_id,
63
+ focus_run_id=focus_run_id,
64
+ format_name=format_name,
65
+ )
66
+ payload = build_handoff_payload(
67
+ workspace_root,
68
+ task_ref,
69
+ mode=request.mode,
70
+ context_for=request.context_for,
71
+ scope=request.scope,
72
+ todo_id=request.todo_id,
73
+ focus_run_id=request.focus_run_id,
74
+ format_name=request.format_name,
75
+ )
76
+ if request.format_name == "json":
77
+ return payload
78
+ return render_markdown_handoff(payload)
79
+
80
+
81
+ def build_context_request(
82
+ *,
83
+ mode: str | None = None,
84
+ context_for: str | None = None,
85
+ scope: str | None = None,
86
+ todo_id: str | None = None,
87
+ focus_run_id: str | None = None,
88
+ format_name: str = "markdown",
89
+ ) -> ContextRequest:
90
+ resolved_format = normalize_context_format(format_name)
91
+ resolved_mode = normalize_handoff_mode(_canonical_mode(mode)) if mode else None
92
+ resolved_for = normalize_context_for(context_for) if context_for else None
93
+
94
+ if resolved_for is None and resolved_mode is None:
95
+ resolved_for = "full"
96
+ resolved_mode = "full"
97
+ elif resolved_for is None:
98
+ assert resolved_mode is not None
99
+ resolved_for = _default_context_for(resolved_mode)
100
+ else:
101
+ inferred_mode = _mode_for_context_for(resolved_for)
102
+ if resolved_mode is None:
103
+ resolved_mode = inferred_mode
104
+ elif resolved_mode != inferred_mode:
105
+ raise LaunchError(
106
+ "Context role "
107
+ f"{resolved_for!r} is incompatible with mode {resolved_mode!r}"
108
+ )
109
+
110
+ assert resolved_mode is not None
111
+ explicit_scope = normalize_context_scope(scope) if scope else None
112
+ resolved_scope = explicit_scope or "task"
113
+ if todo_id is not None:
114
+ if explicit_scope is not None and explicit_scope != "todo":
115
+ raise LaunchError("--todo implies --scope todo")
116
+ resolved_scope = "todo"
117
+ if focus_run_id is not None:
118
+ if explicit_scope is not None and explicit_scope != "run":
119
+ raise LaunchError("--run implies --scope run")
120
+ resolved_scope = "run"
121
+ if resolved_scope == "todo" and not todo_id:
122
+ raise LaunchError("--scope todo requires --todo")
123
+ if resolved_scope == "run" and not focus_run_id:
124
+ raise LaunchError("--scope run requires --run")
125
+ if (
126
+ resolved_for in {"spec-reviewer", "code-reviewer"}
127
+ and focus_run_id is None
128
+ and explicit_scope is None
129
+ ):
130
+ raise LaunchError(f"{resolved_for} context requires --run or --scope task")
131
+
132
+ return ContextRequest(
133
+ mode=resolved_mode,
134
+ context_for=resolved_for,
135
+ scope=resolved_scope,
136
+ todo_id=todo_id,
137
+ focus_run_id=focus_run_id,
138
+ format_name=resolved_format,
139
+ )
140
+
141
+
142
+ def build_handoff_payload(
143
+ workspace_root: Path,
144
+ task_ref: str,
145
+ *,
146
+ mode: str | None = None,
147
+ context_for: str | None = None,
148
+ scope: str | None = None,
149
+ todo_id: str | None = None,
150
+ focus_run_id: str | None = None,
151
+ format_name: str = "markdown",
152
+ ) -> dict[str, object]:
153
+ request = build_context_request(
154
+ mode=mode,
155
+ context_for=context_for,
156
+ scope=scope,
157
+ todo_id=todo_id,
158
+ focus_run_id=focus_run_id,
159
+ format_name=format_name,
160
+ )
161
+ task = resolve_task(workspace_root, task_ref)
162
+ intro = (
163
+ resolve_introduction(workspace_root, task.introduction_ref)
164
+ if task.introduction_ref
165
+ else None
166
+ )
167
+ plans = list_plans(workspace_root, task.id)
168
+ questions = list_questions(workspace_root, task.id)
169
+ runs = list_runs(workspace_root, task.id)
170
+ todos = list(load_todos(workspace_root, task.id).todos)
171
+ changes = list_changes(workspace_root, task.id)
172
+ accepted_plan = (
173
+ resolve_plan(workspace_root, task.id, version=task.accepted_plan_version)
174
+ if task.accepted_plan_version is not None
175
+ else None
176
+ )
177
+ latest_impl = _latest_run(runs, "implementation")
178
+ latest_validation = _latest_run(runs, "validation")
179
+ lock = read_lock(task_lock_path(resolve_v2_paths(workspace_root), task.id))
180
+ active_stage = (
181
+ None
182
+ if lock is None or lock_is_expired(lock)
183
+ else derive_active_stage(lock, runs)
184
+ )
185
+
186
+ dependencies = []
187
+ for requirement in (
188
+ item.task_id for item in load_requirements(workspace_root, task.id).requirements
189
+ ):
190
+ dependency = resolve_task(workspace_root, requirement)
191
+ dependencies.append(
192
+ {
193
+ "task_id": dependency.id,
194
+ "title": dependency.title,
195
+ "status_stage": dependency.status_stage,
196
+ }
197
+ )
198
+
199
+ focus = _resolve_focus(workspace_root, task.id, request, todos, runs, changes)
200
+ open_questions = [item.to_dict() for item in questions if item.status == "open"]
201
+ answered_questions = [
202
+ item.to_dict() for item in questions if item.status == "answered"
203
+ ]
204
+ dismissed_questions = [
205
+ item.to_dict() for item in questions if item.status == "dismissed"
206
+ ]
207
+ validation_history = [
208
+ run.to_dict()
209
+ for run in runs
210
+ if run.run_type == "validation" and run.status != "running"
211
+ ]
212
+
213
+ validation_status_report = None
214
+ if request.context_for in {"validator", "full"}:
215
+ from taskledger.services.validation import build_validation_gate_report
216
+
217
+ validation_status_report = build_validation_gate_report(workspace_root, task)
218
+
219
+ return {
220
+ "kind": "task_handoff",
221
+ "mode": request.mode,
222
+ "context_for": request.context_for,
223
+ "scope": request.scope,
224
+ "context_format": request.format_name,
225
+ "focus": focus,
226
+ "task": {**task.to_dict(), "active_stage": active_stage},
227
+ "introduction": intro.to_dict() if intro is not None else None,
228
+ "guardrails": _guardrails_for_context_for(request.context_for),
229
+ "accepted_plan": accepted_plan.to_dict() if accepted_plan is not None else None,
230
+ "plans": [plan.to_dict() for plan in plans],
231
+ "questions": {
232
+ "open": open_questions,
233
+ "answered": answered_questions,
234
+ "dismissed": dismissed_questions,
235
+ },
236
+ "todos": [todo.to_dict() for todo in todos],
237
+ "todo_summary": _todo_summary(todos, request.todo_id),
238
+ "file_links": [
239
+ item.to_dict() for item in load_links(workspace_root, task.id).links
240
+ ],
241
+ "dependencies": dependencies,
242
+ "runs": {
243
+ "latest_planning": _run_to_dict(_latest_run(runs, "planning")),
244
+ "latest_implementation": _run_to_dict(latest_impl),
245
+ "latest_validation": _run_to_dict(latest_validation),
246
+ },
247
+ "lock": lock.to_dict() if lock is not None else None,
248
+ "lock_status": lock_status(lock),
249
+ "changes": [change.to_dict() for change in changes],
250
+ "focused_changes": focus["focused_changes"],
251
+ "validation_history": validation_history,
252
+ "validation_status": validation_status_report,
253
+ "review_contract": (
254
+ {
255
+ "role": request.context_for,
256
+ "scope": request.scope,
257
+ "guardrails": _guardrails_for_context_for(request.context_for),
258
+ }
259
+ if request.context_for in {"reviewer", "spec-reviewer", "code-reviewer"}
260
+ else None
261
+ ),
262
+ }
263
+
264
+
265
+ def render_markdown_handoff(payload: dict[str, object]) -> str:
266
+ mode = str(payload["mode"])
267
+ context_for = str(payload.get("context_for") or mode)
268
+ task = payload["task"]
269
+ assert isinstance(task, dict)
270
+ title_prefix = {
271
+ "planning": "Planning Context",
272
+ "implementation": "Implementation Context",
273
+ "validation": "Validation Context",
274
+ "review": "Review Context",
275
+ "full": "Task Dossier",
276
+ }.get(mode, "Task Context")
277
+ lines = [f"# {title_prefix}: {task['title']}", ""]
278
+ _append_worker_role(lines, payload)
279
+ _append_worker_contract(lines, payload)
280
+ _append_task_section(lines, task)
281
+ _append_description(lines, task)
282
+ _append_intro(lines, payload.get("introduction"))
283
+ _append_dependencies(lines, payload["dependencies"])
284
+ _append_file_links(lines, payload["file_links"])
285
+ _append_plans(lines, payload["plans"])
286
+ _append_questions(lines, payload["questions"])
287
+ if context_for in {"planner"}:
288
+ _append_guardrails(lines, payload["guardrails"])
289
+ _append_required_commands(lines, payload.get("accepted_plan"))
290
+ _append_required_output(lines, context_for)
291
+ return "\n".join(lines).rstrip() + "\n"
292
+ if context_for in {
293
+ "implementer",
294
+ "validator",
295
+ "reviewer",
296
+ "spec-reviewer",
297
+ "code-reviewer",
298
+ "full",
299
+ }:
300
+ _append_accepted_plan(lines, payload.get("accepted_plan"))
301
+ if context_for == "implementer":
302
+ _append_acceptance_criteria(lines, payload.get("accepted_plan"))
303
+ if payload.get("scope") == "todo":
304
+ _append_focused_todo(lines, payload.get("focus"))
305
+ _append_other_todo_summary(lines, payload.get("todo_summary"))
306
+ else:
307
+ _append_todos(lines, payload["todos"])
308
+ _append_lock_and_runs(lines, payload)
309
+ _append_required_commands(lines, payload.get("accepted_plan"))
310
+ _append_required_output(lines, context_for)
311
+ elif context_for == "validator":
312
+ _append_todo_completion_summary(lines, payload.get("todo_summary"))
313
+ _append_implementation_summary(lines, payload["runs"])
314
+ _append_change_log(lines, payload["changes"])
315
+ _append_validation_status(lines, payload.get("validation_status"))
316
+ _append_validation_history(lines, payload["validation_history"])
317
+ _append_required_commands(lines, payload.get("accepted_plan"))
318
+ _append_required_output(lines, context_for)
319
+ elif context_for == "spec-reviewer":
320
+ _append_acceptance_criteria(lines, payload.get("accepted_plan"))
321
+ _append_focused_run(lines, payload.get("focus"))
322
+ _append_focused_changes(lines, payload.get("focused_changes"))
323
+ _append_plan_deviations(lines, payload.get("focus"))
324
+ _append_todo_updates(lines, payload.get("focus"))
325
+ _append_spec_review(lines)
326
+ _append_required_output(lines, context_for)
327
+ elif context_for == "code-reviewer":
328
+ _append_focused_run(lines, payload.get("focus"))
329
+ _append_focused_changes(lines, payload.get("focused_changes"))
330
+ _append_commands_already_run(lines, payload.get("focus"))
331
+ _append_code_quality_review(lines)
332
+ _append_required_output(lines, context_for)
333
+ elif context_for == "reviewer":
334
+ _append_focused_run(lines, payload.get("focus"))
335
+ _append_focused_changes(lines, payload.get("focused_changes"))
336
+ _append_required_output(lines, context_for)
337
+ elif context_for == "full":
338
+ _append_acceptance_criteria(lines, payload.get("accepted_plan"))
339
+ _append_todos(lines, payload["todos"])
340
+ _append_lock_and_runs(lines, payload)
341
+ _append_implementation_summary(lines, payload["runs"])
342
+ _append_change_log(lines, payload["changes"])
343
+ _append_validation_status(lines, payload.get("validation_status"))
344
+ _append_validation_history(lines, payload["validation_history"])
345
+ _append_required_commands(lines, payload.get("accepted_plan"))
346
+ _append_required_output(lines, context_for)
347
+ else:
348
+ _append_guardrails(lines, payload["guardrails"])
349
+ _append_required_output(lines, context_for)
350
+ return "\n".join(lines).rstrip() + "\n"
351
+
352
+
353
+ def _append_worker_role(lines: list[str], payload: dict[str, object]) -> None:
354
+ focus = payload.get("focus")
355
+ focused_todo = "none"
356
+ focused_run = "none"
357
+ if isinstance(focus, dict):
358
+ focused_todo = str(focus.get("todo_id") or "none")
359
+ focused_run = str(focus.get("focus_run_id") or "none")
360
+ lines.extend(
361
+ [
362
+ "## Worker Role",
363
+ "",
364
+ f"- role: {payload.get('context_for')}",
365
+ f"- lifecycle_mode: {payload.get('mode')}",
366
+ f"- scope: {payload.get('scope')}",
367
+ f"- focused_todo: {focused_todo}",
368
+ f"- focused_run: {focused_run}",
369
+ "",
370
+ ]
371
+ )
372
+
373
+
374
+ def _append_worker_contract(lines: list[str], payload: dict[str, object]) -> None:
375
+ context_for = str(payload.get("context_for") or "full")
376
+ must_items, must_not_items = _worker_contract(context_for)
377
+ lines.extend(["## Worker Contract", "", "You must:"])
378
+ for item in must_items:
379
+ lines.append(f"- {item}")
380
+ lines.extend(["", "You must not:"])
381
+ for item in must_not_items:
382
+ lines.append(f"- {item}")
383
+ lines.append("")
384
+
385
+
386
+ def _append_task_section(lines: list[str], task: dict[str, object]) -> None:
387
+ lines.extend(
388
+ [
389
+ "## Task",
390
+ "",
391
+ f"- id: {task['id']}",
392
+ f"- slug: {task['slug']}",
393
+ f"- status_stage: {task['status_stage']}",
394
+ f"- active_stage: {task.get('active_stage') or 'none'}",
395
+ f"- priority: {task.get('priority') or 'unset'}",
396
+ "- labels: "
397
+ + (", ".join(cast(list[str], task.get("labels") or [])) or "none"),
398
+ f"- owner: {task.get('owner') or 'unassigned'}",
399
+ "",
400
+ ]
401
+ )
402
+
403
+
404
+ def _append_description(lines: list[str], task: dict[str, object]) -> None:
405
+ lines.extend(["## Description", "", str(task.get("body") or ""), ""])
406
+
407
+
408
+ def _append_intro(lines: list[str], intro: object) -> None:
409
+ if not isinstance(intro, dict):
410
+ return
411
+ lines.extend(["## Introduction", "", str(intro.get("body") or ""), ""])
412
+
413
+
414
+ def _append_dependencies(lines: list[str], dependencies: object) -> None:
415
+ if not isinstance(dependencies, list):
416
+ return
417
+ lines.extend(["## Requirements", ""])
418
+ for item in dependencies:
419
+ if isinstance(item, dict):
420
+ lines.append(
421
+ f"- {item['task_id']}: {item['title']} — {item['status_stage']}"
422
+ )
423
+ if not dependencies:
424
+ lines.append("- none")
425
+ lines.append("")
426
+
427
+
428
+ def _append_file_links(lines: list[str], file_links: object) -> None:
429
+ if not isinstance(file_links, list):
430
+ return
431
+ lines.extend(["## Linked Files", ""])
432
+ for item in file_links:
433
+ if isinstance(item, dict):
434
+ lines.append(f"- @{item['path']} [{item['kind']}]")
435
+ if not file_links:
436
+ lines.append("- none")
437
+ lines.append("")
438
+
439
+
440
+ def _append_plans(lines: list[str], plans: object) -> None:
441
+ if not isinstance(plans, list):
442
+ return
443
+ lines.extend(["## Existing Plans", ""])
444
+ for item in plans:
445
+ if isinstance(item, dict):
446
+ lines.append(f"- v{item['plan_version']} {item['status']}")
447
+ if not plans:
448
+ lines.append("- none")
449
+ lines.append("")
450
+
451
+
452
+ def _append_questions(lines: list[str], payload: object) -> None:
453
+ if not isinstance(payload, dict):
454
+ return
455
+ lines.extend(["## Questions", "", "### Open", ""])
456
+ open_items = payload.get("open")
457
+ if isinstance(open_items, list) and open_items:
458
+ for item in open_items:
459
+ if isinstance(item, dict):
460
+ lines.append(f"- {item['id']}: {item['question']}")
461
+ else:
462
+ lines.append("- none")
463
+ lines.extend(["", "### Answered", ""])
464
+ answered_items = payload.get("answered")
465
+ if isinstance(answered_items, list) and answered_items:
466
+ for item in answered_items:
467
+ if isinstance(item, dict):
468
+ lines.append(
469
+ f"- {item['question']} -> {item.get('answer') or '(none)'}"
470
+ )
471
+ else:
472
+ lines.append("- none")
473
+ lines.append("")
474
+
475
+
476
+ def _append_guardrails(lines: list[str], guardrails: object) -> None:
477
+ if not isinstance(guardrails, list):
478
+ return
479
+ lines.extend(["## Guardrails", ""])
480
+ for item in guardrails:
481
+ lines.append(f"- {item}")
482
+ lines.append("")
483
+
484
+
485
+ def _append_accepted_plan(lines: list[str], accepted_plan: object) -> None:
486
+ if not isinstance(accepted_plan, dict):
487
+ return
488
+ lines.extend(["## Accepted Plan", "", str(accepted_plan.get("body") or ""), ""])
489
+
490
+
491
+ def _append_acceptance_criteria(lines: list[str], accepted_plan: object) -> None:
492
+ if not isinstance(accepted_plan, dict):
493
+ return
494
+ criteria = accepted_plan.get("criteria")
495
+ lines.extend(["## Acceptance Criteria", ""])
496
+ if isinstance(criteria, list) and criteria:
497
+ for item in criteria:
498
+ if isinstance(item, dict):
499
+ lines.append(f"- {item['id']}: {item['text']}")
500
+ else:
501
+ lines.append("- none")
502
+ lines.append("")
503
+
504
+
505
+ def _append_todos(lines: list[str], todos: object) -> None:
506
+ if not isinstance(todos, list):
507
+ return
508
+ done_count = sum(1 for item in todos if isinstance(item, dict) and item.get("done"))
509
+ total_count = len(todos)
510
+ lines.extend(["## Todo Checklist", ""])
511
+ if total_count == 0:
512
+ lines.append("- none (no todos)")
513
+ else:
514
+ lines.append(f"Progress: {done_count}/{total_count} done")
515
+ lines.append("")
516
+ for item in todos:
517
+ if isinstance(item, dict):
518
+ mark = "x" if item.get("done") else " "
519
+ lines.append(f"- [{mark}] {item['id']}: {item['text']}")
520
+ lines.append("")
521
+
522
+
523
+ def _append_focused_todo(lines: list[str], focus: object) -> None:
524
+ lines.extend(["## Focused Todo", ""])
525
+ if not isinstance(focus, dict) or not isinstance(focus.get("todo"), dict):
526
+ lines.append("- none")
527
+ lines.append("")
528
+ return
529
+ todo = cast(dict[str, object], focus["todo"])
530
+ lines.append(f"- id: {todo['id']}")
531
+ lines.append(f"- text: {todo['text']}")
532
+ lines.append(f"- status: {todo['status']}")
533
+ if todo.get("validation_hint"):
534
+ lines.append(f"- validation_hint: {todo['validation_hint']}")
535
+ lines.append("")
536
+
537
+
538
+ def _append_other_todo_summary(lines: list[str], summary: object) -> None:
539
+ lines.extend(["## Other Todo Summary", ""])
540
+ if not isinstance(summary, dict):
541
+ lines.append("- none")
542
+ lines.append("")
543
+ return
544
+ lines.append(f"- total: {summary.get('total', 0)}")
545
+ lines.append(f"- done: {summary.get('done', 0)}")
546
+ lines.append(f"- open: {summary.get('open', 0)}")
547
+ focused_index = summary.get("focused_index")
548
+ if focused_index is not None:
549
+ lines.append(f"- focused_index: {focused_index}")
550
+ lines.append("")
551
+
552
+
553
+ def _append_todo_completion_summary(lines: list[str], summary: object) -> None:
554
+ lines.extend(["## Todo Completion Summary", ""])
555
+ if not isinstance(summary, dict):
556
+ lines.append("- none")
557
+ lines.append("")
558
+ return
559
+ lines.append(f"- total: {summary.get('total', 0)}")
560
+ lines.append(f"- done: {summary.get('done', 0)}")
561
+ lines.append(f"- open: {summary.get('open', 0)}")
562
+ lines.append("")
563
+
564
+
565
+ def _append_lock_and_runs(lines: list[str], payload: dict[str, object]) -> None:
566
+ lines.extend(["## Current Run / Lock State", ""])
567
+ runs = payload["runs"]
568
+ assert isinstance(runs, dict)
569
+ latest_impl = runs.get("latest_implementation")
570
+ if isinstance(latest_impl, dict):
571
+ lines.append(
572
+ f"- implementation run: {latest_impl['run_id']} ({latest_impl['status']})"
573
+ )
574
+ else:
575
+ lines.append("- implementation run: none")
576
+ status = payload.get("lock_status")
577
+ if isinstance(status, dict) and status.get("active"):
578
+ lines.append(
579
+ f"- lock: {status.get('stage')} / {status.get('run_id')} "
580
+ f"expired={status.get('expired')}"
581
+ )
582
+ else:
583
+ lines.append("- lock: none")
584
+ lines.append("")
585
+
586
+
587
+ def _append_focused_run(lines: list[str], focus: object) -> None:
588
+ lines.extend(["## Focused Run", ""])
589
+ if not isinstance(focus, dict) or not isinstance(focus.get("run"), dict):
590
+ lines.append("- none")
591
+ lines.append("")
592
+ return
593
+ run = cast(dict[str, object], focus["run"])
594
+ lines.append(f"- run_id: {run['run_id']}")
595
+ lines.append(f"- run_type: {run['run_type']}")
596
+ lines.append(f"- status: {run['status']}")
597
+ lines.append(f"- based_on_plan: {run.get('based_on_plan') or 'none'}")
598
+ lines.append(f"- summary: {run.get('summary') or '(no summary)'}")
599
+ lines.append("")
600
+
601
+
602
+ def _append_focused_changes(lines: list[str], changes: object) -> None:
603
+ if not isinstance(changes, list):
604
+ return
605
+ lines.extend(["## Focused Changes", ""])
606
+ for item in changes:
607
+ if isinstance(item, dict):
608
+ lines.append(f"- @{item['path']}: {item['summary']}")
609
+ if not changes:
610
+ lines.append("- none")
611
+ lines.append("")
612
+
613
+
614
+ def _append_plan_deviations(lines: list[str], focus: object) -> None:
615
+ lines.extend(["## Plan Deviations", ""])
616
+ if not isinstance(focus, dict) or not isinstance(focus.get("run"), dict):
617
+ lines.append("- none")
618
+ lines.append("")
619
+ return
620
+ run = cast(dict[str, object], focus["run"])
621
+ deviations = run.get("deviations_from_plan")
622
+ if isinstance(deviations, list) and deviations:
623
+ for item in deviations:
624
+ lines.append(f"- {item}")
625
+ else:
626
+ lines.append("- none")
627
+ lines.append("")
628
+
629
+
630
+ def _append_todo_updates(lines: list[str], focus: object) -> None:
631
+ lines.extend(["## Todo Updates from that run", ""])
632
+ if not isinstance(focus, dict) or not isinstance(focus.get("run"), dict):
633
+ lines.append("- none")
634
+ lines.append("")
635
+ return
636
+ run = cast(dict[str, object], focus["run"])
637
+ updates = run.get("todo_updates")
638
+ if isinstance(updates, list) and updates:
639
+ for item in updates:
640
+ lines.append(f"- {item}")
641
+ else:
642
+ lines.append("- none")
643
+ lines.append("")
644
+
645
+
646
+ def _append_commands_already_run(lines: list[str], focus: object) -> None:
647
+ lines.extend(["## Commands already run", ""])
648
+ if not isinstance(focus, dict) or not isinstance(focus.get("run"), dict):
649
+ lines.append("- none")
650
+ lines.append("")
651
+ return
652
+ run = cast(dict[str, object], focus["run"])
653
+ evidence = run.get("evidence")
654
+ if isinstance(evidence, list) and evidence:
655
+ for item in evidence:
656
+ lines.append(f"- {item}")
657
+ else:
658
+ lines.append("- none")
659
+ lines.append("")
660
+
661
+
662
+ def _append_implementation_summary(lines: list[str], runs: object) -> None:
663
+ if not isinstance(runs, dict):
664
+ return
665
+ latest_impl = runs.get("latest_implementation")
666
+ lines.extend(["## Implementation Summary", ""])
667
+ if isinstance(latest_impl, dict):
668
+ lines.append(str(latest_impl.get("summary") or "(no summary)"))
669
+ else:
670
+ lines.append("(no implementation run)")
671
+ lines.append("")
672
+
673
+
674
+ def _append_change_log(lines: list[str], changes: object) -> None:
675
+ if not isinstance(changes, list):
676
+ return
677
+ lines.extend(["## Code Changes", ""])
678
+ for item in changes:
679
+ if isinstance(item, dict):
680
+ lines.append(f"- @{item['path']}: {item['summary']}")
681
+ if not changes:
682
+ lines.append("- none")
683
+ lines.append("")
684
+
685
+
686
+ def _append_validation_history(lines: list[str], history: object) -> None:
687
+ if not isinstance(history, list):
688
+ return
689
+ lines.extend(["## Previous Validation History", ""])
690
+ for item in history:
691
+ if isinstance(item, dict):
692
+ lines.append(
693
+ f"- {item['run_id']}: "
694
+ f"{item.get('result') or item['status']} — "
695
+ f"{item.get('summary') or ''}"
696
+ )
697
+ if not history:
698
+ lines.append("- none")
699
+ lines.append("")
700
+
701
+
702
+ def _append_validation_status(lines: list[str], status_report: object) -> None:
703
+ if not isinstance(status_report, dict):
704
+ return
705
+
706
+ lines.extend(["## Validation Status", ""])
707
+
708
+ can_finish = status_report.get("can_finish_passed", False)
709
+ lines.append(f"**Can Finish Passed:** {'yes' if can_finish else 'no'}")
710
+ lines.append("")
711
+
712
+ criteria = status_report.get("criteria", [])
713
+ if criteria and isinstance(criteria, list):
714
+ lines.append("### Acceptance Criteria")
715
+ for criterion in criteria:
716
+ if isinstance(criterion, dict):
717
+ criterion_id = criterion.get("id", "unknown")
718
+ mandatory = criterion.get("mandatory", False)
719
+ satisfied = criterion.get("satisfied", False)
720
+ latest_status = criterion.get("latest_status", "unknown")
721
+ marker = "pass" if satisfied else "pending"
722
+ mandatory_marker = " (mandatory)" if mandatory else ""
723
+ lines.append(
724
+ f"- {criterion_id}{mandatory_marker}: {latest_status} [{marker}]"
725
+ )
726
+ lines.append("")
727
+
728
+ blockers = status_report.get("blockers", [])
729
+ if blockers and isinstance(blockers, list) and not can_finish:
730
+ lines.append("### Blocking Issues")
731
+ for blocker in blockers:
732
+ if isinstance(blocker, dict):
733
+ kind = blocker.get("kind", "unknown")
734
+ message = blocker.get("message", "")
735
+ lines.append(f"- [{kind}] {message}")
736
+ lines.append("")
737
+
738
+
739
+ def _append_required_commands(lines: list[str], accepted_plan: object) -> None:
740
+ lines.extend(["## Required Commands", ""])
741
+ if not isinstance(accepted_plan, dict):
742
+ lines.append("- none")
743
+ lines.append("")
744
+ return
745
+ commands = accepted_plan.get("test_commands")
746
+ if isinstance(commands, list) and commands:
747
+ for item in commands:
748
+ lines.append(f"- {item}")
749
+ else:
750
+ lines.append("- none")
751
+ lines.append("")
752
+
753
+
754
+ def _append_spec_review(lines: list[str]) -> None:
755
+ lines.extend(
756
+ [
757
+ "## Spec Compliance Review",
758
+ "",
759
+ "- Check every acceptance criterion against the focused run evidence.",
760
+ "- Call out deviations_from_plan.",
761
+ "- Mark unclear items when evidence is missing.",
762
+ "",
763
+ ]
764
+ )
765
+
766
+
767
+ def _append_code_quality_review(lines: list[str]) -> None:
768
+ lines.extend(
769
+ [
770
+ "## Code Quality Review",
771
+ "",
772
+ "- Check correctness and maintainability risks.",
773
+ "- Call out unsafe or brittle changes.",
774
+ "- Check test coverage gaps.",
775
+ "",
776
+ ]
777
+ )
778
+
779
+
780
+ def _append_required_output(lines: list[str], context_for: str) -> None:
781
+ section = {
782
+ "planner": [
783
+ "plan body",
784
+ "assumptions",
785
+ "risks",
786
+ "acceptance criteria",
787
+ "open questions",
788
+ ],
789
+ "implementer": [
790
+ "worklog entries",
791
+ "code change records",
792
+ "todo updates",
793
+ "implementation summary",
794
+ ],
795
+ "validator": [
796
+ "structured checks",
797
+ "evidence",
798
+ "summary",
799
+ "recommendation",
800
+ ],
801
+ "reviewer": ["approval decision"],
802
+ "spec-reviewer": [
803
+ "overall_spec_result: pass|fail|blocked",
804
+ "acceptance_criteria_findings",
805
+ "todo_findings",
806
+ "deviations_from_plan",
807
+ "missing_evidence",
808
+ "recommended_next_action",
809
+ ],
810
+ "code-reviewer": [
811
+ "overall_code_quality: pass|fail|blocked",
812
+ "high_risk_issues",
813
+ "maintainability_issues",
814
+ "test_coverage_gaps",
815
+ "unsafe_or_brittle_changes",
816
+ "recommended_next_action",
817
+ ],
818
+ "full": ["next action"],
819
+ }[context_for]
820
+ lines.extend(["## Required Output", ""])
821
+ for item in section:
822
+ lines.append(f"- {item}")
823
+ lines.append("")
824
+
825
+
826
+ def _guardrails_for_context_for(context_for: str) -> list[str]:
827
+ if context_for == "planner":
828
+ return [
829
+ "Produce a reviewable plan.",
830
+ "Ask required questions.",
831
+ "Do not implement.",
832
+ ]
833
+ if context_for == "implementer":
834
+ return [
835
+ "Implement only the accepted plan and focused todo.",
836
+ "Do not validate.",
837
+ "Log changes.",
838
+ "Mark todo done only with evidence.",
839
+ ]
840
+ if context_for == "validator":
841
+ return [
842
+ "Validate against the accepted plan and implementation log.",
843
+ "Record failed validation.",
844
+ "Do not modify implementation.",
845
+ ]
846
+ if context_for == "spec-reviewer":
847
+ return [
848
+ "Judge spec compliance only.",
849
+ "Do not rewrite code.",
850
+ "Avoid broad style advice unless it affects compliance.",
851
+ ]
852
+ if context_for == "code-reviewer":
853
+ return [
854
+ "Judge maintainability, correctness risks, testing, and safety.",
855
+ "Do not change validation state.",
856
+ "Do not approve task completion.",
857
+ ]
858
+ return ["Use this handoff as the source of truth for the next step."]
859
+
860
+
861
+ def _worker_contract(context_for: str) -> tuple[list[str], list[str]]:
862
+ if context_for == "implementer":
863
+ return (
864
+ [
865
+ "implement only the focused todo when one is selected",
866
+ "preserve accepted plan constraints",
867
+ "log implementation changes",
868
+ "mark the focused todo done only with evidence",
869
+ ],
870
+ [
871
+ "validate the task",
872
+ "mark unrelated todos done",
873
+ "change the approved plan",
874
+ ],
875
+ )
876
+ if context_for == "validator":
877
+ return (
878
+ [
879
+ "validate against the accepted plan and implementation record",
880
+ "record failed and blocked validation explicitly",
881
+ ],
882
+ [
883
+ "modify implementation code",
884
+ "approve missing evidence implicitly",
885
+ ],
886
+ )
887
+ if context_for == "spec-reviewer":
888
+ return (
889
+ [
890
+ "judge whether the focused run satisfies the plan and "
891
+ "acceptance criteria",
892
+ "cite concrete evidence for pass, fail, or unclear findings",
893
+ ],
894
+ [
895
+ "rewrite code",
896
+ "change validation state",
897
+ "give broad style advice unrelated to compliance",
898
+ ],
899
+ )
900
+ if context_for == "code-reviewer":
901
+ return (
902
+ [
903
+ "judge maintainability, correctness risk, testing, and safety",
904
+ "cite concrete evidence for risky changes",
905
+ ],
906
+ [
907
+ "change validation state",
908
+ "approve task completion",
909
+ ],
910
+ )
911
+ if context_for == "planner":
912
+ return (
913
+ [
914
+ "produce a reviewable plan body",
915
+ "surface assumptions and open questions",
916
+ ],
917
+ ["start implementation"],
918
+ )
919
+ if context_for == "full":
920
+ return (
921
+ ["use this dossier as the durable source of truth"],
922
+ ["assume chat history"],
923
+ )
924
+ return (
925
+ ["use this handoff as the source of truth"],
926
+ ["ignore recorded state"],
927
+ )
928
+
929
+
930
+ def _resolve_focus(
931
+ workspace_root: Path,
932
+ task_id: str,
933
+ request: ContextRequest,
934
+ todos: list[TaskTodo],
935
+ runs: list[TaskRunRecord],
936
+ changes: list[CodeChangeRecord],
937
+ ) -> dict[str, object]:
938
+ focused_todo = None
939
+ focused_run = None
940
+ focused_changes: list[dict[str, object]] = []
941
+
942
+ if request.scope == "todo":
943
+ normalized_todo_id = request.todo_id
944
+ for todo in todos:
945
+ if todo.id == normalized_todo_id:
946
+ focused_todo = todo
947
+ break
948
+ if focused_todo is None:
949
+ raise LaunchError(f"Todo not found: {request.todo_id}")
950
+ elif request.scope == "run":
951
+ assert request.focus_run_id is not None
952
+ focused_run = resolve_run(workspace_root, task_id, request.focus_run_id)
953
+ focused_changes = [
954
+ change.to_dict()
955
+ for change in changes
956
+ if change.implementation_run == focused_run.run_id
957
+ ]
958
+
959
+ return {
960
+ "todo_id": request.todo_id,
961
+ "todo": focused_todo.to_dict() if focused_todo is not None else None,
962
+ "focus_run_id": request.focus_run_id,
963
+ "run": focused_run.to_dict() if focused_run is not None else None,
964
+ "focused_changes": focused_changes,
965
+ }
966
+
967
+
968
+ def _todo_summary(
969
+ todos: list[TaskTodo], focused_todo_id: str | None
970
+ ) -> dict[str, object]:
971
+ focused_index = None
972
+ for index, todo in enumerate(todos, start=1):
973
+ if todo.id == focused_todo_id:
974
+ focused_index = index
975
+ break
976
+ return {
977
+ "total": len(todos),
978
+ "done": sum(1 for todo in todos if todo.done),
979
+ "open": sum(1 for todo in todos if not todo.done and todo.status != "skipped"),
980
+ "focused_index": focused_index,
981
+ }
982
+
983
+
984
+ def _latest_run(runs: list[TaskRunRecord], run_type: str) -> TaskRunRecord | None:
985
+ matches = [item for item in runs if item.run_type == run_type]
986
+ return matches[-1] if matches else None
987
+
988
+
989
+ def _run_to_dict(run: TaskRunRecord | None) -> dict[str, object] | None:
990
+ return run.to_dict() if run is not None else None
991
+
992
+
993
+ def _default_context_for(mode: HandoffMode) -> ContextFor:
994
+ return cast(
995
+ ContextFor,
996
+ {
997
+ "planning": "planner",
998
+ "implementation": "implementer",
999
+ "validation": "validator",
1000
+ "review": "reviewer",
1001
+ "full": "full",
1002
+ }[mode],
1003
+ )
1004
+
1005
+
1006
+ def _mode_for_context_for(context_for: ContextFor) -> HandoffMode:
1007
+ return cast(
1008
+ HandoffMode,
1009
+ {
1010
+ "planner": "planning",
1011
+ "implementer": "implementation",
1012
+ "validator": "validation",
1013
+ "reviewer": "review",
1014
+ "spec-reviewer": "review",
1015
+ "code-reviewer": "review",
1016
+ "full": "full",
1017
+ }[context_for],
1018
+ )
1019
+
1020
+
1021
+ def _canonical_mode(mode: str | None) -> str:
1022
+ if mode is None:
1023
+ return "full"
1024
+ return {
1025
+ "plan-context": "planning",
1026
+ "implementation-context": "implementation",
1027
+ "validation-context": "validation",
1028
+ "show": "full",
1029
+ }.get(mode, mode)