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/cli_misc.py ADDED
@@ -0,0 +1,984 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, cast
5
+
6
+ import typer
7
+
8
+ from taskledger.api.handoff import (
9
+ cancel_handoff_api,
10
+ claim_handoff_api,
11
+ close_handoff_api,
12
+ create_handoff,
13
+ list_all_handoffs,
14
+ render_handoff,
15
+ show_handoff,
16
+ )
17
+ from taskledger.api.introductions import (
18
+ create_introduction,
19
+ link_introduction,
20
+ list_introductions,
21
+ resolve_introduction,
22
+ )
23
+ from taskledger.api.locks import break_lock, list_locks, show_lock
24
+ from taskledger.api.tasks import (
25
+ add_file_link,
26
+ add_requirement,
27
+ add_todo,
28
+ can_perform,
29
+ list_file_links,
30
+ next_action,
31
+ next_todo,
32
+ reindex,
33
+ remove_file_link,
34
+ remove_requirement,
35
+ set_todo_done,
36
+ show_todo,
37
+ todo_status,
38
+ waive_requirement,
39
+ )
40
+ from taskledger.cli_common import (
41
+ TaskOption,
42
+ cli_state_from_context,
43
+ emit_error,
44
+ emit_payload,
45
+ launch_error_exit_code,
46
+ read_text_input,
47
+ render_json,
48
+ resolve_cli_task,
49
+ )
50
+ from taskledger.errors import LaunchError
51
+ from taskledger.services.doctor import (
52
+ inspect_v2_indexes,
53
+ inspect_v2_locks,
54
+ inspect_v2_project,
55
+ inspect_v2_schema,
56
+ )
57
+ from taskledger.storage.task_store import (
58
+ load_active_locks,
59
+ load_requirements,
60
+ load_todos,
61
+ )
62
+
63
+
64
+ def _todo_status_label(todo: dict[str, object]) -> str:
65
+ status = todo.get("status")
66
+ if isinstance(status, str) and status.strip():
67
+ return status
68
+ return "done" if todo.get("done") else "open"
69
+
70
+
71
+ def _todo_done_command_hint(todo: dict[str, object]) -> str | None:
72
+ if todo.get("done") or todo.get("status") == "done":
73
+ return None
74
+ todo_id = todo.get("id")
75
+ if not isinstance(todo_id, str) or not todo_id:
76
+ return None
77
+ return f'taskledger todo done {todo_id} --evidence "..."'
78
+
79
+
80
+ def _todo_detail_lines(todo: dict[str, object]) -> list[str]:
81
+ lines: list[str] = []
82
+ text = todo.get("text")
83
+ if isinstance(text, str) and text.strip():
84
+ lines.append(text.strip())
85
+ validation_hint = todo.get("validation_hint")
86
+ if isinstance(validation_hint, str) and validation_hint.strip():
87
+ if lines:
88
+ lines.append("")
89
+ lines.append("Validation hint:")
90
+ lines.append(validation_hint.strip())
91
+ done_command = _todo_done_command_hint(todo)
92
+ if done_command is not None:
93
+ if lines:
94
+ lines.append("")
95
+ lines.append("Done command:")
96
+ lines.append(done_command)
97
+ return lines
98
+
99
+
100
+ def register_todo_v2_commands(app: typer.Typer) -> None: # noqa: C901
101
+ @app.command("add")
102
+ def add_command(
103
+ ctx: typer.Context,
104
+ text: Annotated[str, typer.Option("--text")],
105
+ mandatory: Annotated[
106
+ bool | None,
107
+ typer.Option("--mandatory", help="Mark todo as mandatory gate."),
108
+ ] = None,
109
+ optional: Annotated[
110
+ bool,
111
+ typer.Option("--optional", help="Explicitly mark todo as optional."),
112
+ ] = False,
113
+ task_ref: TaskOption = None,
114
+ ) -> None:
115
+ state = cli_state_from_context(ctx)
116
+ try:
117
+ task = resolve_cli_task(state.cwd, task_ref)
118
+ resolved_mandatory: bool
119
+ if optional:
120
+ resolved_mandatory = False
121
+ elif mandatory is not None:
122
+ resolved_mandatory = mandatory
123
+ else:
124
+ locks = load_active_locks(state.cwd)
125
+ active_impl = any(
126
+ lock.task_id == task.id and lock.stage == "implementing"
127
+ for lock in locks
128
+ )
129
+ resolved_mandatory = active_impl
130
+ task = add_todo(state.cwd, task.id, text=text, mandatory=resolved_mandatory)
131
+ except LaunchError as exc:
132
+ emit_error(ctx, exc)
133
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
134
+ payload = {"kind": "task", "task": task.to_dict()}
135
+ if state.json_output:
136
+ emit_payload(ctx, payload, human=f"added todo on {task.id}")
137
+ else:
138
+ typer.echo(render_json(payload))
139
+
140
+ @app.command("list")
141
+ def list_command(
142
+ ctx: typer.Context,
143
+ task_ref: TaskOption = None,
144
+ ) -> None:
145
+ state = cli_state_from_context(ctx)
146
+ try:
147
+ task = resolve_cli_task(state.cwd, task_ref)
148
+ todos = load_todos(state.cwd, task.id).todos
149
+ except LaunchError as exc:
150
+ emit_error(ctx, exc)
151
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
152
+ payload = {
153
+ "kind": "todo_list",
154
+ "task_id": task.id,
155
+ "todos": [todo.to_dict() for todo in todos],
156
+ }
157
+ lines = ["TODOS"]
158
+ for todo in todos:
159
+ status = "done" if todo.done else "open"
160
+ lines.append(f"{todo.id} {status} {todo.text}")
161
+ emit_payload(
162
+ ctx, payload, human="\n".join(lines) if todos else "TODOS\n(empty)"
163
+ )
164
+
165
+ @app.command("done")
166
+ def done_command(
167
+ ctx: typer.Context,
168
+ todo_id: Annotated[str, typer.Argument(...)],
169
+ evidence: Annotated[str | None, typer.Option("--evidence")] = None,
170
+ artifact: Annotated[list[str] | None, typer.Option("--artifact")] = None,
171
+ change: Annotated[list[str] | None, typer.Option("--change")] = None,
172
+ task_ref: TaskOption = None,
173
+ ) -> None:
174
+ _emit_todo_update(
175
+ ctx,
176
+ task_ref,
177
+ todo_id,
178
+ done=True,
179
+ evidence=evidence,
180
+ artifacts=tuple(artifact or ()),
181
+ changes=tuple(change or ()),
182
+ )
183
+
184
+ @app.command("undone")
185
+ def undone_command(
186
+ ctx: typer.Context,
187
+ todo_id: Annotated[str, typer.Argument(...)],
188
+ task_ref: TaskOption = None,
189
+ ) -> None:
190
+ _emit_todo_update(ctx, task_ref, todo_id, done=False)
191
+
192
+ @app.command("show")
193
+ def show_command(
194
+ ctx: typer.Context,
195
+ todo_id: Annotated[str, typer.Argument(...)],
196
+ task_ref: TaskOption = None,
197
+ ) -> None:
198
+ state = cli_state_from_context(ctx)
199
+ try:
200
+ task = resolve_cli_task(state.cwd, task_ref)
201
+ payload = show_todo(state.cwd, task.id, todo_id)
202
+ except LaunchError as exc:
203
+ emit_error(ctx, exc)
204
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
205
+ todo = payload["todo"]
206
+ assert isinstance(todo, dict)
207
+ lines = [f"{todo['id']} {_todo_status_label(todo)}", *_todo_detail_lines(todo)]
208
+ emit_payload(ctx, payload, human="\n".join(lines))
209
+
210
+ @app.command("status")
211
+ def status_command(
212
+ ctx: typer.Context,
213
+ task_ref: TaskOption = None,
214
+ ) -> None:
215
+ state = cli_state_from_context(ctx)
216
+ try:
217
+ task = resolve_cli_task(state.cwd, task_ref)
218
+ payload = todo_status(state.cwd, task.id)
219
+ except LaunchError as exc:
220
+ emit_error(ctx, exc)
221
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
222
+
223
+ # Build human-readable output
224
+ total = payload.get("total", 0)
225
+ done = payload.get("done", 0)
226
+ can_finish = payload.get("can_finish_implementation", False)
227
+ lines = [f"TODOS {payload['task_id']} {done}/{total} done"]
228
+
229
+ todos = load_todos(state.cwd, task.id).todos
230
+ for todo in todos:
231
+ status_mark = "[x]" if todo.done else "[ ]"
232
+ lines.append(f"{status_mark} {todo.id} {todo.text}")
233
+
234
+ if can_finish:
235
+ lines.append("\nFinish: Ready to implement finish.")
236
+ else:
237
+ lines.append(
238
+ f"\nFinish blocked: "
239
+ f"{cast(int, total) - cast(int, done)} todos are not done."
240
+ )
241
+
242
+ emit_payload(ctx, payload, human="\n".join(lines))
243
+
244
+ @app.command("next")
245
+ def next_command(
246
+ ctx: typer.Context,
247
+ task_ref: TaskOption = None,
248
+ ) -> None:
249
+ state = cli_state_from_context(ctx)
250
+ try:
251
+ task = resolve_cli_task(state.cwd, task_ref)
252
+ payload = next_todo(state.cwd, task.id)
253
+ except LaunchError as exc:
254
+ emit_error(ctx, exc)
255
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
256
+
257
+ # Build human-readable output
258
+ next_todo_id = payload.get("next_todo_id")
259
+ if next_todo_id is None:
260
+ human = "No unfinished todos. Ready to finish implementation."
261
+ else:
262
+ next_todo_obj = cast(dict[str, object], payload.get("next_todo", {}))
263
+ lines = [f"Next todo: {next_todo_id}", *_todo_detail_lines(next_todo_obj)]
264
+ human = "\n".join(lines)
265
+
266
+ emit_payload(ctx, payload, human=human)
267
+
268
+
269
+ def register_intro_v2_commands(app: typer.Typer) -> None:
270
+ @app.command("create")
271
+ def create_command(
272
+ ctx: typer.Context,
273
+ title: Annotated[str, typer.Argument(...)],
274
+ text: Annotated[str | None, typer.Option("--text")] = None,
275
+ from_file: Annotated[Path | None, typer.Option("--from-file")] = None,
276
+ ) -> None:
277
+ state = cli_state_from_context(ctx)
278
+ try:
279
+ intro = create_introduction(
280
+ state.cwd,
281
+ title=title,
282
+ body=read_text_input(text=text, from_file=from_file),
283
+ )
284
+ except LaunchError as exc:
285
+ emit_error(ctx, exc)
286
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
287
+ emit_payload(ctx, intro.to_dict(), human=f"created intro {intro.id}")
288
+
289
+ @app.command("list")
290
+ def list_command(ctx: typer.Context) -> None:
291
+ state = cli_state_from_context(ctx)
292
+ payload = [intro.to_dict() for intro in list_introductions(state.cwd)]
293
+ human = "\n".join(
294
+ ["INTRODUCTIONS", *[f"{intro['id']} {intro['slug']}" for intro in payload]]
295
+ )
296
+ emit_payload(
297
+ ctx,
298
+ payload,
299
+ human=human if payload else "INTRODUCTIONS\n(empty)",
300
+ )
301
+
302
+ @app.command("show")
303
+ def show_command(
304
+ ctx: typer.Context,
305
+ intro_ref: Annotated[str, typer.Argument(...)],
306
+ ) -> None:
307
+ state = cli_state_from_context(ctx)
308
+ try:
309
+ intro = resolve_introduction(state.cwd, intro_ref)
310
+ except LaunchError as exc:
311
+ emit_error(ctx, exc)
312
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
313
+ emit_payload(ctx, intro.to_dict(), human=intro.body)
314
+
315
+ @app.command("link")
316
+ def link_command(
317
+ ctx: typer.Context,
318
+ intro_ref: Annotated[str, typer.Argument(...)],
319
+ task_ref: TaskOption = None,
320
+ ) -> None:
321
+ state = cli_state_from_context(ctx)
322
+ try:
323
+ task = resolve_cli_task(state.cwd, task_ref)
324
+ task = link_introduction(state.cwd, task.id, intro_ref)
325
+ except LaunchError as exc:
326
+ emit_error(ctx, exc)
327
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
328
+ emit_payload(ctx, task.to_dict(), human=f"linked intro to {task.id}")
329
+
330
+
331
+ def register_file_v2_commands(app: typer.Typer) -> None:
332
+ @app.command("add")
333
+ def add_command(
334
+ ctx: typer.Context,
335
+ path: Annotated[str, typer.Option("--path")],
336
+ kind: Annotated[str, typer.Option("--kind")] = "code",
337
+ label: Annotated[str | None, typer.Option("--label")] = None,
338
+ required_for_validation: Annotated[
339
+ bool,
340
+ typer.Option("--required-for-validation"),
341
+ ] = False,
342
+ task_ref: TaskOption = None,
343
+ ) -> None:
344
+ state = cli_state_from_context(ctx)
345
+ try:
346
+ task = resolve_cli_task(state.cwd, task_ref)
347
+ task = add_file_link(
348
+ state.cwd,
349
+ task.id,
350
+ path=path,
351
+ kind=kind,
352
+ label=label,
353
+ required_for_validation=required_for_validation,
354
+ )
355
+ except LaunchError as exc:
356
+ emit_error(ctx, exc)
357
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
358
+ emit_payload(ctx, task.to_dict(), human=f"linked file on {task.id}")
359
+
360
+ @app.command("remove")
361
+ def remove_command(
362
+ ctx: typer.Context,
363
+ path: Annotated[str, typer.Option("--path")],
364
+ task_ref: TaskOption = None,
365
+ ) -> None:
366
+ state = cli_state_from_context(ctx)
367
+ try:
368
+ task = resolve_cli_task(state.cwd, task_ref)
369
+ task = remove_file_link(state.cwd, task.id, path=path)
370
+ except LaunchError as exc:
371
+ emit_error(ctx, exc)
372
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
373
+ emit_payload(ctx, task.to_dict(), human=f"unlinked file on {task.id}")
374
+
375
+ @app.command("list")
376
+ def list_command(
377
+ ctx: typer.Context,
378
+ task_ref: TaskOption = None,
379
+ ) -> None:
380
+ state = cli_state_from_context(ctx)
381
+ try:
382
+ task = resolve_cli_task(state.cwd, task_ref)
383
+ payload = list_file_links(state.cwd, task.id)
384
+ except LaunchError as exc:
385
+ emit_error(ctx, exc)
386
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
387
+ file_links = payload["file_links"]
388
+ assert isinstance(file_links, list)
389
+ lines = ["FILES"]
390
+ for item in file_links:
391
+ if isinstance(item, dict):
392
+ lines.append(f"@{item.get('path')} [{item.get('kind')}]")
393
+ emit_payload(
394
+ ctx, payload, human="\n".join(lines) if file_links else "FILES\n(empty)"
395
+ )
396
+
397
+
398
+ def register_link_v2_commands(app: typer.Typer) -> None:
399
+ @app.command("add")
400
+ def add_command(
401
+ ctx: typer.Context,
402
+ url: Annotated[str, typer.Option("--url")],
403
+ label: Annotated[str | None, typer.Option("--label")] = None,
404
+ task_ref: TaskOption = None,
405
+ ) -> None:
406
+ _emit_link_add(
407
+ ctx,
408
+ task_ref,
409
+ path=url,
410
+ kind="other",
411
+ label=label,
412
+ required_for_validation=False,
413
+ )
414
+
415
+ @app.command("remove")
416
+ def remove_command(
417
+ ctx: typer.Context,
418
+ link_ref: Annotated[str, typer.Argument(help="Link URL or path.")],
419
+ task_ref: TaskOption = None,
420
+ ) -> None:
421
+ _emit_link_remove(ctx, task_ref, path=link_ref)
422
+
423
+ @app.command("list")
424
+ def list_command(
425
+ ctx: typer.Context,
426
+ task_ref: TaskOption = None,
427
+ ) -> None:
428
+ _emit_link_list(ctx, task_ref)
429
+
430
+
431
+ def register_require_v2_commands(app: typer.Typer) -> None:
432
+ @app.command("add")
433
+ def add_command(
434
+ ctx: typer.Context,
435
+ required_task_ref: Annotated[str, typer.Argument(...)],
436
+ task_ref: TaskOption = None,
437
+ ) -> None:
438
+ state = cli_state_from_context(ctx)
439
+ try:
440
+ task = resolve_cli_task(state.cwd, task_ref)
441
+ task = add_requirement(state.cwd, task.id, required_task_ref)
442
+ except LaunchError as exc:
443
+ emit_error(ctx, exc)
444
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
445
+ emit_payload(ctx, task.to_dict(), human=f"added requirement on {task.id}")
446
+
447
+ @app.command("list")
448
+ def list_command(
449
+ ctx: typer.Context,
450
+ task_ref: TaskOption = None,
451
+ ) -> None:
452
+ state = cli_state_from_context(ctx)
453
+ try:
454
+ task = resolve_cli_task(state.cwd, task_ref)
455
+ requirements = load_requirements(state.cwd, task.id).requirements
456
+ except LaunchError as exc:
457
+ emit_error(ctx, exc)
458
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
459
+ refs = [item.task_id for item in requirements]
460
+ emit_payload(
461
+ ctx,
462
+ [item.to_dict() for item in requirements],
463
+ human="\n".join(["REQUIREMENTS", *refs])
464
+ if refs
465
+ else "REQUIREMENTS\n(empty)",
466
+ )
467
+
468
+ @app.command("remove")
469
+ def remove_command(
470
+ ctx: typer.Context,
471
+ required_task_ref: Annotated[str, typer.Argument(...)],
472
+ task_ref: TaskOption = None,
473
+ ) -> None:
474
+ state = cli_state_from_context(ctx)
475
+ try:
476
+ task = resolve_cli_task(state.cwd, task_ref)
477
+ task = remove_requirement(state.cwd, task.id, required_task_ref)
478
+ except LaunchError as exc:
479
+ emit_error(ctx, exc)
480
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
481
+ emit_payload(ctx, task.to_dict(), human=f"removed requirement on {task.id}")
482
+
483
+ @app.command("waive")
484
+ def waive_command(
485
+ ctx: typer.Context,
486
+ required_task_ref: Annotated[str, typer.Argument(...)],
487
+ actor: Annotated[str, typer.Option("--actor")] = "user",
488
+ reason: Annotated[str, typer.Option("--reason")] = "",
489
+ task_ref: TaskOption = None,
490
+ ) -> None:
491
+ state = cli_state_from_context(ctx)
492
+ try:
493
+ task = resolve_cli_task(state.cwd, task_ref)
494
+ task = waive_requirement(
495
+ state.cwd,
496
+ task.id,
497
+ required_task_ref,
498
+ actor_type=actor,
499
+ reason=reason,
500
+ )
501
+ except LaunchError as exc:
502
+ emit_error(ctx, exc)
503
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
504
+ emit_payload(ctx, task.to_dict(), human=f"waived requirement on {task.id}")
505
+
506
+
507
+ def _emit_link_add(
508
+ ctx: typer.Context,
509
+ task_ref: str | None,
510
+ *,
511
+ path: str,
512
+ kind: str,
513
+ label: str | None,
514
+ required_for_validation: bool,
515
+ ) -> None:
516
+ state = cli_state_from_context(ctx)
517
+ try:
518
+ task = resolve_cli_task(state.cwd, task_ref)
519
+ task = add_file_link(
520
+ state.cwd,
521
+ task.id,
522
+ path=path,
523
+ kind=kind,
524
+ label=label,
525
+ required_for_validation=required_for_validation,
526
+ )
527
+ except LaunchError as exc:
528
+ emit_error(ctx, exc)
529
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
530
+ emit_payload(ctx, task.to_dict(), human=f"linked file on {task.id}")
531
+
532
+
533
+ def _emit_link_remove(ctx: typer.Context, task_ref: str | None, *, path: str) -> None:
534
+ state = cli_state_from_context(ctx)
535
+ try:
536
+ task = resolve_cli_task(state.cwd, task_ref)
537
+ task = remove_file_link(state.cwd, task.id, path=path)
538
+ except LaunchError as exc:
539
+ emit_error(ctx, exc)
540
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
541
+ emit_payload(ctx, task.to_dict(), human=f"unlinked file on {task.id}")
542
+
543
+
544
+ def _emit_link_list(ctx: typer.Context, task_ref: str | None) -> None:
545
+ state = cli_state_from_context(ctx)
546
+ try:
547
+ task = resolve_cli_task(state.cwd, task_ref)
548
+ payload = list_file_links(state.cwd, task.id)
549
+ except LaunchError as exc:
550
+ emit_error(ctx, exc)
551
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
552
+ file_links = payload["file_links"]
553
+ assert isinstance(file_links, list)
554
+ lines = ["FILES"]
555
+ for item in file_links:
556
+ if isinstance(item, dict):
557
+ lines.append(f"@{item.get('path')} [{item.get('kind')}]")
558
+ emit_payload(
559
+ ctx, payload, human="\n".join(lines) if file_links else "FILES\n(empty)"
560
+ )
561
+
562
+
563
+ def register_lock_v2_commands(app: typer.Typer) -> None:
564
+ @app.command("show")
565
+ def show_command(
566
+ ctx: typer.Context,
567
+ task_ref: TaskOption = None,
568
+ ) -> None:
569
+ state = cli_state_from_context(ctx)
570
+ try:
571
+ task = resolve_cli_task(state.cwd, task_ref)
572
+ payload = show_lock(state.cwd, task.id)
573
+ except LaunchError as exc:
574
+ emit_error(ctx, exc)
575
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
576
+ emit_payload(ctx, payload, human=str(payload["lock"]))
577
+
578
+ @app.command("break")
579
+ def break_command(
580
+ ctx: typer.Context,
581
+ reason: Annotated[str, typer.Option("--reason")],
582
+ task_ref: TaskOption = None,
583
+ ) -> None:
584
+ state = cli_state_from_context(ctx)
585
+ try:
586
+ task = resolve_cli_task(state.cwd, task_ref)
587
+ payload = break_lock(state.cwd, task.id, reason=reason)
588
+ except LaunchError as exc:
589
+ emit_error(ctx, exc)
590
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
591
+ emit_payload(ctx, payload, human=f"broke lock for {payload['task_id']}")
592
+
593
+ @app.command("list")
594
+ def list_command(ctx: typer.Context) -> None:
595
+ state = cli_state_from_context(ctx)
596
+ try:
597
+ payload = list_locks(state.cwd)
598
+ except LaunchError as exc:
599
+ emit_error(ctx, exc)
600
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
601
+ locks = payload["locks"]
602
+ assert isinstance(locks, list)
603
+ lines = ["LOCKS"]
604
+ for item in locks:
605
+ if isinstance(item, dict):
606
+ lines.append(
607
+ f"{item.get('task_id')} {item.get('stage')} {item.get('run_id')}"
608
+ )
609
+ emit_payload(
610
+ ctx, payload, human="\n".join(lines) if locks else "LOCKS\n(empty)"
611
+ )
612
+
613
+
614
+ def register_handoff_v2_commands(app: typer.Typer) -> None:
615
+ @app.command("create")
616
+ def create_command(
617
+ ctx: typer.Context,
618
+ mode: Annotated[str, typer.Option("--mode")],
619
+ context_for: Annotated[str | None, typer.Option("--for")] = None,
620
+ scope: Annotated[str | None, typer.Option("--scope")] = None,
621
+ todo_id: Annotated[str | None, typer.Option("--todo")] = None,
622
+ focus_run_id: Annotated[str | None, typer.Option("--run")] = None,
623
+ intended_actor: Annotated[str | None, typer.Option("--intended-actor")] = None,
624
+ intended_harness: Annotated[
625
+ str | None, typer.Option("--intended-harness")
626
+ ] = None,
627
+ summary: Annotated[str | None, typer.Option("--summary")] = None,
628
+ next_action: Annotated[str | None, typer.Option("--next-action")] = None,
629
+ task_ref: TaskOption = None,
630
+ ) -> None:
631
+ state = cli_state_from_context(ctx)
632
+ try:
633
+ task = resolve_cli_task(state.cwd, task_ref)
634
+ payload = create_handoff(
635
+ state.cwd,
636
+ task.id,
637
+ mode=mode,
638
+ context_for=context_for,
639
+ scope=scope,
640
+ todo_id=todo_id,
641
+ focus_run_id=focus_run_id,
642
+ intended_actor_type=intended_actor,
643
+ intended_harness=intended_harness,
644
+ summary=summary,
645
+ next_action=next_action,
646
+ )
647
+ except LaunchError as exc:
648
+ emit_error(ctx, exc)
649
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
650
+ emit_payload(ctx, payload, human=f"created handoff {payload['handoff_id']}")
651
+
652
+ @app.command("list")
653
+ def list_handoff_command(
654
+ ctx: typer.Context,
655
+ task_ref: TaskOption = None,
656
+ ) -> None:
657
+ state = cli_state_from_context(ctx)
658
+ try:
659
+ task = resolve_cli_task(state.cwd, task_ref)
660
+ handoffs = list_all_handoffs(state.cwd, task.id)
661
+ payload = {"kind": "handoff_list", "task_id": task.id, "handoffs": handoffs}
662
+ except LaunchError as exc:
663
+ emit_error(ctx, exc)
664
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
665
+ emit_payload(
666
+ ctx,
667
+ payload,
668
+ human="\n".join(str(h["handoff_id"]) for h in handoffs),
669
+ )
670
+
671
+ @app.command("claim")
672
+ def claim_command(
673
+ ctx: typer.Context,
674
+ handoff_id: Annotated[str, typer.Argument(help="Handoff id.")],
675
+ task_ref: TaskOption = None,
676
+ ) -> None:
677
+ state = cli_state_from_context(ctx)
678
+ try:
679
+ task = resolve_cli_task(state.cwd, task_ref)
680
+ payload = claim_handoff_api(state.cwd, task.id, handoff_id)
681
+ except LaunchError as exc:
682
+ emit_error(ctx, exc)
683
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
684
+ emit_payload(ctx, payload, human=f"claimed handoff {payload['handoff_id']}")
685
+
686
+ @app.command("close")
687
+ def close_command(
688
+ ctx: typer.Context,
689
+ handoff_id: Annotated[str, typer.Argument(help="Handoff id.")],
690
+ reason: Annotated[str | None, typer.Option("--reason")] = None,
691
+ task_ref: TaskOption = None,
692
+ ) -> None:
693
+ state = cli_state_from_context(ctx)
694
+ try:
695
+ task = resolve_cli_task(state.cwd, task_ref)
696
+ payload = close_handoff_api(state.cwd, task.id, handoff_id, reason=reason)
697
+ except LaunchError as exc:
698
+ emit_error(ctx, exc)
699
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
700
+ emit_payload(ctx, payload, human=f"closed handoff {payload['handoff_id']}")
701
+
702
+ @app.command("cancel")
703
+ def cancel_command(
704
+ ctx: typer.Context,
705
+ handoff_id: Annotated[str, typer.Argument(help="Handoff id.")],
706
+ reason: Annotated[str | None, typer.Option("--reason")] = None,
707
+ task_ref: TaskOption = None,
708
+ ) -> None:
709
+ state = cli_state_from_context(ctx)
710
+ try:
711
+ task = resolve_cli_task(state.cwd, task_ref)
712
+ payload = cancel_handoff_api(state.cwd, task.id, handoff_id, reason=reason)
713
+ except LaunchError as exc:
714
+ emit_error(ctx, exc)
715
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
716
+ emit_payload(ctx, payload, human=f"cancelled handoff {payload['handoff_id']}")
717
+
718
+ @app.command("show")
719
+ def show_command(
720
+ ctx: typer.Context,
721
+ handoff_id: Annotated[str, typer.Argument(help="Handoff id.")],
722
+ format_name: Annotated[str, typer.Option("--format")] = "text",
723
+ task_ref: TaskOption = None,
724
+ ) -> None:
725
+ state = cli_state_from_context(ctx)
726
+ try:
727
+ task = resolve_cli_task(state.cwd, task_ref)
728
+ payload = show_handoff(
729
+ state.cwd, task.id, handoff_id, format_name=format_name
730
+ )
731
+ except LaunchError as exc:
732
+ emit_error(ctx, exc)
733
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
734
+ human = (
735
+ payload
736
+ if isinstance(payload, str)
737
+ else render_json(payload)
738
+ if format_name == "json"
739
+ else None
740
+ )
741
+ emit_payload(ctx, payload, human=human)
742
+
743
+ @app.command("plan-context")
744
+ def plan_context_command(
745
+ ctx: typer.Context,
746
+ format_name: Annotated[str, typer.Option("--format")] = "text",
747
+ task_ref: TaskOption = None,
748
+ ) -> None:
749
+ _emit_handoff(
750
+ ctx,
751
+ task_ref,
752
+ mode="plan-context",
753
+ format_name=format_name,
754
+ )
755
+
756
+ @app.command("implementation-context")
757
+ def implementation_context_command(
758
+ ctx: typer.Context,
759
+ format_name: Annotated[str, typer.Option("--format")] = "text",
760
+ task_ref: TaskOption = None,
761
+ ) -> None:
762
+ _emit_handoff(
763
+ ctx,
764
+ task_ref,
765
+ mode="implementation-context",
766
+ format_name=format_name,
767
+ )
768
+
769
+ @app.command("validation-context")
770
+ def validation_context_command(
771
+ ctx: typer.Context,
772
+ format_name: Annotated[str, typer.Option("--format")] = "text",
773
+ task_ref: TaskOption = None,
774
+ ) -> None:
775
+ _emit_handoff(
776
+ ctx,
777
+ task_ref,
778
+ mode="validation-context",
779
+ format_name=format_name,
780
+ )
781
+
782
+
783
+ def emit_next_action_command(
784
+ ctx: typer.Context,
785
+ task_ref: str | None,
786
+ ) -> None:
787
+ state = cli_state_from_context(ctx)
788
+ try:
789
+ task = resolve_cli_task(state.cwd, task_ref)
790
+ payload = next_action(state.cwd, task.id)
791
+ except LaunchError as exc:
792
+ emit_error(ctx, exc)
793
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
794
+ emit_payload(ctx, payload, human=_next_action_human(payload))
795
+
796
+
797
+ def _next_action_human(payload: dict[str, object]) -> str:
798
+ lines = [f"{payload['action']}: {payload['reason']}"]
799
+
800
+ next_item = payload.get("next_item")
801
+ if isinstance(next_item, dict):
802
+ kind = next_item.get("kind")
803
+ item_id = next_item.get("id")
804
+ text = next_item.get("text")
805
+ if kind and kind != "none":
806
+ label = f"Next {kind}:"
807
+ if item_id and text:
808
+ lines.append(f"{label} {item_id} -- {text}")
809
+ elif item_id:
810
+ lines.append(f"{label} {item_id}")
811
+
812
+ command = payload.get("next_command")
813
+ if command:
814
+ lines.append(f"Command: {command}")
815
+
816
+ commands = payload.get("commands")
817
+ if isinstance(commands, list):
818
+ for item in commands:
819
+ if not isinstance(item, dict) or item.get("primary"):
820
+ continue
821
+ command_label = item.get("label")
822
+ command_text = item.get("command")
823
+ if isinstance(command_label, str) and isinstance(command_text, str):
824
+ lines.append(f"{command_label}: {command_text}")
825
+
826
+ progress = payload.get("progress")
827
+ if isinstance(progress, dict):
828
+ todos = progress.get("todos")
829
+ if isinstance(todos, dict):
830
+ lines.append(
831
+ f"Progress: {todos.get('done', 0)}/{todos.get('total', 0)} todos done"
832
+ )
833
+ questions = progress.get("questions")
834
+ if isinstance(questions, dict) and questions.get("required_open") is not None:
835
+ lines.append(f"Open required questions: {questions.get('required_open')}")
836
+ validation = progress.get("validation")
837
+ if isinstance(validation, dict):
838
+ lines.append(
839
+ "Validation progress: "
840
+ f"{validation.get('satisfied', 0)}/"
841
+ f"{validation.get('total', 0)} satisfied"
842
+ )
843
+
844
+ blockers = payload.get("blocking")
845
+ if isinstance(blockers, list):
846
+ for blocker in blockers:
847
+ if isinstance(blocker, dict):
848
+ msg = blocker.get("message")
849
+ if msg:
850
+ lines.append(f"Blocker: {msg}")
851
+
852
+ return "\n".join(lines)
853
+
854
+
855
+ def emit_can_command(ctx: typer.Context, task_ref: str | None, action: str) -> None:
856
+ state = cli_state_from_context(ctx)
857
+ try:
858
+ task = resolve_cli_task(state.cwd, task_ref)
859
+ payload = can_perform(state.cwd, task.id, action)
860
+ except LaunchError as exc:
861
+ emit_error(ctx, exc)
862
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
863
+ prefix = "yes" if payload["ok"] else "no"
864
+ emit_payload(ctx, payload, human=f"{prefix}: {payload['reason']}")
865
+
866
+
867
+ def emit_reindex_command(ctx: typer.Context) -> None:
868
+ state = cli_state_from_context(ctx)
869
+ payload = reindex(state.cwd)
870
+ emit_payload(ctx, payload, human="reindexed v2 task state")
871
+
872
+
873
+ def emit_doctor_command(ctx: typer.Context) -> None:
874
+ state = cli_state_from_context(ctx)
875
+ payload = inspect_v2_project(state.cwd)
876
+ emit_payload(
877
+ ctx,
878
+ payload,
879
+ human=(
880
+ f"healthy: {payload['healthy']} "
881
+ f"errors: {len(cast(list[object], payload['errors']))}"
882
+ ),
883
+ )
884
+
885
+
886
+ def emit_doctor_locks_command(ctx: typer.Context) -> None:
887
+ state = cli_state_from_context(ctx)
888
+ payload = inspect_v2_locks(state.cwd)
889
+ emit_payload(
890
+ ctx,
891
+ payload,
892
+ human=_expired_locks_human(payload["expired_locks"]),
893
+ )
894
+
895
+
896
+ def emit_doctor_schema_command(ctx: typer.Context) -> None:
897
+ state = cli_state_from_context(ctx)
898
+ payload = inspect_v2_schema(state.cwd)
899
+ emit_payload(
900
+ ctx,
901
+ payload,
902
+ human=f"schema healthy: {payload['healthy']}",
903
+ )
904
+
905
+
906
+ def emit_doctor_indexes_command(ctx: typer.Context) -> None:
907
+ state = cli_state_from_context(ctx)
908
+ payload = inspect_v2_indexes(state.cwd)
909
+ emit_payload(
910
+ ctx,
911
+ payload,
912
+ human=f"indexes healthy: {payload['healthy']}",
913
+ )
914
+
915
+
916
+ def _emit_todo_update(
917
+ ctx: typer.Context,
918
+ task_ref: str | None,
919
+ todo_id: str,
920
+ *,
921
+ done: bool,
922
+ evidence: str | None = None,
923
+ artifacts: tuple[str, ...] = (),
924
+ changes: tuple[str, ...] = (),
925
+ ) -> None:
926
+ state = cli_state_from_context(ctx)
927
+ try:
928
+ task = resolve_cli_task(state.cwd, task_ref)
929
+ task = set_todo_done(
930
+ state.cwd,
931
+ task.id,
932
+ todo_id,
933
+ done=done,
934
+ evidence=evidence,
935
+ artifacts=artifacts,
936
+ changes=changes,
937
+ )
938
+ except LaunchError as exc:
939
+ emit_error(ctx, exc)
940
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
941
+ payload = {"kind": "task", "task": task.to_dict()}
942
+ if state.json_output:
943
+ emit_payload(ctx, payload, human=f"updated todo {todo_id}")
944
+ else:
945
+ typer.echo(render_json(payload))
946
+
947
+
948
+ def _emit_handoff(
949
+ ctx: typer.Context,
950
+ task_ref: str | None,
951
+ *,
952
+ mode: str,
953
+ format_name: str,
954
+ ) -> None:
955
+ state = cli_state_from_context(ctx)
956
+ try:
957
+ task = resolve_cli_task(state.cwd, task_ref)
958
+ payload = render_handoff(
959
+ state.cwd,
960
+ task.id,
961
+ mode=mode,
962
+ format_name=format_name,
963
+ )
964
+ except LaunchError as exc:
965
+ emit_error(ctx, exc)
966
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
967
+ human = (
968
+ payload
969
+ if isinstance(payload, str)
970
+ else render_json(payload)
971
+ if format_name == "json"
972
+ else None
973
+ )
974
+ emit_payload(ctx, payload, human=human)
975
+
976
+
977
+ def _expired_locks_human(payload: object) -> str:
978
+ if not isinstance(payload, list) or not payload:
979
+ return "EXPIRED LOCKS\n(empty)"
980
+ lines = ["EXPIRED LOCKS"]
981
+ for item in payload:
982
+ if isinstance(item, dict):
983
+ lines.append(str(item.get("task_id")))
984
+ return "\n".join(lines)