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
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from taskledger.domain.models import (
|
|
9
|
+
PlanRecord,
|
|
10
|
+
QuestionRecord,
|
|
11
|
+
TaskLock,
|
|
12
|
+
TaskRecord,
|
|
13
|
+
TaskTodo,
|
|
14
|
+
)
|
|
15
|
+
from taskledger.domain.states import EXIT_CODE_BAD_INPUT, IMPLEMENTABLE_TASK_STAGES
|
|
16
|
+
from taskledger.services.tasks import (
|
|
17
|
+
_build_todo_gate_report,
|
|
18
|
+
_cli_error,
|
|
19
|
+
_current_lock,
|
|
20
|
+
_dependency_blockers,
|
|
21
|
+
_optional_run,
|
|
22
|
+
_task_active_stage,
|
|
23
|
+
_task_with_sidecars,
|
|
24
|
+
_todo_command_hints,
|
|
25
|
+
_todo_done_command,
|
|
26
|
+
)
|
|
27
|
+
from taskledger.services.validation import build_validation_gate_report
|
|
28
|
+
from taskledger.storage.locks import lock_is_expired
|
|
29
|
+
from taskledger.storage.task_store import (
|
|
30
|
+
list_plans,
|
|
31
|
+
list_questions,
|
|
32
|
+
list_runs,
|
|
33
|
+
resolve_task,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def next_action(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
38
|
+
task = resolve_task(workspace_root, task_ref)
|
|
39
|
+
lock = _current_lock(workspace_root, task.id)
|
|
40
|
+
runs = list_runs(workspace_root, task.id)
|
|
41
|
+
active_stage = _task_active_stage(
|
|
42
|
+
workspace_root,
|
|
43
|
+
task,
|
|
44
|
+
lock=lock,
|
|
45
|
+
runs=runs,
|
|
46
|
+
)
|
|
47
|
+
action: str
|
|
48
|
+
reason: str
|
|
49
|
+
blockers: list[dict[str, object]] = []
|
|
50
|
+
next_item: dict[str, object] | None = None
|
|
51
|
+
progress: dict[str, object] = {}
|
|
52
|
+
if active_stage == "planning":
|
|
53
|
+
questions = list_questions(workspace_root, task.id)
|
|
54
|
+
open_questions = _required_open_question_ids(questions)
|
|
55
|
+
answered_questions = [
|
|
56
|
+
item.id
|
|
57
|
+
for item in questions
|
|
58
|
+
if item.status == "answered" and item.required_for_plan
|
|
59
|
+
]
|
|
60
|
+
latest_plan = _latest_plan_or_none(workspace_root, task.id)
|
|
61
|
+
stale_answers = (
|
|
62
|
+
_stale_answer_question_ids(questions, latest_plan)
|
|
63
|
+
if latest_plan is not None
|
|
64
|
+
else answered_questions
|
|
65
|
+
)
|
|
66
|
+
if open_questions:
|
|
67
|
+
action, reason = (
|
|
68
|
+
"question-answer",
|
|
69
|
+
"Required planning questions are open.",
|
|
70
|
+
)
|
|
71
|
+
question = _first_question_by_ids(questions, open_questions)
|
|
72
|
+
next_item = _question_next_item(question) if question is not None else None
|
|
73
|
+
progress["questions"] = {
|
|
74
|
+
"required_open": len(open_questions),
|
|
75
|
+
"required_open_ids": open_questions,
|
|
76
|
+
}
|
|
77
|
+
blockers.append(
|
|
78
|
+
{
|
|
79
|
+
"kind": "open_questions",
|
|
80
|
+
"question_ids": open_questions,
|
|
81
|
+
"message": "Required planning questions must be answered.",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
elif stale_answers:
|
|
85
|
+
action, reason = (
|
|
86
|
+
"plan-regenerate",
|
|
87
|
+
"Answered planning questions should be reflected in the plan.",
|
|
88
|
+
)
|
|
89
|
+
question = _first_question_by_ids(questions, stale_answers)
|
|
90
|
+
next_item = (
|
|
91
|
+
_answered_question_next_item(question) if question is not None else None
|
|
92
|
+
)
|
|
93
|
+
progress["questions"] = {
|
|
94
|
+
"required_open": 0,
|
|
95
|
+
"required_open_ids": [],
|
|
96
|
+
"answered_since_latest_plan": stale_answers,
|
|
97
|
+
}
|
|
98
|
+
else:
|
|
99
|
+
action, reason = (
|
|
100
|
+
"plan-propose",
|
|
101
|
+
"Planning is active; propose the next plan.",
|
|
102
|
+
)
|
|
103
|
+
elif active_stage == "implementation":
|
|
104
|
+
todo_report = _build_todo_gate_report(workspace_root, task)
|
|
105
|
+
open_todo_ids = cast(list[str], todo_report.get("open_todos", []))
|
|
106
|
+
open_todo_count = len(open_todo_ids)
|
|
107
|
+
total_todos = todo_report.get("total", 0)
|
|
108
|
+
done_todos = todo_report.get("done", 0)
|
|
109
|
+
progress["todos"] = {
|
|
110
|
+
"total": total_todos if isinstance(total_todos, int) else 0,
|
|
111
|
+
"done": done_todos if isinstance(done_todos, int) else 0,
|
|
112
|
+
"open": open_todo_count,
|
|
113
|
+
"open_ids": open_todo_ids,
|
|
114
|
+
}
|
|
115
|
+
if open_todo_count > 0:
|
|
116
|
+
todo = _first_open_todo_from_report(workspace_root, task, open_todo_ids)
|
|
117
|
+
next_item = _todo_next_item(todo) if todo is not None else None
|
|
118
|
+
action, reason = (
|
|
119
|
+
"todo-work",
|
|
120
|
+
f"Implementation is in progress; {open_todo_count} todos remain.",
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
action, reason = (
|
|
124
|
+
"implement-finish",
|
|
125
|
+
"All todos done; ready to finish implementation.",
|
|
126
|
+
)
|
|
127
|
+
next_item = _task_next_item(task)
|
|
128
|
+
elif active_stage == "validation":
|
|
129
|
+
gate_report = build_validation_gate_report(workspace_root, task)
|
|
130
|
+
report_blockers = cast(list[dict[str, object]], gate_report.get("blockers", []))
|
|
131
|
+
blockers.extend(_compact_next_action_blockers(report_blockers))
|
|
132
|
+
progress["validation"] = _validation_progress(gate_report)
|
|
133
|
+
if report_blockers:
|
|
134
|
+
action, reason = (
|
|
135
|
+
"validate-check",
|
|
136
|
+
"Validation is in progress; required checks remain.",
|
|
137
|
+
)
|
|
138
|
+
next_item = _next_validation_item(
|
|
139
|
+
workspace_root,
|
|
140
|
+
task,
|
|
141
|
+
gate_report,
|
|
142
|
+
report_blockers,
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
action, reason = (
|
|
146
|
+
"validate-finish",
|
|
147
|
+
"Validation is complete enough to finish.",
|
|
148
|
+
)
|
|
149
|
+
next_item = _task_next_item(task)
|
|
150
|
+
elif task.status_stage == "draft":
|
|
151
|
+
action, reason = "plan", "Draft tasks need planning before work starts."
|
|
152
|
+
elif task.status_stage == "plan_review":
|
|
153
|
+
questions = list_questions(workspace_root, task.id)
|
|
154
|
+
open_questions = _required_open_question_ids(questions)
|
|
155
|
+
latest_plan = _latest_plan_or_none(workspace_root, task.id)
|
|
156
|
+
stale_answers = (
|
|
157
|
+
_stale_answer_question_ids(questions, latest_plan)
|
|
158
|
+
if latest_plan is not None
|
|
159
|
+
else []
|
|
160
|
+
)
|
|
161
|
+
if open_questions:
|
|
162
|
+
action, reason = (
|
|
163
|
+
"question-answer",
|
|
164
|
+
"Required planning questions are open.",
|
|
165
|
+
)
|
|
166
|
+
question = _first_question_by_ids(questions, open_questions)
|
|
167
|
+
next_item = _question_next_item(question) if question is not None else None
|
|
168
|
+
progress["questions"] = {
|
|
169
|
+
"required_open": len(open_questions),
|
|
170
|
+
"required_open_ids": open_questions,
|
|
171
|
+
}
|
|
172
|
+
blockers.append(
|
|
173
|
+
{
|
|
174
|
+
"kind": "open_questions",
|
|
175
|
+
"question_ids": open_questions,
|
|
176
|
+
"message": "Required planning questions must be answered.",
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
elif stale_answers:
|
|
180
|
+
action, reason = (
|
|
181
|
+
"plan-regenerate",
|
|
182
|
+
"Answered planning questions are not reflected in the latest plan.",
|
|
183
|
+
)
|
|
184
|
+
question = _first_question_by_ids(questions, stale_answers)
|
|
185
|
+
next_item = (
|
|
186
|
+
_answered_question_next_item(question) if question is not None else None
|
|
187
|
+
)
|
|
188
|
+
progress["questions"] = {
|
|
189
|
+
"required_open": 0,
|
|
190
|
+
"required_open_ids": [],
|
|
191
|
+
"answered_since_latest_plan": stale_answers,
|
|
192
|
+
}
|
|
193
|
+
blockers.append(
|
|
194
|
+
{
|
|
195
|
+
"kind": "stale_answers",
|
|
196
|
+
"question_ids": stale_answers,
|
|
197
|
+
"message": "Regenerate the plan from answered questions.",
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
action, reason = "plan-approve", "A proposed plan is waiting for review."
|
|
202
|
+
if latest_plan is not None:
|
|
203
|
+
next_item = _plan_next_item(latest_plan)
|
|
204
|
+
elif task.status_stage == "approved":
|
|
205
|
+
action, reason = "implement", "The approved plan is ready for implementation."
|
|
206
|
+
next_item = _task_next_item(task)
|
|
207
|
+
if task.accepted_plan_version is None:
|
|
208
|
+
blockers.append(
|
|
209
|
+
{"kind": "approval", "message": "No accepted plan version is recorded."}
|
|
210
|
+
)
|
|
211
|
+
blockers.extend(
|
|
212
|
+
cast(list[dict[str, object]], _dependency_blockers(workspace_root, task))
|
|
213
|
+
)
|
|
214
|
+
elif task.status_stage == "implemented":
|
|
215
|
+
action, reason = "validate", "Implementation is complete and ready to validate."
|
|
216
|
+
next_item = _task_next_item(task)
|
|
217
|
+
impl_run = _optional_run(workspace_root, task, task.latest_implementation_run)
|
|
218
|
+
if (
|
|
219
|
+
impl_run is None
|
|
220
|
+
or impl_run.run_type != "implementation"
|
|
221
|
+
or impl_run.status != "finished"
|
|
222
|
+
):
|
|
223
|
+
blockers.append(
|
|
224
|
+
{
|
|
225
|
+
"kind": "implementation",
|
|
226
|
+
"message": "Validation requires a finished implementation run.",
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
elif task.status_stage == "failed_validation":
|
|
230
|
+
action, reason = (
|
|
231
|
+
"implement-restart",
|
|
232
|
+
"Validation failed; restart implementation.",
|
|
233
|
+
)
|
|
234
|
+
next_item = _task_next_item(task)
|
|
235
|
+
blockers.extend(
|
|
236
|
+
cast(list[dict[str, object]], _dependency_blockers(workspace_root, task))
|
|
237
|
+
)
|
|
238
|
+
elif task.status_stage == "done":
|
|
239
|
+
action, reason = "none", "The task is complete."
|
|
240
|
+
else:
|
|
241
|
+
action, reason = "none", "The task is cancelled."
|
|
242
|
+
if lock is not None and active_stage is None:
|
|
243
|
+
blockers.append(
|
|
244
|
+
{
|
|
245
|
+
"kind": "lock",
|
|
246
|
+
"message": (
|
|
247
|
+
f"Task has a {lock.stage} lock from {lock.run_id} "
|
|
248
|
+
"without a matching running run."
|
|
249
|
+
),
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
action = "repair-lock"
|
|
253
|
+
reason = "A stale or broken lock must be repaired before work can continue."
|
|
254
|
+
next_item = _lock_next_item(task, lock)
|
|
255
|
+
next_command = _primary_command_for_next_item(action, next_item)
|
|
256
|
+
commands = _commands_for_next_item(action, next_item)
|
|
257
|
+
return {
|
|
258
|
+
"kind": "task_next_action",
|
|
259
|
+
"task_id": task.id,
|
|
260
|
+
"status_stage": task.status_stage,
|
|
261
|
+
"active_stage": active_stage,
|
|
262
|
+
"action": action,
|
|
263
|
+
"reason": reason,
|
|
264
|
+
"blocking": blockers,
|
|
265
|
+
"next_command": next_command,
|
|
266
|
+
"next_item": next_item,
|
|
267
|
+
"commands": commands,
|
|
268
|
+
"progress": progress,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def can_perform(workspace_root: Path, task_ref: str, action: str) -> dict[str, object]:
|
|
273
|
+
task = resolve_task(workspace_root, task_ref)
|
|
274
|
+
lock = _current_lock(workspace_root, task.id)
|
|
275
|
+
active_stage = _task_active_stage(workspace_root, task, lock=lock)
|
|
276
|
+
ok = False
|
|
277
|
+
reason = ""
|
|
278
|
+
blocking: list[dict[str, str]] = []
|
|
279
|
+
if action == "plan":
|
|
280
|
+
ok = task.status_stage in {"draft", "plan_review"} and lock is None
|
|
281
|
+
reason = (
|
|
282
|
+
"Planning can start from draft or after plan review."
|
|
283
|
+
if ok
|
|
284
|
+
else (
|
|
285
|
+
"Planning is only available from draft or plan_review "
|
|
286
|
+
"without an active lock."
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
if lock is not None:
|
|
290
|
+
blocking.append(
|
|
291
|
+
{
|
|
292
|
+
"kind": "lock",
|
|
293
|
+
"message": (
|
|
294
|
+
f"Task has an active {lock.stage} lock from {lock.run_id}."
|
|
295
|
+
),
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
elif action == "implement":
|
|
299
|
+
ok = (
|
|
300
|
+
task.status_stage in IMPLEMENTABLE_TASK_STAGES
|
|
301
|
+
and task.accepted_plan_version is not None
|
|
302
|
+
and not _dependency_blockers(workspace_root, task)
|
|
303
|
+
and lock is None
|
|
304
|
+
and active_stage is None
|
|
305
|
+
)
|
|
306
|
+
reason = (
|
|
307
|
+
"Implementation is ready."
|
|
308
|
+
if ok
|
|
309
|
+
else (
|
|
310
|
+
"Implementation requires an accepted plan, valid stage, "
|
|
311
|
+
"no conflicting lock, and completed dependencies."
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if task.accepted_plan_version is None:
|
|
315
|
+
blocking.append(
|
|
316
|
+
{"kind": "approval", "message": "No accepted plan version."}
|
|
317
|
+
)
|
|
318
|
+
blocking.extend(_dependency_blockers(workspace_root, task))
|
|
319
|
+
if lock is not None:
|
|
320
|
+
blocking.append(
|
|
321
|
+
{
|
|
322
|
+
"kind": "lock",
|
|
323
|
+
"message": (
|
|
324
|
+
f"Task has an active {lock.stage} lock from {lock.run_id}."
|
|
325
|
+
),
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
elif action == "implement-restart":
|
|
329
|
+
validation_run = _optional_run(workspace_root, task, task.latest_validation_run)
|
|
330
|
+
implementation_run = _optional_run(
|
|
331
|
+
workspace_root,
|
|
332
|
+
task,
|
|
333
|
+
task.latest_implementation_run,
|
|
334
|
+
)
|
|
335
|
+
ok = (
|
|
336
|
+
task.status_stage == "failed_validation"
|
|
337
|
+
and task.accepted_plan_version is not None
|
|
338
|
+
and validation_run is not None
|
|
339
|
+
and validation_run.run_type == "validation"
|
|
340
|
+
and validation_run.status in {"failed", "blocked"}
|
|
341
|
+
and validation_run.result in {"failed", "blocked"}
|
|
342
|
+
and implementation_run is not None
|
|
343
|
+
and implementation_run.run_type == "implementation"
|
|
344
|
+
and not _dependency_blockers(workspace_root, task)
|
|
345
|
+
and lock is None
|
|
346
|
+
and active_stage is None
|
|
347
|
+
)
|
|
348
|
+
reason = (
|
|
349
|
+
"Implementation restart is ready."
|
|
350
|
+
if ok
|
|
351
|
+
else (
|
|
352
|
+
"Implementation restart requires failed_validation state, "
|
|
353
|
+
"an accepted plan, recorded failed validation, a previous "
|
|
354
|
+
"implementation run, no conflicting lock, and completed dependencies."
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
if task.accepted_plan_version is None:
|
|
358
|
+
blocking.append(
|
|
359
|
+
{"kind": "approval", "message": "No accepted plan version."}
|
|
360
|
+
)
|
|
361
|
+
if (
|
|
362
|
+
validation_run is None
|
|
363
|
+
or validation_run.run_type != "validation"
|
|
364
|
+
or validation_run.status not in {"failed", "blocked"}
|
|
365
|
+
or validation_run.result not in {"failed", "blocked"}
|
|
366
|
+
):
|
|
367
|
+
blocking.append(
|
|
368
|
+
{
|
|
369
|
+
"kind": "validation",
|
|
370
|
+
"message": "No failed validation run is available for restart.",
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
if (
|
|
374
|
+
implementation_run is None
|
|
375
|
+
or implementation_run.run_type != "implementation"
|
|
376
|
+
):
|
|
377
|
+
blocking.append(
|
|
378
|
+
{
|
|
379
|
+
"kind": "implementation",
|
|
380
|
+
"message": "No previous implementation run is available.",
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
blocking.extend(_dependency_blockers(workspace_root, task))
|
|
384
|
+
if lock is not None:
|
|
385
|
+
blocking.append(
|
|
386
|
+
{
|
|
387
|
+
"kind": "lock",
|
|
388
|
+
"message": (
|
|
389
|
+
f"Task has an active {lock.stage} lock from {lock.run_id}."
|
|
390
|
+
),
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
elif action == "validate":
|
|
394
|
+
impl_run = _optional_run(workspace_root, task, task.latest_implementation_run)
|
|
395
|
+
ok = (
|
|
396
|
+
task.status_stage == "implemented"
|
|
397
|
+
and lock is None
|
|
398
|
+
and active_stage is None
|
|
399
|
+
and impl_run is not None
|
|
400
|
+
and impl_run.run_type == "implementation"
|
|
401
|
+
and impl_run.status == "finished"
|
|
402
|
+
)
|
|
403
|
+
reason = (
|
|
404
|
+
"Validation is ready."
|
|
405
|
+
if ok
|
|
406
|
+
else (
|
|
407
|
+
"Validation requires implemented state, a finished "
|
|
408
|
+
"implementation run, and no conflicting lock."
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
if (
|
|
412
|
+
impl_run is None
|
|
413
|
+
or impl_run.run_type != "implementation"
|
|
414
|
+
or impl_run.status != "finished"
|
|
415
|
+
):
|
|
416
|
+
blocking.append(
|
|
417
|
+
{
|
|
418
|
+
"kind": "implementation",
|
|
419
|
+
"message": "No finished implementation run is available.",
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
if lock is not None:
|
|
423
|
+
blocking.append(
|
|
424
|
+
{
|
|
425
|
+
"kind": "lock",
|
|
426
|
+
"message": (
|
|
427
|
+
f"Task has an active {lock.stage} lock from {lock.run_id}."
|
|
428
|
+
),
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
raise _cli_error(f"Unsupported action: {action}", EXIT_CODE_BAD_INPUT)
|
|
433
|
+
return {
|
|
434
|
+
"kind": "task_capability",
|
|
435
|
+
"task_id": task.id,
|
|
436
|
+
"action": action,
|
|
437
|
+
"ok": ok,
|
|
438
|
+
"reason": reason,
|
|
439
|
+
"active_stage": active_stage,
|
|
440
|
+
"blocking": blocking,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def task_dossier(
|
|
445
|
+
workspace_root: Path,
|
|
446
|
+
task_ref: str,
|
|
447
|
+
*,
|
|
448
|
+
format_name: str = "markdown",
|
|
449
|
+
) -> str | dict[str, object]:
|
|
450
|
+
from taskledger.services.handoff import render_handoff
|
|
451
|
+
|
|
452
|
+
return render_handoff(
|
|
453
|
+
workspace_root,
|
|
454
|
+
task_ref,
|
|
455
|
+
mode="full",
|
|
456
|
+
format_name=format_name,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _answer_snapshot_hash(questions: list[QuestionRecord]) -> str | None:
|
|
461
|
+
answered = [
|
|
462
|
+
f"{item.id}\0{item.answer or ''}"
|
|
463
|
+
for item in questions
|
|
464
|
+
if item.status == "answered"
|
|
465
|
+
]
|
|
466
|
+
if not answered:
|
|
467
|
+
return None
|
|
468
|
+
digest = hashlib.sha256("\n".join(sorted(answered)).encode("utf-8")).hexdigest()
|
|
469
|
+
return f"sha256:{digest}"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _required_open_question_ids(questions: list[QuestionRecord]) -> list[str]:
|
|
473
|
+
return [
|
|
474
|
+
item.id
|
|
475
|
+
for item in questions
|
|
476
|
+
if item.status == "open" and item.required_for_plan
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _latest_plan_or_none(workspace_root: Path, task_id: str) -> PlanRecord | None:
|
|
481
|
+
plans = list_plans(workspace_root, task_id)
|
|
482
|
+
return plans[-1] if plans else None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _stale_answer_question_ids(
|
|
486
|
+
questions: list[QuestionRecord],
|
|
487
|
+
plan: PlanRecord,
|
|
488
|
+
) -> list[str]:
|
|
489
|
+
answered = [
|
|
490
|
+
item
|
|
491
|
+
for item in questions
|
|
492
|
+
if item.status == "answered" and item.required_for_plan
|
|
493
|
+
]
|
|
494
|
+
if not answered:
|
|
495
|
+
return []
|
|
496
|
+
current_hash = _answer_snapshot_hash(questions)
|
|
497
|
+
if (
|
|
498
|
+
plan.generation_reason == "after_questions"
|
|
499
|
+
and plan.based_on_answer_hash == current_hash
|
|
500
|
+
):
|
|
501
|
+
return []
|
|
502
|
+
return [item.id for item in answered]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _question_next_item(question: QuestionRecord) -> dict[str, object]:
|
|
506
|
+
return {
|
|
507
|
+
"kind": "question",
|
|
508
|
+
"id": question.id,
|
|
509
|
+
"text": question.question,
|
|
510
|
+
"status": question.status,
|
|
511
|
+
"required_for_plan": question.required_for_plan,
|
|
512
|
+
"plan_version": question.plan_version,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _answered_question_next_item(question: QuestionRecord) -> dict[str, object]:
|
|
517
|
+
return {
|
|
518
|
+
"kind": "answered_question",
|
|
519
|
+
"id": question.id,
|
|
520
|
+
"text": question.question,
|
|
521
|
+
"status": question.status,
|
|
522
|
+
"answer": question.answer,
|
|
523
|
+
"answered_at": question.answered_at,
|
|
524
|
+
"required_for_plan": question.required_for_plan,
|
|
525
|
+
"plan_version": question.plan_version,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _todo_next_item(todo: TaskTodo) -> dict[str, object]:
|
|
530
|
+
return {
|
|
531
|
+
"kind": "todo",
|
|
532
|
+
"id": todo.id,
|
|
533
|
+
"text": todo.text,
|
|
534
|
+
"status": todo.status,
|
|
535
|
+
"mandatory": todo.mandatory,
|
|
536
|
+
"source": todo.source,
|
|
537
|
+
"done": todo.done,
|
|
538
|
+
"validation_hint": todo.validation_hint,
|
|
539
|
+
"done_command_hint": _todo_done_command(todo.id),
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _criterion_next_item(criterion_report: Mapping[str, object]) -> dict[str, object]:
|
|
544
|
+
return {
|
|
545
|
+
"kind": "criterion",
|
|
546
|
+
"id": criterion_report.get("id"),
|
|
547
|
+
"text": criterion_report.get("text"),
|
|
548
|
+
"mandatory": criterion_report.get("mandatory"),
|
|
549
|
+
"latest_status": criterion_report.get("latest_status"),
|
|
550
|
+
"satisfied": criterion_report.get("satisfied"),
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _plan_next_item(plan: PlanRecord) -> dict[str, object]:
|
|
555
|
+
return {
|
|
556
|
+
"kind": "plan",
|
|
557
|
+
"id": f"plan-v{plan.plan_version}",
|
|
558
|
+
"version": plan.plan_version,
|
|
559
|
+
"status": plan.status,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _task_next_item(task: TaskRecord) -> dict[str, object]:
|
|
564
|
+
return {
|
|
565
|
+
"kind": "task",
|
|
566
|
+
"id": task.id,
|
|
567
|
+
"status_stage": task.status_stage,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _lock_next_item(task: TaskRecord, lock: TaskLock) -> dict[str, object]:
|
|
572
|
+
return {
|
|
573
|
+
"kind": "lock",
|
|
574
|
+
"id": lock.lock_id,
|
|
575
|
+
"task_id": task.id,
|
|
576
|
+
"stage": lock.stage,
|
|
577
|
+
"run_id": lock.run_id,
|
|
578
|
+
"expired": lock_is_expired(lock),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _command(
|
|
583
|
+
kind: str,
|
|
584
|
+
label: str,
|
|
585
|
+
command: str,
|
|
586
|
+
*,
|
|
587
|
+
primary: bool = False,
|
|
588
|
+
) -> dict[str, object]:
|
|
589
|
+
return {
|
|
590
|
+
"kind": kind,
|
|
591
|
+
"label": label,
|
|
592
|
+
"command": command,
|
|
593
|
+
"primary": primary,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _first_question_by_ids(
|
|
598
|
+
questions: Sequence[QuestionRecord],
|
|
599
|
+
ids: Sequence[str],
|
|
600
|
+
) -> QuestionRecord | None:
|
|
601
|
+
wanted = set(ids)
|
|
602
|
+
for question in questions:
|
|
603
|
+
if question.id in wanted:
|
|
604
|
+
return question
|
|
605
|
+
return None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _first_open_todo_from_report(
|
|
609
|
+
workspace_root: Path,
|
|
610
|
+
task: TaskRecord,
|
|
611
|
+
open_ids: Sequence[str],
|
|
612
|
+
) -> TaskTodo | None:
|
|
613
|
+
task = _task_with_sidecars(workspace_root, task)
|
|
614
|
+
wanted = set(open_ids)
|
|
615
|
+
for todo in task.todos:
|
|
616
|
+
if todo.id in wanted and todo.status == "active" and not todo.done:
|
|
617
|
+
return todo
|
|
618
|
+
for todo in task.todos:
|
|
619
|
+
if todo.id in wanted and not todo.done:
|
|
620
|
+
return todo
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _criterion_report_by_id(
|
|
625
|
+
gate_report: Mapping[str, object],
|
|
626
|
+
criterion_id: str,
|
|
627
|
+
) -> dict[str, object] | None:
|
|
628
|
+
criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
|
|
629
|
+
for criterion in criteria:
|
|
630
|
+
if criterion.get("id") == criterion_id:
|
|
631
|
+
return criterion
|
|
632
|
+
return None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _compact_next_action_blockers(
|
|
636
|
+
blockers: Sequence[Mapping[str, object]],
|
|
637
|
+
) -> list[dict[str, object]]:
|
|
638
|
+
compact: list[dict[str, object]] = []
|
|
639
|
+
for blocker in blockers:
|
|
640
|
+
item: dict[str, object] = {
|
|
641
|
+
"kind": str(blocker.get("kind", "blocker")),
|
|
642
|
+
"message": str(blocker.get("message", "Next-action blocker")),
|
|
643
|
+
}
|
|
644
|
+
ref = blocker.get("ref")
|
|
645
|
+
if isinstance(ref, str) and ref:
|
|
646
|
+
item["ref"] = ref
|
|
647
|
+
command_hint = _optional_string_value(blocker.get("command_hint"))
|
|
648
|
+
if command_hint is not None:
|
|
649
|
+
item["command_hint"] = command_hint
|
|
650
|
+
compact.append(item)
|
|
651
|
+
return compact
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _validation_progress(gate_report: Mapping[str, object]) -> dict[str, object]:
|
|
655
|
+
criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
|
|
656
|
+
satisfied = sum(1 for criterion in criteria if criterion.get("satisfied") is True)
|
|
657
|
+
blocking_ids: list[str] = []
|
|
658
|
+
for blocker in cast(list[dict[str, object]], gate_report.get("blockers", [])):
|
|
659
|
+
ref = blocker.get("ref")
|
|
660
|
+
if isinstance(ref, str) and ref and ref not in blocking_ids:
|
|
661
|
+
blocking_ids.append(ref)
|
|
662
|
+
return {
|
|
663
|
+
"total": len(criteria),
|
|
664
|
+
"satisfied": satisfied,
|
|
665
|
+
"remaining": max(len(blocking_ids), len(criteria) - satisfied),
|
|
666
|
+
"blocking_ids": blocking_ids,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _next_validation_item(
|
|
671
|
+
workspace_root: Path,
|
|
672
|
+
task: TaskRecord,
|
|
673
|
+
gate_report: Mapping[str, object],
|
|
674
|
+
blockers: Sequence[Mapping[str, object]],
|
|
675
|
+
) -> dict[str, object] | None:
|
|
676
|
+
priority = (
|
|
677
|
+
"criterion_fail",
|
|
678
|
+
"criterion_missing",
|
|
679
|
+
"criterion_unsatisfied",
|
|
680
|
+
"todo_open",
|
|
681
|
+
"no_finished_implementation",
|
|
682
|
+
"dependency_blocker",
|
|
683
|
+
"no_accepted_plan",
|
|
684
|
+
"plan_not_accepted",
|
|
685
|
+
)
|
|
686
|
+
for kind in priority:
|
|
687
|
+
for blocker in blockers:
|
|
688
|
+
if blocker.get("kind") != kind:
|
|
689
|
+
continue
|
|
690
|
+
ref = blocker.get("ref")
|
|
691
|
+
if kind.startswith("criterion_") and isinstance(ref, str):
|
|
692
|
+
criterion = _criterion_report_by_id(gate_report, ref)
|
|
693
|
+
if criterion is not None:
|
|
694
|
+
return _criterion_next_item(criterion)
|
|
695
|
+
if kind == "todo_open" and isinstance(ref, str):
|
|
696
|
+
todo = _first_open_todo_from_report(workspace_root, task, (ref,))
|
|
697
|
+
if todo is not None:
|
|
698
|
+
return _todo_next_item(todo)
|
|
699
|
+
if kind == "dependency_blocker" and isinstance(ref, str):
|
|
700
|
+
return {"kind": "dependency", "id": ref}
|
|
701
|
+
if kind == "no_finished_implementation":
|
|
702
|
+
return _task_next_item(task)
|
|
703
|
+
if kind in {"no_accepted_plan", "plan_not_accepted"}:
|
|
704
|
+
plan = _latest_plan_or_none(workspace_root, task.id)
|
|
705
|
+
if plan is not None:
|
|
706
|
+
return _plan_next_item(plan)
|
|
707
|
+
return _task_next_item(task)
|
|
708
|
+
|
|
709
|
+
for criterion in cast(list[dict[str, object]], gate_report.get("criteria", [])):
|
|
710
|
+
criterion_blockers = criterion.get("blockers")
|
|
711
|
+
if isinstance(criterion_blockers, list) and criterion_blockers:
|
|
712
|
+
return _criterion_next_item(criterion)
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _next_action_command(action: str) -> str | None:
|
|
717
|
+
return {
|
|
718
|
+
"plan": "taskledger plan start",
|
|
719
|
+
"plan-propose": "taskledger plan upsert --file plan.md",
|
|
720
|
+
"question-answer": "taskledger question answer-many --file answers.yaml",
|
|
721
|
+
"plan-regenerate": "taskledger plan upsert --from-answers --file plan.md",
|
|
722
|
+
"plan-approve": "taskledger plan approve --version VERSION --actor user",
|
|
723
|
+
"implement": "taskledger implement start",
|
|
724
|
+
"implement-restart": "taskledger implement restart --summary SUMMARY",
|
|
725
|
+
"todo-work": "taskledger implement checklist",
|
|
726
|
+
"implement-finish": "taskledger implement finish --summary SUMMARY",
|
|
727
|
+
"validate": "taskledger validate start",
|
|
728
|
+
"validate-check": (
|
|
729
|
+
"taskledger validate check --criterion CRITERION "
|
|
730
|
+
'--status pass --evidence "..."'
|
|
731
|
+
),
|
|
732
|
+
"validate-finish": (
|
|
733
|
+
"taskledger validate finish --result passed --summary SUMMARY"
|
|
734
|
+
),
|
|
735
|
+
"repair-lock": "taskledger lock show",
|
|
736
|
+
}.get(action)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _primary_command_for_next_item(
|
|
740
|
+
action: str,
|
|
741
|
+
next_item: dict[str, object] | None,
|
|
742
|
+
) -> str | None:
|
|
743
|
+
if not next_item:
|
|
744
|
+
return _next_action_command(action)
|
|
745
|
+
|
|
746
|
+
kind = next_item.get("kind")
|
|
747
|
+
item_id = next_item.get("id")
|
|
748
|
+
|
|
749
|
+
if kind == "question" and isinstance(item_id, str):
|
|
750
|
+
return f'taskledger question answer {item_id} --text "..."'
|
|
751
|
+
if kind == "todo" and isinstance(item_id, str):
|
|
752
|
+
return f"taskledger todo show {item_id}"
|
|
753
|
+
if kind == "criterion" and isinstance(item_id, str):
|
|
754
|
+
return (
|
|
755
|
+
f"taskledger validate check --criterion {item_id} "
|
|
756
|
+
'--status pass --evidence "..."'
|
|
757
|
+
)
|
|
758
|
+
if kind == "plan":
|
|
759
|
+
version = next_item.get("version")
|
|
760
|
+
if isinstance(version, int):
|
|
761
|
+
return f"taskledger plan show --version {version}"
|
|
762
|
+
if kind == "lock":
|
|
763
|
+
task_id = next_item.get("task_id")
|
|
764
|
+
if isinstance(task_id, str):
|
|
765
|
+
return f'taskledger lock break --task {task_id} --reason "..."'
|
|
766
|
+
|
|
767
|
+
return _next_action_command(action)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _commands_for_next_item(
|
|
771
|
+
action: str,
|
|
772
|
+
next_item: dict[str, object] | None,
|
|
773
|
+
) -> list[dict[str, object]]:
|
|
774
|
+
if next_item is None:
|
|
775
|
+
primary = _primary_command_for_next_item(action, next_item)
|
|
776
|
+
if primary is None:
|
|
777
|
+
return []
|
|
778
|
+
label = {
|
|
779
|
+
"plan": "Start planning",
|
|
780
|
+
"plan-propose": "Propose plan",
|
|
781
|
+
"plan-regenerate": "Regenerate plan from answers",
|
|
782
|
+
"plan-approve": "Approve plan",
|
|
783
|
+
"implement": "Start implementation",
|
|
784
|
+
"implement-restart": "Restart implementation",
|
|
785
|
+
"todo-work": "Show implementation checklist",
|
|
786
|
+
"implement-finish": "Finish implementation",
|
|
787
|
+
"validate": "Start validation",
|
|
788
|
+
"validate-check": "Record validation check",
|
|
789
|
+
"validate-finish": "Finish validation",
|
|
790
|
+
"repair-lock": "Show current lock",
|
|
791
|
+
}.get(action, "Show next action")
|
|
792
|
+
command_kind = {
|
|
793
|
+
"plan": "start",
|
|
794
|
+
"plan-propose": "regenerate",
|
|
795
|
+
"plan-regenerate": "regenerate",
|
|
796
|
+
"plan-approve": "approve",
|
|
797
|
+
"implement": "start",
|
|
798
|
+
"implement-restart": "restart",
|
|
799
|
+
"todo-work": "context",
|
|
800
|
+
"implement-finish": "finish",
|
|
801
|
+
"validate": "start",
|
|
802
|
+
"validate-check": "check",
|
|
803
|
+
"validate-finish": "finish",
|
|
804
|
+
"repair-lock": "inspect",
|
|
805
|
+
}.get(action, "context")
|
|
806
|
+
return [_command(command_kind, label, primary, primary=True)]
|
|
807
|
+
|
|
808
|
+
item_kind = next_item.get("kind")
|
|
809
|
+
item_id = next_item.get("id")
|
|
810
|
+
if item_kind == "question" and isinstance(item_id, str):
|
|
811
|
+
return [
|
|
812
|
+
_command(
|
|
813
|
+
"answer",
|
|
814
|
+
"Answer required question",
|
|
815
|
+
f'taskledger question answer {item_id} --text "..."',
|
|
816
|
+
primary=True,
|
|
817
|
+
),
|
|
818
|
+
_command("context", "Show question status", "taskledger question status"),
|
|
819
|
+
]
|
|
820
|
+
if item_kind == "answered_question":
|
|
821
|
+
return [
|
|
822
|
+
_command(
|
|
823
|
+
"regenerate",
|
|
824
|
+
"Regenerate plan from answers",
|
|
825
|
+
"taskledger plan upsert --from-answers --file plan.md",
|
|
826
|
+
primary=True,
|
|
827
|
+
),
|
|
828
|
+
_command(
|
|
829
|
+
"context",
|
|
830
|
+
"Show answered questions",
|
|
831
|
+
"taskledger question answers",
|
|
832
|
+
),
|
|
833
|
+
]
|
|
834
|
+
if item_kind == "todo" and isinstance(item_id, str):
|
|
835
|
+
return [
|
|
836
|
+
*_todo_command_hints(item_id),
|
|
837
|
+
_command(
|
|
838
|
+
"context",
|
|
839
|
+
"Show implementation checklist",
|
|
840
|
+
"taskledger implement checklist",
|
|
841
|
+
),
|
|
842
|
+
]
|
|
843
|
+
if item_kind == "criterion" and isinstance(item_id, str):
|
|
844
|
+
return [
|
|
845
|
+
_command(
|
|
846
|
+
"check",
|
|
847
|
+
"Record validation check",
|
|
848
|
+
(
|
|
849
|
+
f"taskledger validate check --criterion {item_id} "
|
|
850
|
+
'--status pass --evidence "..."'
|
|
851
|
+
),
|
|
852
|
+
primary=True,
|
|
853
|
+
),
|
|
854
|
+
_command("context", "Show validation status", "taskledger validate status"),
|
|
855
|
+
]
|
|
856
|
+
if item_kind == "plan":
|
|
857
|
+
version = next_item.get("version")
|
|
858
|
+
if isinstance(version, int):
|
|
859
|
+
commands = [
|
|
860
|
+
_command(
|
|
861
|
+
"inspect",
|
|
862
|
+
"Show proposed plan",
|
|
863
|
+
f"taskledger plan show --version {version}",
|
|
864
|
+
primary=True,
|
|
865
|
+
)
|
|
866
|
+
]
|
|
867
|
+
if action == "plan-approve":
|
|
868
|
+
commands.append(
|
|
869
|
+
_command(
|
|
870
|
+
"approve",
|
|
871
|
+
"Approve plan",
|
|
872
|
+
f"taskledger plan approve --version {version} --actor user",
|
|
873
|
+
)
|
|
874
|
+
)
|
|
875
|
+
return commands
|
|
876
|
+
if item_kind == "lock":
|
|
877
|
+
task_id = next_item.get("task_id")
|
|
878
|
+
if isinstance(task_id, str):
|
|
879
|
+
return [
|
|
880
|
+
_command(
|
|
881
|
+
"repair",
|
|
882
|
+
"Break stale lock",
|
|
883
|
+
f'taskledger lock break --task {task_id} --reason "..."',
|
|
884
|
+
primary=True,
|
|
885
|
+
),
|
|
886
|
+
_command("inspect", "Show current lock", "taskledger lock show"),
|
|
887
|
+
]
|
|
888
|
+
|
|
889
|
+
primary = _primary_command_for_next_item(action, next_item)
|
|
890
|
+
if primary is None:
|
|
891
|
+
return []
|
|
892
|
+
label = {
|
|
893
|
+
"implement": "Start implementation",
|
|
894
|
+
"implement-restart": "Restart implementation",
|
|
895
|
+
"implement-finish": "Finish implementation",
|
|
896
|
+
"validate": "Start validation",
|
|
897
|
+
"validate-finish": "Finish validation",
|
|
898
|
+
}.get(action, "Show next action")
|
|
899
|
+
kind_name = {
|
|
900
|
+
"implement": "start",
|
|
901
|
+
"implement-restart": "restart",
|
|
902
|
+
"implement-finish": "finish",
|
|
903
|
+
"validate": "start",
|
|
904
|
+
"validate-finish": "finish",
|
|
905
|
+
}.get(action, "context")
|
|
906
|
+
commands = [_command(kind_name, label, primary, primary=True)]
|
|
907
|
+
if action == "implement-finish":
|
|
908
|
+
commands.append(
|
|
909
|
+
_command(
|
|
910
|
+
"context",
|
|
911
|
+
"Show implementation checklist",
|
|
912
|
+
"taskledger implement checklist",
|
|
913
|
+
)
|
|
914
|
+
)
|
|
915
|
+
if action == "validate-finish":
|
|
916
|
+
commands.append(
|
|
917
|
+
_command(
|
|
918
|
+
"context",
|
|
919
|
+
"Show validation status",
|
|
920
|
+
"taskledger validate status",
|
|
921
|
+
)
|
|
922
|
+
)
|
|
923
|
+
return commands
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _optional_string_value(value: object) -> str | None:
|
|
927
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
__all__ = ["can_perform", "next_action", "task_dossier"]
|