taskledger 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskledger/__init__.py +5 -0
- taskledger/__main__.py +6 -0
- taskledger/_version.py +24 -0
- taskledger/api/__init__.py +13 -0
- taskledger/api/handoff.py +247 -0
- taskledger/api/introductions.py +9 -0
- taskledger/api/locks.py +4 -0
- taskledger/api/plans.py +31 -0
- taskledger/api/project.py +185 -0
- taskledger/api/questions.py +19 -0
- taskledger/api/search.py +87 -0
- taskledger/api/task_runs.py +38 -0
- taskledger/api/tasks.py +61 -0
- taskledger/cli.py +600 -0
- taskledger/cli_actor.py +196 -0
- taskledger/cli_common.py +617 -0
- taskledger/cli_implement.py +409 -0
- taskledger/cli_migrate.py +328 -0
- taskledger/cli_misc.py +984 -0
- taskledger/cli_plan.py +478 -0
- taskledger/cli_question.py +350 -0
- taskledger/cli_task.py +257 -0
- taskledger/cli_validate.py +285 -0
- taskledger/command_inventory.py +125 -0
- taskledger/domain/__init__.py +2 -0
- taskledger/domain/models.py +1697 -0
- taskledger/domain/policies.py +542 -0
- taskledger/domain/states.py +320 -0
- taskledger/errors.py +165 -0
- taskledger/exchange.py +343 -0
- taskledger/ids.py +19 -0
- taskledger/py.typed +0 -0
- taskledger/search.py +349 -0
- taskledger/services/__init__.py +1 -0
- taskledger/services/actors.py +245 -0
- taskledger/services/dashboard.py +306 -0
- taskledger/services/doctor.py +435 -0
- taskledger/services/handoff.py +1029 -0
- taskledger/services/handoff_lifecycle.py +154 -0
- taskledger/services/navigation.py +930 -0
- taskledger/services/phase5_lock_transfer.py +96 -0
- taskledger/services/plan_lint.py +397 -0
- taskledger/services/serve_read_model.py +852 -0
- taskledger/services/tasks.py +4224 -0
- taskledger/services/validation.py +221 -0
- taskledger/services/web_dashboard.py +1742 -0
- taskledger/storage/__init__.py +39 -0
- taskledger/storage/atomic.py +57 -0
- taskledger/storage/common.py +90 -0
- taskledger/storage/events.py +98 -0
- taskledger/storage/frontmatter.py +57 -0
- taskledger/storage/indexes.py +42 -0
- taskledger/storage/init.py +187 -0
- taskledger/storage/locks.py +83 -0
- taskledger/storage/meta.py +103 -0
- taskledger/storage/migrations.py +207 -0
- taskledger/storage/paths.py +166 -0
- taskledger/storage/project_config.py +393 -0
- taskledger/storage/repos.py +256 -0
- taskledger/storage/task_store.py +836 -0
- taskledger/timeutils.py +7 -0
- taskledger-0.1.0.dist-info/METADATA +411 -0
- taskledger-0.1.0.dist-info/RECORD +67 -0
- taskledger-0.1.0.dist-info/WHEEL +5 -0
- taskledger-0.1.0.dist-info/entry_points.txt +2 -0
- taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- taskledger-0.1.0.dist-info/top_level.txt +1 -0
taskledger/cli_common.py
ADDED
|
@@ -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
|
+
}
|