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,350 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Annotated, Literal, cast
6
+
7
+ import typer
8
+ import yaml
9
+
10
+ from taskledger.api.questions import (
11
+ add_question,
12
+ answer_question,
13
+ answer_questions,
14
+ dismiss_question,
15
+ list_open_questions,
16
+ question_status,
17
+ )
18
+ from taskledger.cli_common import (
19
+ cli_state_from_context,
20
+ emit_error,
21
+ emit_payload,
22
+ launch_error_exit_code,
23
+ read_text_input,
24
+ resolve_cli_task,
25
+ )
26
+ from taskledger.domain.models import ActorRef
27
+ from taskledger.errors import LaunchError
28
+ from taskledger.storage.task_store import list_questions
29
+
30
+ _QUESTION_ANSWER_RE = re.compile(r"^(q-\d+):\s*(.+)$")
31
+
32
+
33
+ class _UniqueKeyLoader(yaml.SafeLoader):
34
+ pass
35
+
36
+
37
+ def _construct_mapping_unique_keys(
38
+ loader: yaml.Loader,
39
+ node: yaml.nodes.MappingNode,
40
+ deep: bool = False,
41
+ ) -> dict[object, object]:
42
+ mapping: dict[object, object] = {}
43
+ for key_node, value_node in node.value:
44
+ key = loader.construct_object(key_node, deep=deep)
45
+ if key in mapping:
46
+ raise LaunchError(f"Duplicate key in answers input: {key}")
47
+ mapping[key] = loader.construct_object(value_node, deep=deep)
48
+ return mapping
49
+
50
+
51
+ _UniqueKeyLoader.add_constructor(
52
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
53
+ _construct_mapping_unique_keys,
54
+ )
55
+
56
+
57
+ def _parse_answer_many_input(raw: str) -> dict[str, str]:
58
+ try:
59
+ parsed = yaml.load(raw, Loader=_UniqueKeyLoader)
60
+ except yaml.YAMLError as exc:
61
+ raise LaunchError(f"Invalid answers YAML: {exc}") from exc
62
+ if isinstance(parsed, dict):
63
+ raw_answers = parsed.get("answers", parsed)
64
+ if not isinstance(raw_answers, dict):
65
+ raise LaunchError("answers must be a mapping of question ids to text.")
66
+ answers: dict[str, str] = {}
67
+ for key, value in raw_answers.items():
68
+ question_id = str(key).strip()
69
+ if not re.fullmatch(r"q-\d+", question_id):
70
+ raise LaunchError(f"Invalid question id in answers input: {key}")
71
+ if not isinstance(value, str):
72
+ raise LaunchError(f"Answer for {question_id} must be text.")
73
+ answers[question_id] = value
74
+ return answers
75
+ answers = {}
76
+ for line_number, line in enumerate(raw.splitlines(), start=1):
77
+ stripped = line.strip()
78
+ if not stripped:
79
+ continue
80
+ match = _QUESTION_ANSWER_RE.match(stripped)
81
+ if match is None:
82
+ raise LaunchError(
83
+ "Plain answer input must use 'q-0001: answer' lines; "
84
+ f"line {line_number} was invalid."
85
+ )
86
+ question_id, answer = match.groups()
87
+ if question_id in answers:
88
+ raise LaunchError(f"Duplicate question id in answers input: {question_id}")
89
+ answers[question_id] = answer
90
+ if not answers:
91
+ raise LaunchError("At least one answer is required.")
92
+ return answers
93
+
94
+
95
+ def _add_command(
96
+ ctx: typer.Context,
97
+ text: Annotated[str, typer.Option("--text")],
98
+ required_for_plan: Annotated[
99
+ bool,
100
+ typer.Option("--required-for-plan"),
101
+ ] = False,
102
+ task_ref: Annotated[
103
+ str | None,
104
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
105
+ ] = None,
106
+ ) -> None:
107
+ state = cli_state_from_context(ctx)
108
+ try:
109
+ task = resolve_cli_task(state.cwd, task_ref)
110
+ question = add_question(
111
+ state.cwd,
112
+ task.id,
113
+ text=text,
114
+ required_for_plan=required_for_plan,
115
+ )
116
+ except LaunchError as exc:
117
+ emit_error(ctx, exc)
118
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
119
+ emit_payload(ctx, question.to_dict(), human=f"added question {question.id}")
120
+
121
+
122
+ def _list_command(
123
+ ctx: typer.Context,
124
+ status: Annotated[
125
+ str | None,
126
+ typer.Option(
127
+ "--status",
128
+ help="Comma-separated status filter, e.g. answered,dismissed.",
129
+ ),
130
+ ] = None,
131
+ task_ref: Annotated[
132
+ str | None,
133
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
134
+ ] = None,
135
+ ) -> None:
136
+ state = cli_state_from_context(ctx)
137
+ try:
138
+ task = resolve_cli_task(state.cwd, task_ref)
139
+ all_questions = list_questions(state.cwd, task.id)
140
+ except LaunchError as exc:
141
+ emit_error(ctx, exc)
142
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
143
+ status_filter: set[str] | None = None
144
+ if status is not None:
145
+ status_filter = {s.strip() for s in status.split(",") if s.strip()}
146
+ filtered = (
147
+ [q for q in all_questions if q.status in status_filter]
148
+ if status_filter is not None
149
+ else all_questions
150
+ )
151
+ payload = [item.to_dict() for item in filtered]
152
+ lines = ["QUESTIONS"]
153
+ for item in payload:
154
+ lines.append(f"{item['id']} {item['status']} {item['question']}")
155
+ emit_payload(
156
+ ctx,
157
+ payload,
158
+ human="\n".join(lines) if payload else "QUESTIONS\n(empty)",
159
+ )
160
+
161
+
162
+ def _answer_command(
163
+ ctx: typer.Context,
164
+ question_id: Annotated[str, typer.Argument(..., help="Question id.")],
165
+ text: Annotated[str, typer.Option("--text")],
166
+ actor: Annotated[str, typer.Option("--actor")] = "user",
167
+ task_ref: Annotated[
168
+ str | None,
169
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
170
+ ] = None,
171
+ ) -> None:
172
+ state = cli_state_from_context(ctx)
173
+ try:
174
+ task = resolve_cli_task(state.cwd, task_ref)
175
+ question = answer_question(
176
+ state.cwd,
177
+ task.id,
178
+ question_id,
179
+ text=text,
180
+ actor=ActorRef(
181
+ actor_type=cast(Literal["agent", "user", "system"], actor),
182
+ actor_name=actor,
183
+ ),
184
+ )
185
+ except LaunchError as exc:
186
+ emit_error(ctx, exc)
187
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
188
+ emit_payload(ctx, question.to_dict(), human=f"answered {question.id}")
189
+
190
+
191
+ def _answer_many_command(
192
+ ctx: typer.Context,
193
+ text: Annotated[str | None, typer.Option("--text")] = None,
194
+ from_file: Annotated[Path | None, typer.Option("--file")] = None,
195
+ actor: Annotated[str, typer.Option("--actor")] = "user",
196
+ task_ref: Annotated[
197
+ str | None,
198
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
199
+ ] = None,
200
+ ) -> None:
201
+ state = cli_state_from_context(ctx)
202
+ try:
203
+ task = resolve_cli_task(state.cwd, task_ref)
204
+ raw = read_text_input(text=text, from_file=from_file)
205
+ payload = answer_questions(
206
+ state.cwd,
207
+ task.id,
208
+ _parse_answer_many_input(raw),
209
+ actor=ActorRef(
210
+ actor_type=cast(Literal["agent", "user", "system"], actor),
211
+ actor_name=actor,
212
+ ),
213
+ answer_source="harness",
214
+ )
215
+ except LaunchError as exc:
216
+ emit_error(ctx, exc)
217
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
218
+ emit_payload(
219
+ ctx,
220
+ payload,
221
+ human=(
222
+ f"answered {len(cast(list[object], payload['answered_question_ids']))} "
223
+ "questions\n"
224
+ f"Required open: {payload['required_open']}\n"
225
+ f"Plan regeneration needed: {payload['plan_regeneration_needed']}\n"
226
+ f"Next: {payload['next_action']}"
227
+ ),
228
+ )
229
+
230
+
231
+ def _dismiss_command(
232
+ ctx: typer.Context,
233
+ question_id: Annotated[str, typer.Argument(..., help="Question id.")],
234
+ task_ref: Annotated[
235
+ str | None,
236
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
237
+ ] = None,
238
+ ) -> None:
239
+ state = cli_state_from_context(ctx)
240
+ try:
241
+ task = resolve_cli_task(state.cwd, task_ref)
242
+ question = dismiss_question(state.cwd, task.id, question_id)
243
+ except LaunchError as exc:
244
+ emit_error(ctx, exc)
245
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
246
+ emit_payload(ctx, question.to_dict(), human=f"dismissed {question.id}")
247
+
248
+
249
+ def _open_command(
250
+ ctx: typer.Context,
251
+ task_ref: Annotated[
252
+ str | None,
253
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
254
+ ] = None,
255
+ ) -> None:
256
+ state = cli_state_from_context(ctx)
257
+ try:
258
+ task = resolve_cli_task(state.cwd, task_ref)
259
+ payload = list_open_questions(state.cwd, task.id)
260
+ except LaunchError as exc:
261
+ emit_error(ctx, exc)
262
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
263
+ questions = payload["questions"]
264
+ assert isinstance(questions, list)
265
+ lines = ["OPEN QUESTIONS"]
266
+ for item in questions:
267
+ if isinstance(item, dict):
268
+ lines.append(f"{item['id']} {item['question']}")
269
+ emit_payload(
270
+ ctx,
271
+ payload,
272
+ human="\n".join(lines) if questions else "OPEN QUESTIONS\n(empty)",
273
+ )
274
+
275
+
276
+ def _answers_command(
277
+ ctx: typer.Context,
278
+ format_name: Annotated[
279
+ str,
280
+ typer.Option("--format", help="Output format: markdown or json."),
281
+ ] = "markdown",
282
+ task_ref: Annotated[
283
+ str | None,
284
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
285
+ ] = None,
286
+ ) -> None:
287
+ state = cli_state_from_context(ctx)
288
+ try:
289
+ task = resolve_cli_task(state.cwd, task_ref)
290
+ all_questions = list_questions(state.cwd, task.id)
291
+ except LaunchError as exc:
292
+ emit_error(ctx, exc)
293
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
294
+ answered = [q for q in all_questions if q.status == "answered"]
295
+ payload_dicts = [q.to_dict() for q in answered]
296
+ payload = {
297
+ "kind": "question_answers",
298
+ "task_id": task.id,
299
+ "questions": payload_dicts,
300
+ }
301
+ if format_name == "json":
302
+ emit_payload(ctx, payload)
303
+ return
304
+ lines = ["ANSWERED QUESTIONS"]
305
+ for q in answered:
306
+ lines.append("")
307
+ lines.append(f"### {q.id}")
308
+ lines.append(f"Q: {q.question}")
309
+ lines.append(f"A: {q.answer}")
310
+ emit_payload(
311
+ ctx,
312
+ payload,
313
+ human="\n".join(lines) if answered else "ANSWERED QUESTIONS\n(empty)",
314
+ )
315
+
316
+
317
+ def _status_command(
318
+ ctx: typer.Context,
319
+ task_ref: Annotated[
320
+ str | None,
321
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
322
+ ] = None,
323
+ ) -> None:
324
+ state = cli_state_from_context(ctx)
325
+ try:
326
+ task = resolve_cli_task(state.cwd, task_ref)
327
+ payload = question_status(state.cwd, task.id)
328
+ except LaunchError as exc:
329
+ emit_error(ctx, exc)
330
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
331
+ emit_payload(
332
+ ctx,
333
+ payload,
334
+ human=(
335
+ f"Required open: {payload['required_open']}\n"
336
+ f"Plan regeneration needed: {payload['plan_regeneration_needed']}\n"
337
+ f"Next: {payload['next_action']}"
338
+ ),
339
+ )
340
+
341
+
342
+ def register_question_v2_commands(app: typer.Typer) -> None:
343
+ app.command("add")(_add_command)
344
+ app.command("list")(_list_command)
345
+ app.command("answer")(_answer_command)
346
+ app.command("answer-many")(_answer_many_command)
347
+ app.command("dismiss")(_dismiss_command)
348
+ app.command("open")(_open_command)
349
+ app.command("answers")(_answers_command)
350
+ app.command("status")(_status_command)
taskledger/cli_task.py ADDED
@@ -0,0 +1,257 @@
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.tasks import (
9
+ activate_task,
10
+ cancel_task,
11
+ close_task,
12
+ create_task,
13
+ deactivate_task,
14
+ edit_task,
15
+ list_task_summaries,
16
+ show_active_task,
17
+ show_task,
18
+ task_dossier,
19
+ )
20
+ from taskledger.cli_common import (
21
+ TaskOption,
22
+ cli_state_from_context,
23
+ emit_error,
24
+ emit_payload,
25
+ launch_error_exit_code,
26
+ read_text_input,
27
+ resolve_cli_task,
28
+ )
29
+ from taskledger.errors import LaunchError
30
+ from taskledger.services.tasks import list_events as _list_events
31
+
32
+
33
+ def register_task_v2_commands(app: typer.Typer) -> None: # noqa: C901
34
+ @app.command("create")
35
+ def create_command(
36
+ ctx: typer.Context,
37
+ title_arg: Annotated[str, typer.Argument(..., help="Task title.")],
38
+ description: Annotated[
39
+ str | None,
40
+ typer.Option("--description", help="Task description."),
41
+ ] = None,
42
+ slug: Annotated[str | None, typer.Option("--slug")] = None,
43
+ ) -> None:
44
+ state = cli_state_from_context(ctx)
45
+ try:
46
+ task = create_task(
47
+ state.cwd,
48
+ title=title_arg,
49
+ description=read_text_input(
50
+ text=description or title_arg,
51
+ text_label="--description",
52
+ ),
53
+ slug=slug or title_arg,
54
+ )
55
+ except LaunchError as exc:
56
+ emit_error(ctx, exc)
57
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
58
+ emit_payload(
59
+ ctx,
60
+ task.to_dict(),
61
+ human=f"created task {task.slug} ({task.id})",
62
+ )
63
+
64
+ @app.command("list")
65
+ def list_command(ctx: typer.Context) -> None:
66
+ state = cli_state_from_context(ctx)
67
+ payload = {"kind": "task_list", "tasks": list_task_summaries(state.cwd)}
68
+ human_lines = ["TASKS"]
69
+ if not payload["tasks"]:
70
+ human_lines.append("(empty)")
71
+ else:
72
+ for task in cast(list[dict[str, object]], payload["tasks"]):
73
+ active = task.get("active_stage")
74
+ stage = (
75
+ f"{task['status_stage']} [{active}]"
76
+ if active
77
+ else str(task["status_stage"])
78
+ )
79
+ human_lines.append(f"{task['slug']} {task['id']} {stage}")
80
+ emit_payload(ctx, payload, human="\n".join(human_lines))
81
+
82
+ @app.command("active")
83
+ def active_command(ctx: typer.Context) -> None:
84
+ state = cli_state_from_context(ctx)
85
+ try:
86
+ payload = show_active_task(state.cwd)
87
+ except LaunchError as exc:
88
+ emit_error(ctx, exc)
89
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
90
+ emit_payload(
91
+ ctx,
92
+ payload,
93
+ human=f"{payload['slug']} ({payload['task_id']})",
94
+ )
95
+
96
+ @app.command("activate")
97
+ def activate_command(
98
+ ctx: typer.Context,
99
+ ref: Annotated[str, typer.Argument(..., help="Task ref.")],
100
+ reason: Annotated[str | None, typer.Option("--reason")] = None,
101
+ force: Annotated[bool, typer.Option("--force")] = False,
102
+ ) -> None:
103
+ state = cli_state_from_context(ctx)
104
+ try:
105
+ payload = activate_task(state.cwd, ref, reason=reason, force=force)
106
+ except LaunchError as exc:
107
+ emit_error(ctx, exc)
108
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
109
+ changed = "activated" if payload["changed"] else "already active"
110
+ emit_payload(ctx, payload, human=f"{changed} {payload['task_id']}")
111
+
112
+ @app.command("deactivate")
113
+ def deactivate_command(
114
+ ctx: typer.Context,
115
+ reason: Annotated[str, typer.Option("--reason")],
116
+ force: Annotated[bool, typer.Option("--force")] = False,
117
+ ) -> None:
118
+ state = cli_state_from_context(ctx)
119
+ try:
120
+ payload = deactivate_task(state.cwd, reason=reason, force=force)
121
+ except LaunchError as exc:
122
+ emit_error(ctx, exc)
123
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
124
+ emit_payload(ctx, payload, human=f"deactivated {payload['task_id']}")
125
+
126
+ @app.command("show")
127
+ def show_command(
128
+ ctx: typer.Context,
129
+ task_ref: TaskOption = None,
130
+ ) -> None:
131
+ state = cli_state_from_context(ctx)
132
+ try:
133
+ resolved = resolve_cli_task(state.cwd, task_ref)
134
+ payload = show_task(state.cwd, resolved.id)
135
+ except LaunchError as exc:
136
+ emit_error(ctx, exc)
137
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
138
+ task = payload["task"]
139
+ assert isinstance(task, dict)
140
+ emit_payload(
141
+ ctx,
142
+ payload,
143
+ human=(
144
+ f"{task['title']} ({task['id']})\n"
145
+ f"status: {task['status_stage']}\n"
146
+ f"active_stage: {task.get('active_stage') or 'none'}\n"
147
+ f"slug: {task['slug']}"
148
+ ),
149
+ )
150
+
151
+ @app.command("edit")
152
+ def edit_command(
153
+ ctx: typer.Context,
154
+ task_ref: TaskOption = None,
155
+ title: Annotated[str | None, typer.Option("--title")] = None,
156
+ description: Annotated[str | None, typer.Option("--description")] = None,
157
+ from_file: Annotated[Path | None, typer.Option("--from-file")] = None,
158
+ priority: Annotated[str | None, typer.Option("--priority")] = None,
159
+ owner: Annotated[str | None, typer.Option("--owner")] = None,
160
+ add_label: Annotated[list[str] | None, typer.Option("--add-label")] = None,
161
+ remove_label: Annotated[
162
+ list[str] | None,
163
+ typer.Option("--remove-label"),
164
+ ] = None,
165
+ add_note: Annotated[list[str] | None, typer.Option("--add-note")] = None,
166
+ ) -> None:
167
+ state = cli_state_from_context(ctx)
168
+ try:
169
+ task = resolve_cli_task(state.cwd, task_ref)
170
+ task = edit_task(
171
+ state.cwd,
172
+ task.id,
173
+ title=title,
174
+ description=(
175
+ read_text_input(text=description, from_file=from_file)
176
+ if description is not None or from_file is not None
177
+ else None
178
+ ),
179
+ priority=priority,
180
+ owner=owner,
181
+ add_labels=tuple(add_label or ()),
182
+ remove_labels=tuple(remove_label or ()),
183
+ add_notes=tuple(add_note or ()),
184
+ )
185
+ except LaunchError as exc:
186
+ emit_error(ctx, exc)
187
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
188
+ emit_payload(ctx, task.to_dict(), human=f"updated task {task.id}")
189
+
190
+ @app.command("cancel")
191
+ def cancel_command(
192
+ ctx: typer.Context,
193
+ task_ref: TaskOption = None,
194
+ reason: Annotated[str | None, typer.Option("--reason")] = None,
195
+ ) -> None:
196
+ state = cli_state_from_context(ctx)
197
+ try:
198
+ task = resolve_cli_task(state.cwd, task_ref)
199
+ payload = cancel_task(state.cwd, task.id, reason=reason)
200
+ except LaunchError as exc:
201
+ emit_error(ctx, exc)
202
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
203
+ emit_payload(ctx, payload, human=f"cancelled task {payload['task_id']}")
204
+
205
+ @app.command("close")
206
+ def close_command(
207
+ ctx: typer.Context,
208
+ task_ref: TaskOption = None,
209
+ ) -> None:
210
+ state = cli_state_from_context(ctx)
211
+ try:
212
+ task = resolve_cli_task(state.cwd, task_ref)
213
+ payload = close_task(state.cwd, task.id)
214
+ except LaunchError as exc:
215
+ emit_error(ctx, exc)
216
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
217
+ emit_payload(ctx, payload, human=f"closed task {payload['task_id']}")
218
+
219
+ @app.command("events")
220
+ def events_command(
221
+ ctx: typer.Context,
222
+ task_ref: TaskOption = None,
223
+ all_tasks: Annotated[
224
+ bool, typer.Option("--all", help="Show events for all tasks.")
225
+ ] = False,
226
+ limit: Annotated[int, typer.Option("--limit", help="Max events to show.")] = 50,
227
+ ) -> None:
228
+ state = cli_state_from_context(ctx)
229
+ events = _list_events(state.cwd)
230
+ if not all_tasks:
231
+ try:
232
+ resolved = resolve_cli_task(state.cwd, task_ref)
233
+ except LaunchError as exc:
234
+ emit_error(ctx, exc)
235
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
236
+ events = [e for e in events if e.get("task_id") == resolved.id]
237
+ events = events[-limit:]
238
+ payload = {"kind": "event_list", "items": events}
239
+ from taskledger.cli_common import render_events_human
240
+
241
+ human = render_events_human(events)
242
+ emit_payload(ctx, payload, human=human, result_type="event_list")
243
+
244
+ @app.command("dossier")
245
+ def dossier_command(
246
+ ctx: typer.Context,
247
+ task_ref: TaskOption = None,
248
+ format_name: Annotated[str, typer.Option("--format")] = "markdown",
249
+ ) -> None:
250
+ state = cli_state_from_context(ctx)
251
+ try:
252
+ task = resolve_cli_task(state.cwd, task_ref)
253
+ payload = task_dossier(state.cwd, task.id, format_name=format_name)
254
+ except LaunchError as exc:
255
+ emit_error(ctx, exc)
256
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
257
+ emit_payload(ctx, payload, human=payload if isinstance(payload, str) else None)