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_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)
|