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,617 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+
10
+ from taskledger.errors import LaunchError, TaskledgerError
11
+ from taskledger.storage.paths import discover_workspace_root
12
+ from taskledger.storage.task_store import TaskRecord, resolve_task_or_active
13
+
14
+
15
+ @dataclass(slots=True, frozen=True)
16
+ class CLIState:
17
+ cwd: Path
18
+ json_output: bool
19
+
20
+
21
+ TaskOption = Annotated[
22
+ str | None,
23
+ typer.Option("--task", help="Task ref. Defaults to the active task."),
24
+ ]
25
+ TextOption = Annotated[str | None, typer.Option("--text")]
26
+ MessageOption = Annotated[str | None, typer.Option("--message")]
27
+ SummaryOption = Annotated[str, typer.Option("--summary")]
28
+ ReasonOption = Annotated[str, typer.Option("--reason")]
29
+ EvidenceOption = Annotated[list[str] | None, typer.Option("--evidence")]
30
+
31
+
32
+ def resolve_workspace_root(cwd: Path | None) -> Path:
33
+ return discover_workspace_root((cwd or Path.cwd()).expanduser().resolve())
34
+
35
+
36
+ def cli_state_from_context(ctx: typer.Context) -> CLIState:
37
+ state = ctx.obj
38
+ if not isinstance(state, CLIState):
39
+ raise LaunchError("Taskledger CLI state is not initialized.")
40
+ return state
41
+
42
+
43
+ def resolve_cli_task(workspace_root: Path, task_ref: str | None) -> TaskRecord:
44
+ return resolve_task_or_active(workspace_root, task_ref)
45
+
46
+
47
+ def render_json(payload: Any) -> str:
48
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
49
+
50
+
51
+ def emit_payload(
52
+ ctx: typer.Context,
53
+ payload: Any,
54
+ *,
55
+ human: str | None = None,
56
+ result_type: str | None = None,
57
+ warnings: list[str] | None = None,
58
+ ) -> None:
59
+ state = cli_state_from_context(ctx)
60
+ if state.json_output:
61
+ typer.echo(
62
+ render_json(
63
+ _success_envelope(
64
+ ctx,
65
+ payload,
66
+ result_type=result_type,
67
+ warnings=warnings,
68
+ )
69
+ )
70
+ )
71
+ return
72
+ if human is None:
73
+ if isinstance(payload, dict):
74
+ human = "\n".join(
75
+ f"{key}: {value}" for key, value in payload.items() if value is not None
76
+ )
77
+ else:
78
+ human = str(payload)
79
+ typer.echo(human)
80
+
81
+
82
+ def emit_error(
83
+ ctx: typer.Context,
84
+ error: Exception | str,
85
+ *,
86
+ data: dict[str, object] | None = None,
87
+ remediation: list[str] | None = None,
88
+ exit_code: int | None = None,
89
+ error_type: str | None = None,
90
+ ) -> None:
91
+ state = cli_state_from_context(ctx)
92
+ if state.json_output:
93
+ typer.echo(
94
+ render_json(
95
+ _error_envelope(
96
+ ctx,
97
+ error,
98
+ data=data,
99
+ remediation=remediation,
100
+ exit_code=exit_code,
101
+ error_type=error_type,
102
+ )
103
+ )
104
+ )
105
+ else:
106
+ human_output = _format_human_error(error)
107
+ typer.echo(human_output, err=True)
108
+
109
+
110
+ def launch_error_exit_code(exc: Exception, default: int = 1) -> int:
111
+ code = getattr(exc, "taskledger_exit_code", None)
112
+ if not isinstance(code, int):
113
+ code = getattr(exc, "exit_code", None)
114
+ if not isinstance(code, int):
115
+ code = _exit_code_from_message(str(exc), default)
116
+ return code if isinstance(code, int) else default
117
+
118
+
119
+ def _success_envelope(
120
+ ctx: typer.Context,
121
+ payload: Any,
122
+ *,
123
+ result_type: str | None,
124
+ warnings: list[str] | None,
125
+ ) -> dict[str, object]:
126
+ extracted_warnings = warnings
127
+ if extracted_warnings is None and isinstance(payload, dict):
128
+ raw_warnings = payload.get("warnings")
129
+ if isinstance(raw_warnings, list):
130
+ extracted_warnings = [str(item) for item in raw_warnings]
131
+ envelope: dict[str, object] = {
132
+ "ok": True,
133
+ "command": _operation_name(ctx),
134
+ "result": payload,
135
+ "events": _event_refs(payload),
136
+ }
137
+ task_id = _task_id_from_value(payload)
138
+ if task_id is not None:
139
+ envelope["task_id"] = task_id
140
+ if extracted_warnings:
141
+ envelope["warnings"] = extracted_warnings
142
+ if result_type is not None:
143
+ envelope["result_type"] = result_type
144
+ return envelope
145
+
146
+
147
+ def _operation_name(ctx: typer.Context) -> str:
148
+ root_name = ctx.find_root().info_name
149
+ parts = ctx.command_path.split()
150
+ if root_name:
151
+ root_parts = root_name.split()
152
+ if parts[: len(root_parts)] == root_parts:
153
+ parts = parts[len(root_parts) :]
154
+ elif parts and parts[0] == Path(root_name).name:
155
+ parts = parts[1:]
156
+ return ".".join(parts) if parts else "taskledger"
157
+
158
+
159
+ def _infer_result_type(payload: Any) -> str:
160
+ if isinstance(payload, list):
161
+ return "collection"
162
+ if isinstance(payload, str):
163
+ return "text"
164
+ if not isinstance(payload, dict):
165
+ return type(payload).__name__
166
+ if isinstance(payload.get("task"), dict):
167
+ return "task"
168
+ if isinstance(payload.get("plan"), dict):
169
+ return "plan"
170
+ if isinstance(payload.get("run"), dict):
171
+ return "run"
172
+ if isinstance(payload.get("todo"), dict):
173
+ return "todo"
174
+ if "lock" in payload:
175
+ return "lock"
176
+ if {"id", "status_stage", "title"}.issubset(payload):
177
+ return "task"
178
+ if {"run_id", "run_type", "status"}.issubset(payload):
179
+ return "run"
180
+ if {"task_id", "plan_version", "status"}.issubset(payload):
181
+ return "plan"
182
+ if any(
183
+ key in payload for key in ("tasks", "plans", "questions", "locks", "file_links")
184
+ ):
185
+ return "collection"
186
+ kind = payload.get("kind")
187
+ return str(kind) if kind else "object"
188
+
189
+
190
+ def _error_envelope(
191
+ ctx: typer.Context,
192
+ error: Exception | str,
193
+ *,
194
+ data: dict[str, object] | None,
195
+ remediation: list[str] | None,
196
+ exit_code: int | None,
197
+ error_type: str | None,
198
+ ) -> dict[str, object]:
199
+ resolved_error = _error_payload(
200
+ error,
201
+ data=data,
202
+ remediation=remediation,
203
+ exit_code=exit_code,
204
+ error_type=error_type,
205
+ )
206
+ envelope: dict[str, object] = {
207
+ "ok": False,
208
+ "command": _operation_name(ctx),
209
+ "error": resolved_error,
210
+ }
211
+ task_id = resolved_error.get("task_id")
212
+ if isinstance(task_id, str):
213
+ envelope["task_id"] = task_id
214
+ return envelope
215
+
216
+
217
+ def _error_exit_code(error: Exception | str) -> int:
218
+ if isinstance(error, Exception):
219
+ return launch_error_exit_code(error)
220
+ return _exit_code_from_message(str(error), 1)
221
+
222
+
223
+ def _error_data(error: Exception | str) -> dict[str, object]:
224
+ if isinstance(error, Exception):
225
+ payload = getattr(error, "taskledger_data", None)
226
+ if isinstance(payload, dict):
227
+ return dict(payload)
228
+ return {}
229
+
230
+
231
+ def _error_remediation(error: Exception | str) -> list[str]:
232
+ if isinstance(error, Exception):
233
+ explicit = getattr(error, "taskledger_remediation", None)
234
+ if isinstance(explicit, list) and explicit:
235
+ return [str(item) for item in explicit]
236
+ return _default_remediation(_error_exit_code(error))
237
+
238
+
239
+ def _error_payload(
240
+ error: Exception | str,
241
+ *,
242
+ data: dict[str, object] | None,
243
+ remediation: list[str] | None,
244
+ exit_code: int | None,
245
+ error_type: str | None,
246
+ ) -> dict[str, object]:
247
+ if isinstance(error, TaskledgerError):
248
+ payload = error.to_error_payload()
249
+ else:
250
+ payload = {
251
+ "code": _error_code(
252
+ error, explicit_error_type=error_type, explicit_exit_code=exit_code
253
+ ),
254
+ "message": str(error),
255
+ }
256
+ payload["code"] = _error_code(
257
+ error,
258
+ explicit_error_type=error_type,
259
+ explicit_exit_code=exit_code,
260
+ )
261
+ details = data or _error_details(error)
262
+ if details:
263
+ existing = payload.get("details")
264
+ merged = dict(existing) if isinstance(existing, dict) else {}
265
+ merged.update(details)
266
+ payload["details"] = merged
267
+ blocking_refs = _error_blocking_refs(error)
268
+ if blocking_refs:
269
+ payload["blocking_refs"] = blocking_refs
270
+ task_id = _error_task_id(error)
271
+ if task_id is not None:
272
+ payload["task_id"] = task_id
273
+ resolved_remediation = remediation or _error_remediation(error)
274
+ if resolved_remediation:
275
+ payload["remediation"] = resolved_remediation
276
+ resolved_exit_code = exit_code or _error_exit_code(error)
277
+ if resolved_exit_code:
278
+ payload["exit_code"] = resolved_exit_code
279
+ return payload
280
+
281
+
282
+ def _error_code(
283
+ error: Exception | str,
284
+ *,
285
+ explicit_error_type: str | None = None,
286
+ explicit_exit_code: int | None = None,
287
+ ) -> str:
288
+ if isinstance(error, TaskledgerError):
289
+ explicit = getattr(error, "__dict__", {}).get("taskledger_error_code")
290
+ if isinstance(explicit, str) and explicit not in {
291
+ "TASKLEDGER_ERROR",
292
+ "LAUNCH_ERROR",
293
+ }:
294
+ return explicit
295
+ legacy_type = explicit_error_type or getattr(error, "__dict__", {}).get(
296
+ "taskledger_error_type"
297
+ )
298
+ if isinstance(legacy_type, str):
299
+ mapped = _error_code_from_error_type(legacy_type)
300
+ if mapped is not None and error.code in {
301
+ "TASKLEDGER_ERROR",
302
+ "LAUNCH_ERROR",
303
+ }:
304
+ return mapped
305
+ by_exit_code = _error_code_from_exit_code(
306
+ explicit_exit_code
307
+ if explicit_exit_code is not None
308
+ else _error_exit_code(error)
309
+ )
310
+ if by_exit_code is not None and error.code in {
311
+ "TASKLEDGER_ERROR",
312
+ "LAUNCH_ERROR",
313
+ }:
314
+ return by_exit_code
315
+ return error.code
316
+ if isinstance(error, Exception):
317
+ explicit = getattr(error, "__dict__", {}).get("taskledger_error_code")
318
+ if isinstance(explicit, str):
319
+ return explicit
320
+ legacy_type = explicit_error_type or getattr(error, "__dict__", {}).get(
321
+ "taskledger_error_type"
322
+ )
323
+ if isinstance(legacy_type, str):
324
+ mapped = _error_code_from_error_type(legacy_type)
325
+ if mapped is not None:
326
+ return mapped
327
+ by_exit_code = _error_code_from_exit_code(
328
+ explicit_exit_code
329
+ if explicit_exit_code is not None
330
+ else _error_exit_code(error)
331
+ )
332
+ if by_exit_code is not None:
333
+ return by_exit_code
334
+ return "TASKLEDGER_ERROR"
335
+
336
+
337
+ def _error_details(error: Exception | str) -> dict[str, object]:
338
+ payload = _error_data(error)
339
+ return {
340
+ key: value
341
+ for key, value in payload.items()
342
+ if key not in {"code", "message", "task_id", "blocking_refs"}
343
+ }
344
+
345
+
346
+ def _error_task_id(error: Exception | str) -> str | None:
347
+ if isinstance(error, TaskledgerError) and error.task_id is not None:
348
+ return error.task_id
349
+ payload = _error_data(error)
350
+ task_id = payload.get("task_id")
351
+ if isinstance(task_id, str):
352
+ return task_id
353
+ return None
354
+
355
+
356
+ def _error_blocking_refs(error: Exception | str) -> list[str]:
357
+ if isinstance(error, TaskledgerError) and error.blocking_refs:
358
+ return [str(item) for item in error.blocking_refs]
359
+ payload = _error_data(error)
360
+ blocking_refs = payload.get("blocking_refs")
361
+ if isinstance(blocking_refs, list):
362
+ return [str(item) for item in blocking_refs]
363
+ return []
364
+
365
+
366
+ def _exit_code_from_message(message: str, default: int) -> int:
367
+ lowered = message.lower()
368
+ if "not found" in lowered or lowered.startswith("no plans found"):
369
+ return 5
370
+ if "lock already exists" in lowered:
371
+ return 4
372
+ if "invalid yaml" in lowered or "invalid lock file" in lowered:
373
+ return 6
374
+ return default
375
+
376
+
377
+ def _error_code_from_error_type(error_type: str) -> str | None:
378
+ return {
379
+ "ApprovalRequired": "APPROVAL_REQUIRED",
380
+ "DependencyIncomplete": "DEPENDENCY_INCOMPLETE",
381
+ "InvalidStageTransition": "INVALID_STAGE_TRANSITION",
382
+ "LockConflict": "LOCK_CONFLICT",
383
+ "NotFound": "NOT_FOUND",
384
+ "StaleLockRequiresBreak": "STALE_LOCK_REQUIRES_BREAK",
385
+ "StorageCorruption": "STORAGE_CORRUPTION",
386
+ "ValidationError": "VALIDATION_FAILED",
387
+ }.get(error_type)
388
+
389
+
390
+ def _error_code_from_exit_code(exit_code: int) -> str | None:
391
+ return {
392
+ 2: "INVALID_INPUT",
393
+ 3: "WORKFLOW_REJECTION",
394
+ 4: "LOCK_CONFLICT",
395
+ 5: "NOT_FOUND",
396
+ 6: "STORAGE_ERROR",
397
+ 7: "VALIDATION_FAILED",
398
+ }.get(exit_code)
399
+
400
+
401
+ def _default_remediation(exit_code: int) -> list[str]:
402
+ return {
403
+ 2: ["Review the invalid input or command usage and retry."],
404
+ 3: ["Move the task through the required workflow gate before retrying."],
405
+ 4: ["Inspect the active lock or break it explicitly if it is stale."],
406
+ 5: ["Check the task or record reference and retry."],
407
+ 6: ["Run `taskledger doctor` and repair the ledger state before retrying."],
408
+ 7: ["Review the recorded validation results and resolve the failing checks."],
409
+ }.get(exit_code, [])
410
+
411
+
412
+ def _format_human_error(error: Exception | str) -> str:
413
+ """Format error for human-readable output
414
+ with special handling for validation errors."""
415
+ message = str(error)
416
+ error_code = None
417
+ error_data = {}
418
+
419
+ if isinstance(error, Exception):
420
+ error_code = getattr(error, "taskledger_error_code", None)
421
+ payload = getattr(error, "taskledger_data", None)
422
+ if isinstance(payload, dict):
423
+ error_data = payload
424
+
425
+ if error_code == "VALIDATION_INCOMPLETE":
426
+ lines = [f"Error: {message}", ""]
427
+
428
+ missing_criteria = error_data.get("missing_criteria", [])
429
+ if missing_criteria and isinstance(missing_criteria, list):
430
+ lines.append("Missing Mandatory Criteria:")
431
+ for criterion in missing_criteria:
432
+ lines.append(f" • {criterion}")
433
+ lines.append("")
434
+
435
+ failing_criteria = error_data.get("failing_criteria", [])
436
+ if failing_criteria and isinstance(failing_criteria, list):
437
+ lines.append("Failing Mandatory Criteria:")
438
+ for criterion in failing_criteria:
439
+ lines.append(f" ✗ {criterion}")
440
+ lines.append("")
441
+
442
+ open_mandatory_todos = error_data.get("open_mandatory_todos", [])
443
+ if open_mandatory_todos and isinstance(open_mandatory_todos, list):
444
+ lines.append("Open Mandatory Todos:")
445
+ for todo_id in open_mandatory_todos:
446
+ lines.append(f" ☐ {todo_id}")
447
+ lines.append("")
448
+
449
+ dependency_blockers = error_data.get("dependency_blockers", [])
450
+ if dependency_blockers and isinstance(dependency_blockers, list):
451
+ lines.append("Dependency Blockers:")
452
+ for blocker in dependency_blockers:
453
+ lines.append(f" - {blocker}")
454
+ lines.append("")
455
+
456
+ blockers = error_data.get("blockers", [])
457
+ if blockers and isinstance(blockers, list):
458
+ lines.append("Blocking Issues:")
459
+ for blocker in blockers:
460
+ if isinstance(blocker, dict):
461
+ kind = blocker.get("kind", "unknown")
462
+ msg = blocker.get("message", "")
463
+ hint = blocker.get("command_hint", "")
464
+ lines.append(f" [{kind}] {msg}")
465
+ if hint:
466
+ lines.append(f" Command: {hint}")
467
+ lines.append("")
468
+
469
+ lines.append("Next Steps:")
470
+ lines.append(" 1. Review the blocking issues above")
471
+ lines.append(" 2. Address the validation gates")
472
+ lines.append(" 3. Run 'taskledger validate status' to check progress")
473
+
474
+ return "\n".join(lines)
475
+
476
+ return message
477
+
478
+
479
+ def _task_id_from_value(value: Any) -> str | None:
480
+ if isinstance(value, dict):
481
+ direct = value.get("task_id")
482
+ if isinstance(direct, str):
483
+ return direct
484
+ candidate = value.get("id")
485
+ if isinstance(candidate, str) and candidate.startswith("task-"):
486
+ return candidate
487
+ nested_task = value.get("task")
488
+ if isinstance(nested_task, dict):
489
+ nested_id = nested_task.get("id")
490
+ if isinstance(nested_id, str):
491
+ return nested_id
492
+ return None
493
+
494
+
495
+ def _event_refs(payload: Any) -> list[str]:
496
+ if not isinstance(payload, dict):
497
+ return []
498
+ events = payload.get("events")
499
+ if isinstance(events, list):
500
+ return [str(item) for item in events]
501
+ return []
502
+
503
+
504
+ def read_text_input(
505
+ *,
506
+ text: str | None,
507
+ from_file: Path | None = None,
508
+ text_label: str = "--text",
509
+ ) -> str:
510
+ if text and from_file is not None:
511
+ raise LaunchError(f"Use either {text_label} or --from-file, not both.")
512
+ if from_file is not None:
513
+ try:
514
+ return from_file.read_text(encoding="utf-8")
515
+ except OSError as exc:
516
+ raise LaunchError(f"Failed to read {from_file}: {exc}") from exc
517
+ if text is None:
518
+ raise LaunchError(f"Provide {text_label} or --from-file.")
519
+ if not text.strip():
520
+ raise LaunchError("Text input must not be empty.")
521
+ return text
522
+
523
+
524
+ def write_text_output(path: Path, text: str) -> Path:
525
+ target = path.expanduser()
526
+ parent = target.parent
527
+ try:
528
+ parent.mkdir(parents=True, exist_ok=True)
529
+ target.write_text(text, encoding="utf-8")
530
+ except OSError as exc:
531
+ raise LaunchError(f"Failed to write {target}: {exc}") from exc
532
+ return target
533
+
534
+
535
+ def human_kv(title: str, rows: list[tuple[str, object]]) -> str:
536
+ lines = [title]
537
+ for key, value in rows:
538
+ if value is None:
539
+ continue
540
+ lines.append(f"{key}: {value}")
541
+ return "\n".join(lines)
542
+
543
+
544
+ def render_events_human(events: list[dict[str, object]]) -> str:
545
+ if not events:
546
+ return "EVENTS\n(empty)"
547
+ header = f"{'TIMESTAMP':<21} {'EVENT':<25} {'ACTOR':<15} SUMMARY"
548
+ lines = ["EVENTS", header]
549
+ for evt in events:
550
+ ts_raw = str(evt.get("ts", ""))
551
+ ts = ts_raw[:19].replace("T", " ") if ts_raw else ""
552
+ event_type = str(evt.get("event", ""))
553
+ actor_ref = evt.get("actor")
554
+ if isinstance(actor_ref, dict):
555
+ actor = str(actor_ref.get("actor_name", ""))
556
+ else:
557
+ actor = ""
558
+ summary = _event_summary(evt)
559
+ lines.append(f"{ts:<21} {event_type:<25} {actor:<15} {summary}")
560
+ return "\n".join(lines)
561
+
562
+
563
+ def _event_summary(evt: dict[str, object]) -> str:
564
+ data = evt.get("data")
565
+ if not isinstance(data, dict):
566
+ return ""
567
+ for key in ("reason", "todo_id", "lock_id", "status", "slug", "title"):
568
+ value = data.get(key)
569
+ if isinstance(value, str) and value:
570
+ return value
571
+ parts = [
572
+ f"{k}={v}" for k, v in data.items() if isinstance(v, str | int | float | bool)
573
+ ]
574
+ return " ".join(parts[:3])
575
+
576
+
577
+ def human_list(title: str, rows: list[str]) -> str:
578
+ if not rows:
579
+ return f"{title}\n(empty)"
580
+ return "\n".join([title, *rows])
581
+
582
+
583
+ def actor_options() -> dict[str, Any]:
584
+ """
585
+ Returns option descriptors for actor/harness identity.
586
+ Used by commands to capture who is performing work and via what tool.
587
+ """
588
+ return {
589
+ "actor": typer.Option(
590
+ None,
591
+ "--actor",
592
+ help="Actor type: user (human), agent (coding tool), or system.",
593
+ ),
594
+ "actor_name": typer.Option(
595
+ None,
596
+ "--actor-name",
597
+ help='Actor name (e.g., "codex", "nahrstaedt").',
598
+ ),
599
+ "actor_role": typer.Option(
600
+ None,
601
+ "--actor-role",
602
+ help=(
603
+ "Current role in task lifecycle "
604
+ "(planner, implementer, validator, reviewer, operator)."
605
+ ),
606
+ ),
607
+ "harness": typer.Option(
608
+ None,
609
+ "--harness",
610
+ help='Harness name (e.g., "codex", "opencode", "manual", "ci").',
611
+ ),
612
+ "session_id": typer.Option(
613
+ None,
614
+ "--session-id",
615
+ help="External session identifier.",
616
+ ),
617
+ }