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,4224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import getpass
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal, TypedDict, cast
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from taskledger.domain.models import (
|
|
19
|
+
AcceptanceCriterion,
|
|
20
|
+
ActiveTaskState,
|
|
21
|
+
ActorRef,
|
|
22
|
+
CodeChangeRecord,
|
|
23
|
+
CriterionWaiver,
|
|
24
|
+
DependencyRequirement,
|
|
25
|
+
DependencyWaiver,
|
|
26
|
+
FileLink,
|
|
27
|
+
HarnessRef,
|
|
28
|
+
IntroductionRecord,
|
|
29
|
+
LinkCollection,
|
|
30
|
+
PlanRecord,
|
|
31
|
+
QuestionRecord,
|
|
32
|
+
RequirementCollection,
|
|
33
|
+
TaskEvent,
|
|
34
|
+
TaskLock,
|
|
35
|
+
TaskRecord,
|
|
36
|
+
TaskRunRecord,
|
|
37
|
+
TaskTodo,
|
|
38
|
+
TodoCollection,
|
|
39
|
+
ValidationCheck,
|
|
40
|
+
)
|
|
41
|
+
from taskledger.domain.policies import (
|
|
42
|
+
Decision,
|
|
43
|
+
derive_active_stage,
|
|
44
|
+
implementation_mutation_decision,
|
|
45
|
+
metadata_edit_decision,
|
|
46
|
+
plan_approve_decision,
|
|
47
|
+
plan_command_decision,
|
|
48
|
+
plan_propose_decision,
|
|
49
|
+
plan_revise_decision,
|
|
50
|
+
question_add_decision,
|
|
51
|
+
question_mutation_decision,
|
|
52
|
+
require_known_actor_role,
|
|
53
|
+
todo_add_decision,
|
|
54
|
+
todo_toggle_decision,
|
|
55
|
+
validation_check_decision,
|
|
56
|
+
)
|
|
57
|
+
from taskledger.domain.states import (
|
|
58
|
+
ACTIVE_TASK_STAGES,
|
|
59
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
60
|
+
EXIT_CODE_BAD_INPUT,
|
|
61
|
+
EXIT_CODE_DEPENDENCY_BLOCKED,
|
|
62
|
+
EXIT_CODE_GENERIC_FAILURE,
|
|
63
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
64
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
65
|
+
EXIT_CODE_MISSING,
|
|
66
|
+
EXIT_CODE_STALE_LOCK_REQUIRES_BREAK,
|
|
67
|
+
EXIT_CODE_VALIDATION_FAILED,
|
|
68
|
+
IMPLEMENTABLE_TASK_STAGES,
|
|
69
|
+
TaskStatusStage,
|
|
70
|
+
normalize_file_link_kind,
|
|
71
|
+
normalize_run_type,
|
|
72
|
+
normalize_validation_check_status,
|
|
73
|
+
normalize_validation_result,
|
|
74
|
+
require_transition,
|
|
75
|
+
)
|
|
76
|
+
from taskledger.errors import LaunchError, LockConflict, NoActiveTask
|
|
77
|
+
from taskledger.ids import next_project_id, slugify_project_ref
|
|
78
|
+
from taskledger.services.plan_lint import lint_plan
|
|
79
|
+
from taskledger.storage.atomic import atomic_write_text
|
|
80
|
+
from taskledger.storage.events import append_event, load_events, next_event_id
|
|
81
|
+
from taskledger.storage.indexes import rebuild_v2_indexes
|
|
82
|
+
from taskledger.storage.locks import (
|
|
83
|
+
lock_is_expired,
|
|
84
|
+
lock_status,
|
|
85
|
+
read_lock,
|
|
86
|
+
remove_lock,
|
|
87
|
+
write_lock,
|
|
88
|
+
)
|
|
89
|
+
from taskledger.storage.task_store import (
|
|
90
|
+
V2Paths,
|
|
91
|
+
ensure_v2_layout,
|
|
92
|
+
list_changes,
|
|
93
|
+
list_introductions,
|
|
94
|
+
list_plans,
|
|
95
|
+
list_questions,
|
|
96
|
+
list_runs,
|
|
97
|
+
list_tasks,
|
|
98
|
+
load_active_locks,
|
|
99
|
+
load_active_task_state,
|
|
100
|
+
load_links,
|
|
101
|
+
load_requirements,
|
|
102
|
+
load_todos,
|
|
103
|
+
overwrite_plan,
|
|
104
|
+
resolve_introduction,
|
|
105
|
+
resolve_plan,
|
|
106
|
+
resolve_question,
|
|
107
|
+
resolve_run,
|
|
108
|
+
resolve_task,
|
|
109
|
+
resolve_v2_paths,
|
|
110
|
+
save_active_task_state,
|
|
111
|
+
save_change,
|
|
112
|
+
save_introduction,
|
|
113
|
+
save_links,
|
|
114
|
+
save_plan,
|
|
115
|
+
save_question,
|
|
116
|
+
save_requirements,
|
|
117
|
+
save_run,
|
|
118
|
+
save_task,
|
|
119
|
+
save_todos,
|
|
120
|
+
task_artifacts_dir,
|
|
121
|
+
task_audit_dir,
|
|
122
|
+
task_lock_path,
|
|
123
|
+
)
|
|
124
|
+
from taskledger.storage.task_store import (
|
|
125
|
+
resolve_active_task as storage_resolve_active_task,
|
|
126
|
+
)
|
|
127
|
+
from taskledger.timeutils import utc_now_iso
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def create_task(
|
|
131
|
+
workspace_root: Path,
|
|
132
|
+
*,
|
|
133
|
+
title: str,
|
|
134
|
+
description: str,
|
|
135
|
+
slug: str | None = None,
|
|
136
|
+
priority: str | None = None,
|
|
137
|
+
labels: tuple[str, ...] = (),
|
|
138
|
+
owner: str | None = None,
|
|
139
|
+
) -> TaskRecord:
|
|
140
|
+
paths = ensure_v2_layout(workspace_root)
|
|
141
|
+
tasks = list_tasks(workspace_root)
|
|
142
|
+
task_slug = _unique_slug(tasks, slug or title)
|
|
143
|
+
task = TaskRecord(
|
|
144
|
+
id=next_project_id("task", [item.id for item in tasks]),
|
|
145
|
+
slug=task_slug,
|
|
146
|
+
title=title,
|
|
147
|
+
body=description.strip(),
|
|
148
|
+
description_summary=_summary_line(description),
|
|
149
|
+
priority=priority,
|
|
150
|
+
labels=tuple(dict.fromkeys(labels)),
|
|
151
|
+
owner=owner,
|
|
152
|
+
)
|
|
153
|
+
save_task(workspace_root, task)
|
|
154
|
+
_append_event(
|
|
155
|
+
paths.project_dir,
|
|
156
|
+
task.id,
|
|
157
|
+
"task.created",
|
|
158
|
+
{"slug": task.slug, "title": task.title},
|
|
159
|
+
)
|
|
160
|
+
rebuild_v2_indexes(paths)
|
|
161
|
+
return task
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def list_task_summaries(workspace_root: Path) -> list[dict[str, object]]:
|
|
165
|
+
tasks = list_tasks(workspace_root)
|
|
166
|
+
active_state = load_active_task_state(workspace_root)
|
|
167
|
+
active_task_id = active_state.task_id if active_state is not None else None
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
"id": task.id,
|
|
171
|
+
"slug": task.slug,
|
|
172
|
+
"title": task.title,
|
|
173
|
+
"status": task.status_stage,
|
|
174
|
+
"status_stage": task.status_stage,
|
|
175
|
+
"is_active": task.id == active_task_id,
|
|
176
|
+
"active_stage": _task_active_stage(workspace_root, task),
|
|
177
|
+
"accepted_plan_version": task.accepted_plan_version,
|
|
178
|
+
}
|
|
179
|
+
for task in tasks
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def resolve_active_task(workspace_root: Path) -> TaskRecord:
|
|
184
|
+
return storage_resolve_active_task(workspace_root)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def show_active_task(workspace_root: Path) -> dict[str, object]:
|
|
188
|
+
state = load_active_task_state(workspace_root)
|
|
189
|
+
if state is None:
|
|
190
|
+
raise NoActiveTask()
|
|
191
|
+
task = storage_resolve_active_task(workspace_root)
|
|
192
|
+
return _active_task_payload(
|
|
193
|
+
workspace_root,
|
|
194
|
+
task,
|
|
195
|
+
state=state,
|
|
196
|
+
changed=False,
|
|
197
|
+
previous_task_id=state.previous_task_id,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def activate_task(
|
|
202
|
+
workspace_root: Path,
|
|
203
|
+
ref: str,
|
|
204
|
+
*,
|
|
205
|
+
reason: str | None = None,
|
|
206
|
+
actor_type: str = "user",
|
|
207
|
+
force: bool = False,
|
|
208
|
+
) -> dict[str, object]:
|
|
209
|
+
task = resolve_task(workspace_root, ref)
|
|
210
|
+
previous = load_active_task_state(workspace_root)
|
|
211
|
+
previous_task_id = previous.task_id if previous is not None else None
|
|
212
|
+
if previous_task_id == task.id and previous is not None:
|
|
213
|
+
return _active_task_payload(
|
|
214
|
+
workspace_root,
|
|
215
|
+
task,
|
|
216
|
+
state=previous,
|
|
217
|
+
changed=False,
|
|
218
|
+
previous_task_id=previous.previous_task_id,
|
|
219
|
+
)
|
|
220
|
+
if previous_task_id is not None:
|
|
221
|
+
_ensure_active_switch_allowed(
|
|
222
|
+
workspace_root,
|
|
223
|
+
previous_task_id,
|
|
224
|
+
force=force,
|
|
225
|
+
reason=reason,
|
|
226
|
+
)
|
|
227
|
+
state = ActiveTaskState(
|
|
228
|
+
task_id=task.id,
|
|
229
|
+
activated_by=_actor_for_active_task(actor_type),
|
|
230
|
+
reason=reason,
|
|
231
|
+
previous_task_id=previous_task_id,
|
|
232
|
+
)
|
|
233
|
+
save_active_task_state(workspace_root, state)
|
|
234
|
+
project_dir = resolve_v2_paths(workspace_root).project_dir
|
|
235
|
+
if previous_task_id is not None:
|
|
236
|
+
_append_event(
|
|
237
|
+
project_dir,
|
|
238
|
+
previous_task_id,
|
|
239
|
+
"task.deactivated",
|
|
240
|
+
{"reason": reason, "next_task_id": task.id, "forced": force},
|
|
241
|
+
)
|
|
242
|
+
_append_event(
|
|
243
|
+
project_dir,
|
|
244
|
+
task.id,
|
|
245
|
+
"task.activated",
|
|
246
|
+
{"reason": reason, "previous_task_id": previous_task_id, "forced": force},
|
|
247
|
+
)
|
|
248
|
+
return _active_task_payload(
|
|
249
|
+
workspace_root,
|
|
250
|
+
task,
|
|
251
|
+
state=state,
|
|
252
|
+
changed=True,
|
|
253
|
+
previous_task_id=previous_task_id,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def deactivate_task(
|
|
258
|
+
workspace_root: Path,
|
|
259
|
+
*,
|
|
260
|
+
reason: str,
|
|
261
|
+
actor_type: str = "user",
|
|
262
|
+
force: bool = False,
|
|
263
|
+
) -> dict[str, object]:
|
|
264
|
+
return clear_active_task(
|
|
265
|
+
workspace_root,
|
|
266
|
+
reason=reason,
|
|
267
|
+
actor_type=actor_type,
|
|
268
|
+
force=force,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def clear_active_task(
|
|
273
|
+
workspace_root: Path,
|
|
274
|
+
*,
|
|
275
|
+
reason: str,
|
|
276
|
+
actor_type: str = "user",
|
|
277
|
+
force: bool = False,
|
|
278
|
+
) -> dict[str, object]:
|
|
279
|
+
from taskledger.storage.task_store import clear_active_task_state
|
|
280
|
+
|
|
281
|
+
state = load_active_task_state(workspace_root)
|
|
282
|
+
if state is None:
|
|
283
|
+
raise NoActiveTask()
|
|
284
|
+
_ensure_active_switch_allowed(
|
|
285
|
+
workspace_root,
|
|
286
|
+
state.task_id,
|
|
287
|
+
force=force,
|
|
288
|
+
reason=reason,
|
|
289
|
+
)
|
|
290
|
+
task = storage_resolve_active_task(workspace_root)
|
|
291
|
+
clear_active_task_state(workspace_root)
|
|
292
|
+
_append_event(
|
|
293
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
294
|
+
task.id,
|
|
295
|
+
"task.deactivated",
|
|
296
|
+
{"reason": reason, "forced": force, "actor_type": actor_type},
|
|
297
|
+
)
|
|
298
|
+
return _active_task_payload(
|
|
299
|
+
workspace_root,
|
|
300
|
+
task,
|
|
301
|
+
state=state,
|
|
302
|
+
changed=True,
|
|
303
|
+
previous_task_id=state.previous_task_id,
|
|
304
|
+
active=False,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def show_task(workspace_root: Path, ref: str) -> dict[str, object]:
|
|
309
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, ref))
|
|
310
|
+
lock = read_lock(task_lock_path(resolve_v2_paths(workspace_root), task.id))
|
|
311
|
+
plans = list_plans(workspace_root, task.id)
|
|
312
|
+
questions = list_questions(workspace_root, task.id)
|
|
313
|
+
runs = list_runs(workspace_root, task.id)
|
|
314
|
+
changes = list_changes(workspace_root, task.id)
|
|
315
|
+
active_stage = _task_active_stage(
|
|
316
|
+
workspace_root,
|
|
317
|
+
task,
|
|
318
|
+
lock=lock,
|
|
319
|
+
runs=runs,
|
|
320
|
+
)
|
|
321
|
+
return {
|
|
322
|
+
"kind": "task",
|
|
323
|
+
"task": _task_payload(task, active_stage=active_stage),
|
|
324
|
+
"lock": lock.to_dict() if lock is not None else None,
|
|
325
|
+
"plans": [plan.to_dict() for plan in plans],
|
|
326
|
+
"questions": [question.to_dict() for question in questions],
|
|
327
|
+
"runs": [run.to_dict() for run in runs],
|
|
328
|
+
"changes": [change.to_dict() for change in changes],
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def edit_task(
|
|
333
|
+
workspace_root: Path,
|
|
334
|
+
ref: str,
|
|
335
|
+
*,
|
|
336
|
+
title: str | None = None,
|
|
337
|
+
description: str | None = None,
|
|
338
|
+
priority: str | None = None,
|
|
339
|
+
owner: str | None = None,
|
|
340
|
+
add_labels: tuple[str, ...] = (),
|
|
341
|
+
remove_labels: tuple[str, ...] = (),
|
|
342
|
+
add_notes: tuple[str, ...] = (),
|
|
343
|
+
) -> TaskRecord:
|
|
344
|
+
task = resolve_task(workspace_root, ref)
|
|
345
|
+
_enforce_decision(
|
|
346
|
+
metadata_edit_decision(task, _current_lock(workspace_root, task.id))
|
|
347
|
+
)
|
|
348
|
+
labels = [item for item in task.labels if item not in set(remove_labels)]
|
|
349
|
+
for label in add_labels:
|
|
350
|
+
if label not in labels:
|
|
351
|
+
labels.append(label)
|
|
352
|
+
notes = tuple([*task.notes, *[note for note in add_notes if note]])
|
|
353
|
+
updated = replace(
|
|
354
|
+
task,
|
|
355
|
+
title=title or task.title,
|
|
356
|
+
body=description.strip() if description is not None else task.body,
|
|
357
|
+
description_summary=(
|
|
358
|
+
_summary_line(description)
|
|
359
|
+
if description is not None
|
|
360
|
+
else task.description_summary
|
|
361
|
+
),
|
|
362
|
+
priority=priority or task.priority,
|
|
363
|
+
owner=owner or task.owner,
|
|
364
|
+
labels=tuple(labels),
|
|
365
|
+
notes=notes,
|
|
366
|
+
updated_at=utc_now_iso(),
|
|
367
|
+
)
|
|
368
|
+
save_task(workspace_root, updated)
|
|
369
|
+
_append_event(
|
|
370
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
371
|
+
updated.id,
|
|
372
|
+
"task.updated",
|
|
373
|
+
{"title": updated.title},
|
|
374
|
+
)
|
|
375
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
376
|
+
return updated
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def cancel_task(
|
|
380
|
+
workspace_root: Path,
|
|
381
|
+
ref: str,
|
|
382
|
+
*,
|
|
383
|
+
reason: str | None = None,
|
|
384
|
+
) -> dict[str, object]:
|
|
385
|
+
task = resolve_task(workspace_root, ref)
|
|
386
|
+
require_transition(task.status_stage, "cancelled")
|
|
387
|
+
lock_path = task_lock_path(resolve_v2_paths(workspace_root), task.id)
|
|
388
|
+
lock = read_lock(lock_path)
|
|
389
|
+
if lock is not None:
|
|
390
|
+
_release_lock(
|
|
391
|
+
workspace_root,
|
|
392
|
+
task=task,
|
|
393
|
+
expected_stage=lock.stage,
|
|
394
|
+
run_id=lock.run_id,
|
|
395
|
+
target_stage="cancelled",
|
|
396
|
+
event_name="stage.failed",
|
|
397
|
+
extra_data={"reason": reason or "cancelled"},
|
|
398
|
+
)
|
|
399
|
+
task = resolve_task(workspace_root, ref)
|
|
400
|
+
updated = replace(task, status_stage="cancelled", updated_at=utc_now_iso())
|
|
401
|
+
save_task(workspace_root, updated)
|
|
402
|
+
_append_event(
|
|
403
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
404
|
+
updated.id,
|
|
405
|
+
"task.cancelled",
|
|
406
|
+
{"reason": reason},
|
|
407
|
+
)
|
|
408
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
409
|
+
return _lifecycle_payload(
|
|
410
|
+
"task cancel",
|
|
411
|
+
updated,
|
|
412
|
+
warnings=[],
|
|
413
|
+
changed=True,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def close_task(workspace_root: Path, ref: str) -> dict[str, object]:
|
|
418
|
+
task = resolve_task(workspace_root, ref)
|
|
419
|
+
if task.status_stage != "done":
|
|
420
|
+
raise _cli_error(
|
|
421
|
+
"Only done tasks can be closed via task close.",
|
|
422
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
423
|
+
)
|
|
424
|
+
return _lifecycle_payload("task close", task, warnings=[], changed=False)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def create_introduction(
|
|
428
|
+
workspace_root: Path,
|
|
429
|
+
*,
|
|
430
|
+
title: str,
|
|
431
|
+
body: str,
|
|
432
|
+
slug: str | None = None,
|
|
433
|
+
labels: tuple[str, ...] = (),
|
|
434
|
+
) -> IntroductionRecord:
|
|
435
|
+
intros = list_introductions(workspace_root)
|
|
436
|
+
intro = IntroductionRecord(
|
|
437
|
+
id=next_project_id("intro", [item.id for item in intros]),
|
|
438
|
+
slug=_unique_slug(intros, slug or title),
|
|
439
|
+
title=title,
|
|
440
|
+
body=body.strip(),
|
|
441
|
+
labels=tuple(dict.fromkeys(labels)),
|
|
442
|
+
)
|
|
443
|
+
save_introduction(workspace_root, intro)
|
|
444
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
445
|
+
return intro
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def link_introduction(
|
|
449
|
+
workspace_root: Path, task_ref: str, introduction_ref: str
|
|
450
|
+
) -> TaskRecord:
|
|
451
|
+
task = resolve_task(workspace_root, task_ref)
|
|
452
|
+
intro = resolve_introduction(workspace_root, introduction_ref)
|
|
453
|
+
updated = replace(
|
|
454
|
+
task,
|
|
455
|
+
introduction_ref=intro.id,
|
|
456
|
+
updated_at=utc_now_iso(),
|
|
457
|
+
)
|
|
458
|
+
save_task(workspace_root, updated)
|
|
459
|
+
_append_event(
|
|
460
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
461
|
+
updated.id,
|
|
462
|
+
"task.updated",
|
|
463
|
+
{"introduction_ref": intro.id},
|
|
464
|
+
)
|
|
465
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
466
|
+
return updated
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def add_requirement(
|
|
470
|
+
workspace_root: Path, task_ref: str, required_task_ref: str
|
|
471
|
+
) -> TaskRecord:
|
|
472
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
473
|
+
required = resolve_task(workspace_root, required_task_ref)
|
|
474
|
+
requirements = list(task.requirements)
|
|
475
|
+
if required.id not in requirements:
|
|
476
|
+
requirements.append(required.id)
|
|
477
|
+
updated = replace(
|
|
478
|
+
task,
|
|
479
|
+
requirements=tuple(requirements),
|
|
480
|
+
updated_at=utc_now_iso(),
|
|
481
|
+
)
|
|
482
|
+
save_requirements(
|
|
483
|
+
workspace_root,
|
|
484
|
+
RequirementCollection(
|
|
485
|
+
task_id=updated.id,
|
|
486
|
+
requirements=tuple(
|
|
487
|
+
DependencyRequirement(task_id=item) for item in requirements
|
|
488
|
+
),
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
save_task(workspace_root, updated)
|
|
492
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
493
|
+
return updated
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def remove_requirement(
|
|
497
|
+
workspace_root: Path, task_ref: str, required_task_ref: str
|
|
498
|
+
) -> TaskRecord:
|
|
499
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
500
|
+
required = resolve_task(workspace_root, required_task_ref)
|
|
501
|
+
remaining = tuple(item for item in task.requirements if item != required.id)
|
|
502
|
+
updated = replace(
|
|
503
|
+
task,
|
|
504
|
+
requirements=remaining,
|
|
505
|
+
updated_at=utc_now_iso(),
|
|
506
|
+
)
|
|
507
|
+
save_requirements(
|
|
508
|
+
workspace_root,
|
|
509
|
+
RequirementCollection(
|
|
510
|
+
task_id=updated.id,
|
|
511
|
+
requirements=tuple(
|
|
512
|
+
DependencyRequirement(task_id=item) for item in remaining
|
|
513
|
+
),
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
save_task(workspace_root, updated)
|
|
517
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
518
|
+
return updated
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def waive_requirement(
|
|
522
|
+
workspace_root: Path,
|
|
523
|
+
task_ref: str,
|
|
524
|
+
required_task_ref: str,
|
|
525
|
+
*,
|
|
526
|
+
actor_type: str,
|
|
527
|
+
reason: str,
|
|
528
|
+
) -> TaskRecord:
|
|
529
|
+
if actor_type != "user":
|
|
530
|
+
raise _cli_error(
|
|
531
|
+
"Only user dependency waivers can unblock implementation.",
|
|
532
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
533
|
+
)
|
|
534
|
+
if not reason.strip():
|
|
535
|
+
raise _cli_error("Dependency waiver requires --reason.", EXIT_CODE_BAD_INPUT)
|
|
536
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
537
|
+
required = resolve_task(workspace_root, required_task_ref)
|
|
538
|
+
sidecar = load_requirements(workspace_root, task.id)
|
|
539
|
+
requirements = list(sidecar.requirements)
|
|
540
|
+
for index, item in enumerate(requirements):
|
|
541
|
+
if item.task_id == required.id:
|
|
542
|
+
requirements[index] = replace(
|
|
543
|
+
item,
|
|
544
|
+
waiver=DependencyWaiver(
|
|
545
|
+
actor=ActorRef(
|
|
546
|
+
actor_type="user",
|
|
547
|
+
actor_name=getpass.getuser() or "user",
|
|
548
|
+
tool="manual",
|
|
549
|
+
),
|
|
550
|
+
reason=reason.strip(),
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
break
|
|
554
|
+
else:
|
|
555
|
+
requirements.append(
|
|
556
|
+
DependencyRequirement(
|
|
557
|
+
task_id=required.id,
|
|
558
|
+
waiver=DependencyWaiver(
|
|
559
|
+
actor=ActorRef(
|
|
560
|
+
actor_type="user",
|
|
561
|
+
actor_name=getpass.getuser() or "user",
|
|
562
|
+
tool="manual",
|
|
563
|
+
),
|
|
564
|
+
reason=reason.strip(),
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
save_requirements(
|
|
569
|
+
workspace_root,
|
|
570
|
+
RequirementCollection(task_id=task.id, requirements=tuple(requirements)),
|
|
571
|
+
)
|
|
572
|
+
updated = replace(
|
|
573
|
+
task,
|
|
574
|
+
requirements=tuple(item.task_id for item in requirements),
|
|
575
|
+
updated_at=utc_now_iso(),
|
|
576
|
+
)
|
|
577
|
+
save_task(workspace_root, updated)
|
|
578
|
+
_append_event(
|
|
579
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
580
|
+
updated.id,
|
|
581
|
+
"requirement.waived",
|
|
582
|
+
{"required_task_id": required.id, "reason": reason.strip()},
|
|
583
|
+
)
|
|
584
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
585
|
+
return updated
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def add_file_link(
|
|
589
|
+
workspace_root: Path,
|
|
590
|
+
task_ref: str,
|
|
591
|
+
*,
|
|
592
|
+
path: str,
|
|
593
|
+
kind: str,
|
|
594
|
+
label: str | None = None,
|
|
595
|
+
required_for_validation: bool = False,
|
|
596
|
+
) -> TaskRecord:
|
|
597
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
598
|
+
links = list(task.file_links)
|
|
599
|
+
existing = next((item for item in links if item.path == path), None)
|
|
600
|
+
new_link = FileLink(
|
|
601
|
+
path=path,
|
|
602
|
+
kind=normalize_file_link_kind(kind),
|
|
603
|
+
label=label,
|
|
604
|
+
required_for_validation=required_for_validation,
|
|
605
|
+
)
|
|
606
|
+
if existing is not None:
|
|
607
|
+
links = [item for item in links if item.path != path]
|
|
608
|
+
links.append(new_link)
|
|
609
|
+
updated = replace(
|
|
610
|
+
task,
|
|
611
|
+
file_links=tuple(links),
|
|
612
|
+
updated_at=utc_now_iso(),
|
|
613
|
+
)
|
|
614
|
+
save_links(
|
|
615
|
+
workspace_root, LinkCollection(task_id=updated.id, links=updated.file_links)
|
|
616
|
+
)
|
|
617
|
+
save_task(workspace_root, updated)
|
|
618
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
619
|
+
return updated
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def remove_file_link(workspace_root: Path, task_ref: str, *, path: str) -> TaskRecord:
|
|
623
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
624
|
+
remaining = tuple(item for item in task.file_links if item.path != path)
|
|
625
|
+
updated = replace(
|
|
626
|
+
task,
|
|
627
|
+
file_links=remaining,
|
|
628
|
+
updated_at=utc_now_iso(),
|
|
629
|
+
)
|
|
630
|
+
save_links(workspace_root, LinkCollection(task_id=updated.id, links=remaining))
|
|
631
|
+
save_task(workspace_root, updated)
|
|
632
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
633
|
+
return updated
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def list_file_links(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
637
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
638
|
+
return {
|
|
639
|
+
"kind": "task_file_links",
|
|
640
|
+
"task_id": task.id,
|
|
641
|
+
"file_links": [item.to_dict() for item in task.file_links],
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def add_todo(
|
|
646
|
+
workspace_root: Path,
|
|
647
|
+
task_ref: str,
|
|
648
|
+
*,
|
|
649
|
+
text: str,
|
|
650
|
+
source: str | None = None,
|
|
651
|
+
mandatory: bool = False,
|
|
652
|
+
) -> TaskRecord:
|
|
653
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
654
|
+
lock = _lock_for_mutation(workspace_root, task.id)
|
|
655
|
+
# Infer source from active lock unless explicitly provided
|
|
656
|
+
if source is not None:
|
|
657
|
+
resolved_source = source
|
|
658
|
+
elif lock is not None and lock.stage == "planning":
|
|
659
|
+
resolved_source = "planner"
|
|
660
|
+
elif lock is not None and lock.stage == "implementing":
|
|
661
|
+
resolved_source = "implementer"
|
|
662
|
+
else:
|
|
663
|
+
resolved_source = "user"
|
|
664
|
+
actor_role = require_known_actor_role(resolved_source)
|
|
665
|
+
_enforce_decision(
|
|
666
|
+
todo_add_decision(
|
|
667
|
+
task,
|
|
668
|
+
lock,
|
|
669
|
+
actor_role=actor_role,
|
|
670
|
+
)
|
|
671
|
+
)
|
|
672
|
+
todo = TaskTodo(
|
|
673
|
+
id=next_project_id("todo", [item.id for item in task.todos]),
|
|
674
|
+
text=text.strip(),
|
|
675
|
+
source=resolved_source,
|
|
676
|
+
mandatory=mandatory,
|
|
677
|
+
active_at=utc_now_iso()
|
|
678
|
+
if lock is not None and lock.stage == "implementing"
|
|
679
|
+
else None,
|
|
680
|
+
)
|
|
681
|
+
updated = replace(
|
|
682
|
+
task,
|
|
683
|
+
todos=tuple([*task.todos, todo]),
|
|
684
|
+
updated_at=utc_now_iso(),
|
|
685
|
+
)
|
|
686
|
+
save_todos(workspace_root, TodoCollection(task_id=updated.id, todos=updated.todos))
|
|
687
|
+
save_task(workspace_root, updated)
|
|
688
|
+
_append_event(
|
|
689
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
690
|
+
updated.id,
|
|
691
|
+
"todo.added",
|
|
692
|
+
{"todo_id": todo.id, "text": todo.text},
|
|
693
|
+
)
|
|
694
|
+
return updated
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def set_todo_done(
|
|
698
|
+
workspace_root: Path,
|
|
699
|
+
task_ref: str,
|
|
700
|
+
todo_id: str,
|
|
701
|
+
*,
|
|
702
|
+
done: bool,
|
|
703
|
+
evidence: str | None = None,
|
|
704
|
+
artifacts: tuple[str, ...] = (),
|
|
705
|
+
changes: tuple[str, ...] = (),
|
|
706
|
+
actor: ActorRef | None = None,
|
|
707
|
+
harness: HarnessRef | None = None,
|
|
708
|
+
) -> TaskRecord:
|
|
709
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
710
|
+
normalized_todo_id = _normalize_local_id(todo_id, "todo")
|
|
711
|
+
_enforce_decision(
|
|
712
|
+
todo_toggle_decision(
|
|
713
|
+
task,
|
|
714
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
715
|
+
actor_role="user",
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
now = utc_now_iso()
|
|
719
|
+
resolved_actor = actor or _default_actor()
|
|
720
|
+
todos = [
|
|
721
|
+
replace(
|
|
722
|
+
todo,
|
|
723
|
+
done=done,
|
|
724
|
+
status="done" if done else "open",
|
|
725
|
+
updated_at=now,
|
|
726
|
+
done_at=now if done else None,
|
|
727
|
+
completed_by=resolved_actor if done else None,
|
|
728
|
+
completed_in_harness=harness if done else None,
|
|
729
|
+
evidence=(
|
|
730
|
+
tuple([*todo.evidence, evidence.strip()])
|
|
731
|
+
if done and evidence and evidence.strip()
|
|
732
|
+
else todo.evidence
|
|
733
|
+
),
|
|
734
|
+
artifact_refs=tuple([*todo.artifact_refs, *artifacts])
|
|
735
|
+
if done
|
|
736
|
+
else todo.artifact_refs,
|
|
737
|
+
change_refs=tuple([*todo.change_refs, *changes])
|
|
738
|
+
if done
|
|
739
|
+
else todo.change_refs,
|
|
740
|
+
)
|
|
741
|
+
if todo.id in {todo_id, normalized_todo_id}
|
|
742
|
+
else todo
|
|
743
|
+
for todo in task.todos
|
|
744
|
+
]
|
|
745
|
+
if not any(todo.id in {todo_id, normalized_todo_id} for todo in task.todos):
|
|
746
|
+
raise _cli_error(f"Todo not found: {todo_id}", EXIT_CODE_MISSING)
|
|
747
|
+
updated = replace(task, todos=tuple(todos), updated_at=now)
|
|
748
|
+
save_todos(workspace_root, TodoCollection(task_id=updated.id, todos=updated.todos))
|
|
749
|
+
save_task(workspace_root, updated)
|
|
750
|
+
_append_event(
|
|
751
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
752
|
+
updated.id,
|
|
753
|
+
"todo.completed" if done else "todo.toggled",
|
|
754
|
+
{
|
|
755
|
+
"todo_id": todo_id,
|
|
756
|
+
"done": done,
|
|
757
|
+
"evidence": evidence,
|
|
758
|
+
"artifacts": list(artifacts),
|
|
759
|
+
"changes": list(changes),
|
|
760
|
+
},
|
|
761
|
+
)
|
|
762
|
+
return updated
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def show_todo(workspace_root: Path, task_ref: str, todo_id: str) -> dict[str, object]:
|
|
766
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
767
|
+
normalized_todo_id = _normalize_local_id(todo_id, "todo")
|
|
768
|
+
for todo in task.todos:
|
|
769
|
+
if todo.id == todo_id or todo.id == normalized_todo_id:
|
|
770
|
+
return {"kind": "task_todo", "task_id": task.id, "todo": todo.to_dict()}
|
|
771
|
+
raise _cli_error(f"Todo not found: {todo_id}", EXIT_CODE_MISSING)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def start_planning(
|
|
775
|
+
workspace_root: Path,
|
|
776
|
+
task_ref: str,
|
|
777
|
+
*,
|
|
778
|
+
actor: ActorRef | None = None,
|
|
779
|
+
harness: HarnessRef | None = None,
|
|
780
|
+
) -> dict[str, object]:
|
|
781
|
+
task = resolve_task(workspace_root, task_ref)
|
|
782
|
+
if task.status_stage not in {"draft", "plan_review"}:
|
|
783
|
+
raise _cli_error(
|
|
784
|
+
"Planning can only start from draft or plan_review.",
|
|
785
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
786
|
+
)
|
|
787
|
+
run = _start_run(
|
|
788
|
+
workspace_root,
|
|
789
|
+
task,
|
|
790
|
+
run_type="planning",
|
|
791
|
+
stage="planning",
|
|
792
|
+
actor=actor,
|
|
793
|
+
harness=harness,
|
|
794
|
+
)
|
|
795
|
+
updated = replace(
|
|
796
|
+
resolve_task(workspace_root, task.id),
|
|
797
|
+
latest_planning_run=run.run_id,
|
|
798
|
+
updated_at=utc_now_iso(),
|
|
799
|
+
)
|
|
800
|
+
save_task(workspace_root, updated)
|
|
801
|
+
_append_event(
|
|
802
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
803
|
+
updated.id,
|
|
804
|
+
"plan.started",
|
|
805
|
+
{"run_id": run.run_id},
|
|
806
|
+
)
|
|
807
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
808
|
+
return _lifecycle_payload(
|
|
809
|
+
"plan start",
|
|
810
|
+
updated,
|
|
811
|
+
warnings=[],
|
|
812
|
+
changed=True,
|
|
813
|
+
run=run,
|
|
814
|
+
lock=_require_lock(workspace_root, updated.id),
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def propose_plan(
|
|
819
|
+
workspace_root: Path,
|
|
820
|
+
task_ref: str,
|
|
821
|
+
*,
|
|
822
|
+
body: str,
|
|
823
|
+
criteria: tuple[str, ...] = (),
|
|
824
|
+
) -> dict[str, object]:
|
|
825
|
+
task = resolve_task(workspace_root, task_ref)
|
|
826
|
+
run = _require_run(workspace_root, task, task.latest_planning_run)
|
|
827
|
+
lock = _lock_for_mutation(workspace_root, task.id)
|
|
828
|
+
_enforce_decision(plan_propose_decision(task, lock, run=run))
|
|
829
|
+
plans = list_plans(workspace_root, task.id)
|
|
830
|
+
version = plans[-1].plan_version + 1 if plans else 1
|
|
831
|
+
front_matter, plan_body = _parse_plan_front_matter(body)
|
|
832
|
+
questions = list_questions(workspace_root, task.id)
|
|
833
|
+
plan = PlanRecord(
|
|
834
|
+
task_id=task.id,
|
|
835
|
+
plan_version=version,
|
|
836
|
+
body=plan_body.strip(),
|
|
837
|
+
status="proposed",
|
|
838
|
+
created_by=_default_actor(),
|
|
839
|
+
supersedes=plans[-1].plan_version if plans else None,
|
|
840
|
+
question_refs=tuple(item.id for item in questions if item.status == "open"),
|
|
841
|
+
criteria=_criteria_from_plan_input(front_matter, criteria),
|
|
842
|
+
todos=_todos_from_plan_input(front_matter),
|
|
843
|
+
generation_reason=_optional_front_matter_string(
|
|
844
|
+
front_matter, "generation_reason"
|
|
845
|
+
)
|
|
846
|
+
or "initial",
|
|
847
|
+
based_on_question_ids=tuple(
|
|
848
|
+
item.id for item in questions if item.status == "answered"
|
|
849
|
+
),
|
|
850
|
+
based_on_answer_hash=_answer_snapshot_hash(questions),
|
|
851
|
+
goal=_optional_front_matter_string(front_matter, "goal"),
|
|
852
|
+
files=_string_tuple_from_front_matter(front_matter, "files"),
|
|
853
|
+
test_commands=_string_tuple_from_front_matter(front_matter, "test_commands"),
|
|
854
|
+
expected_outputs=_string_tuple_from_front_matter(
|
|
855
|
+
front_matter, "expected_outputs"
|
|
856
|
+
),
|
|
857
|
+
todos_waived_reason=(
|
|
858
|
+
_optional_front_matter_string(front_matter, "todos_waived_reason")
|
|
859
|
+
or _optional_front_matter_string(front_matter, "todo_waiver_reason")
|
|
860
|
+
or _optional_front_matter_string(front_matter, "no_todos_reason")
|
|
861
|
+
),
|
|
862
|
+
)
|
|
863
|
+
save_plan(workspace_root, plan)
|
|
864
|
+
finished_run = replace(
|
|
865
|
+
run,
|
|
866
|
+
status="finished",
|
|
867
|
+
finished_at=utc_now_iso(),
|
|
868
|
+
summary=_summary_line(plan_body),
|
|
869
|
+
)
|
|
870
|
+
save_run(workspace_root, finished_run)
|
|
871
|
+
updated = replace(
|
|
872
|
+
task,
|
|
873
|
+
latest_plan_version=version,
|
|
874
|
+
status_stage="plan_review",
|
|
875
|
+
updated_at=utc_now_iso(),
|
|
876
|
+
)
|
|
877
|
+
save_task(workspace_root, updated)
|
|
878
|
+
_release_lock(
|
|
879
|
+
workspace_root,
|
|
880
|
+
task=updated,
|
|
881
|
+
expected_stage="planning",
|
|
882
|
+
run_id=run.run_id,
|
|
883
|
+
target_stage="plan_review",
|
|
884
|
+
event_name="stage.completed",
|
|
885
|
+
extra_data={"plan_version": version},
|
|
886
|
+
delete_only=True,
|
|
887
|
+
)
|
|
888
|
+
_append_event(
|
|
889
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
890
|
+
updated.id,
|
|
891
|
+
"plan.proposed",
|
|
892
|
+
{"plan_version": version},
|
|
893
|
+
)
|
|
894
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
895
|
+
return _lifecycle_payload(
|
|
896
|
+
"plan propose",
|
|
897
|
+
updated,
|
|
898
|
+
warnings=[],
|
|
899
|
+
changed=True,
|
|
900
|
+
plan_version=version,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def upsert_plan(
|
|
905
|
+
workspace_root: Path,
|
|
906
|
+
task_ref: str,
|
|
907
|
+
*,
|
|
908
|
+
body: str,
|
|
909
|
+
criteria: tuple[str, ...] = (),
|
|
910
|
+
from_answers: bool = False,
|
|
911
|
+
allow_open_questions: bool = False,
|
|
912
|
+
) -> dict[str, object]:
|
|
913
|
+
task = resolve_task(workspace_root, task_ref)
|
|
914
|
+
questions = list_questions(workspace_root, task.id)
|
|
915
|
+
open_required = _required_open_question_ids(questions)
|
|
916
|
+
if open_required and not allow_open_questions:
|
|
917
|
+
raise _cli_error(
|
|
918
|
+
"Plan upsert is blocked by required open questions: "
|
|
919
|
+
+ ", ".join(open_required),
|
|
920
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
921
|
+
)
|
|
922
|
+
latest_plan = _latest_plan_or_none(workspace_root, task.id)
|
|
923
|
+
stale_answers = (
|
|
924
|
+
_stale_answer_question_ids(questions, latest_plan)
|
|
925
|
+
if latest_plan is not None
|
|
926
|
+
else [
|
|
927
|
+
item.id
|
|
928
|
+
for item in questions
|
|
929
|
+
if item.status == "answered" and item.required_for_plan
|
|
930
|
+
]
|
|
931
|
+
)
|
|
932
|
+
if from_answers or stale_answers:
|
|
933
|
+
payload = regenerate_plan_from_answers(
|
|
934
|
+
workspace_root,
|
|
935
|
+
task.id,
|
|
936
|
+
body=body,
|
|
937
|
+
criteria=criteria,
|
|
938
|
+
allow_open_questions=allow_open_questions,
|
|
939
|
+
)
|
|
940
|
+
payload["operation"] = "regenerated"
|
|
941
|
+
payload["command"] = "plan upsert"
|
|
942
|
+
return payload
|
|
943
|
+
payload = propose_plan(workspace_root, task.id, body=body, criteria=criteria)
|
|
944
|
+
payload["operation"] = "proposed"
|
|
945
|
+
payload["command"] = "plan upsert"
|
|
946
|
+
return payload
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def show_plan(
|
|
950
|
+
workspace_root: Path, task_ref: str, *, version: int | None = None
|
|
951
|
+
) -> dict[str, object]:
|
|
952
|
+
task = resolve_task(workspace_root, task_ref)
|
|
953
|
+
plan = resolve_plan(
|
|
954
|
+
workspace_root,
|
|
955
|
+
task.id,
|
|
956
|
+
version=version,
|
|
957
|
+
)
|
|
958
|
+
return {
|
|
959
|
+
"kind": "plan",
|
|
960
|
+
"task_id": task.id,
|
|
961
|
+
"plan": plan.to_dict(),
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def list_plan_versions(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
966
|
+
task = resolve_task(workspace_root, task_ref)
|
|
967
|
+
plans = list_plans(workspace_root, task.id)
|
|
968
|
+
return {
|
|
969
|
+
"kind": "plan_list",
|
|
970
|
+
"task_id": task.id,
|
|
971
|
+
"plans": [plan.to_dict() for plan in plans],
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def diff_plan(
|
|
976
|
+
workspace_root: Path, task_ref: str, *, from_version: int, to_version: int
|
|
977
|
+
) -> dict[str, object]:
|
|
978
|
+
task = resolve_task(workspace_root, task_ref)
|
|
979
|
+
earlier = resolve_plan(workspace_root, task.id, version=from_version)
|
|
980
|
+
later = resolve_plan(workspace_root, task.id, version=to_version)
|
|
981
|
+
diff = "\n".join(
|
|
982
|
+
difflib.unified_diff(
|
|
983
|
+
earlier.body.splitlines(),
|
|
984
|
+
later.body.splitlines(),
|
|
985
|
+
fromfile=f"plan-v{from_version}",
|
|
986
|
+
tofile=f"plan-v{to_version}",
|
|
987
|
+
lineterm="",
|
|
988
|
+
)
|
|
989
|
+
)
|
|
990
|
+
return {
|
|
991
|
+
"kind": "plan_diff",
|
|
992
|
+
"task_id": task.id,
|
|
993
|
+
"from_version": from_version,
|
|
994
|
+
"to_version": to_version,
|
|
995
|
+
"diff": diff,
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def approve_plan(
|
|
1000
|
+
workspace_root: Path,
|
|
1001
|
+
task_ref: str,
|
|
1002
|
+
*,
|
|
1003
|
+
version: int,
|
|
1004
|
+
actor_type: str = "user",
|
|
1005
|
+
actor_name: str | None = None,
|
|
1006
|
+
note: str | None = None,
|
|
1007
|
+
allow_agent_approval: bool = False,
|
|
1008
|
+
reason: str | None = None,
|
|
1009
|
+
allow_empty_criteria: bool = False,
|
|
1010
|
+
materialize_todos: bool = True,
|
|
1011
|
+
allow_open_questions: bool = False,
|
|
1012
|
+
allow_empty_todos: bool = False,
|
|
1013
|
+
allow_lint_errors: bool = False,
|
|
1014
|
+
) -> dict[str, object]:
|
|
1015
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1016
|
+
_enforce_decision(
|
|
1017
|
+
plan_approve_decision(task, _current_lock(workspace_root, task.id))
|
|
1018
|
+
)
|
|
1019
|
+
questions = list_questions(workspace_root, task.id)
|
|
1020
|
+
open_questions = _required_open_question_ids(questions)
|
|
1021
|
+
if open_questions and not allow_open_questions:
|
|
1022
|
+
raise _cli_error(
|
|
1023
|
+
"Plan approval is blocked by open planning questions: "
|
|
1024
|
+
+ ", ".join(open_questions),
|
|
1025
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1026
|
+
)
|
|
1027
|
+
if allow_open_questions and not (reason or "").strip():
|
|
1028
|
+
raise _cli_error(
|
|
1029
|
+
"--allow-open-questions requires --reason.", EXIT_CODE_BAD_INPUT
|
|
1030
|
+
)
|
|
1031
|
+
target = resolve_plan(workspace_root, task.id, version=version)
|
|
1032
|
+
if target.status != "proposed":
|
|
1033
|
+
raise _cli_error(
|
|
1034
|
+
"Only proposed plan versions can be approved. "
|
|
1035
|
+
f"v{target.plan_version} is {target.status}.",
|
|
1036
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1037
|
+
)
|
|
1038
|
+
stale_answer_ids = _stale_answer_question_ids(questions, target)
|
|
1039
|
+
if stale_answer_ids:
|
|
1040
|
+
error = _cli_error(
|
|
1041
|
+
"Plan approval is blocked by answered planning questions that are not "
|
|
1042
|
+
"reflected in this plan. Regenerate the plan from answers first: "
|
|
1043
|
+
+ ", ".join(stale_answer_ids),
|
|
1044
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1045
|
+
)
|
|
1046
|
+
error.taskledger_error_code = "APPROVAL_REQUIRED"
|
|
1047
|
+
raise error
|
|
1048
|
+
if not target.criteria and not allow_empty_criteria:
|
|
1049
|
+
raise _cli_error(
|
|
1050
|
+
"Plan approval requires at least one acceptance criterion.",
|
|
1051
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1052
|
+
)
|
|
1053
|
+
if allow_empty_criteria and not (reason or "").strip():
|
|
1054
|
+
raise _cli_error(
|
|
1055
|
+
"--allow-empty-criteria requires --reason.", EXIT_CODE_BAD_INPUT
|
|
1056
|
+
)
|
|
1057
|
+
if not target.todos and not allow_empty_todos:
|
|
1058
|
+
raise _cli_error(
|
|
1059
|
+
"Plan approval requires at least one todo. "
|
|
1060
|
+
'Use --allow-empty-todos --reason "..." for trivial tasks.',
|
|
1061
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1062
|
+
)
|
|
1063
|
+
if allow_empty_todos and not (reason or "").strip():
|
|
1064
|
+
raise _cli_error("--allow-empty-todos requires --reason.", EXIT_CODE_BAD_INPUT)
|
|
1065
|
+
if not materialize_todos and not (reason or "").strip():
|
|
1066
|
+
raise _cli_error(
|
|
1067
|
+
"--no-materialize-todos requires --reason.", EXIT_CODE_BAD_INPUT
|
|
1068
|
+
)
|
|
1069
|
+
approved_by = _approval_actor(
|
|
1070
|
+
actor_type=actor_type,
|
|
1071
|
+
actor_name=actor_name,
|
|
1072
|
+
note=note,
|
|
1073
|
+
allow_agent_approval=allow_agent_approval,
|
|
1074
|
+
reason=reason,
|
|
1075
|
+
)
|
|
1076
|
+
lint_payload = lint_plan(workspace_root, task.id, version=version, strict=False)
|
|
1077
|
+
if not lint_payload["passed"] and not allow_lint_errors:
|
|
1078
|
+
lint_error = _cli_error(
|
|
1079
|
+
"Plan approval is blocked by plan lint errors. "
|
|
1080
|
+
"Run `taskledger plan lint --version ...`.",
|
|
1081
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1082
|
+
)
|
|
1083
|
+
lint_error.taskledger_error_code = "APPROVAL_REQUIRED"
|
|
1084
|
+
lint_error.taskledger_data = {
|
|
1085
|
+
**lint_error.taskledger_data,
|
|
1086
|
+
"details": {"plan_lint": lint_payload},
|
|
1087
|
+
}
|
|
1088
|
+
raise lint_error
|
|
1089
|
+
if allow_lint_errors and not (reason or "").strip():
|
|
1090
|
+
raise _cli_error("--allow-lint-errors requires --reason.", EXIT_CODE_BAD_INPUT)
|
|
1091
|
+
approval_note = (note or reason or "").strip()
|
|
1092
|
+
for plan in list_plans(workspace_root, task.id):
|
|
1093
|
+
if plan.plan_version == target.plan_version:
|
|
1094
|
+
updated_plan = replace(
|
|
1095
|
+
plan,
|
|
1096
|
+
status="accepted",
|
|
1097
|
+
approved_at=utc_now_iso(),
|
|
1098
|
+
approved_by=approved_by,
|
|
1099
|
+
approval_note=approval_note,
|
|
1100
|
+
)
|
|
1101
|
+
elif plan.status == "rejected":
|
|
1102
|
+
updated_plan = plan
|
|
1103
|
+
else:
|
|
1104
|
+
updated_plan = replace(plan, status="superseded")
|
|
1105
|
+
overwrite_plan(workspace_root, updated_plan)
|
|
1106
|
+
updated = replace(
|
|
1107
|
+
task,
|
|
1108
|
+
accepted_plan_version=target.plan_version,
|
|
1109
|
+
status_stage="approved",
|
|
1110
|
+
updated_at=utc_now_iso(),
|
|
1111
|
+
)
|
|
1112
|
+
save_task(workspace_root, updated)
|
|
1113
|
+
materialized = 0
|
|
1114
|
+
if materialize_todos:
|
|
1115
|
+
materialized_result = materialize_plan_todos(
|
|
1116
|
+
workspace_root,
|
|
1117
|
+
updated.id,
|
|
1118
|
+
version=target.plan_version,
|
|
1119
|
+
)
|
|
1120
|
+
materialized = materialized_result["materialized_todos"]
|
|
1121
|
+
updated = resolve_task(workspace_root, updated.id)
|
|
1122
|
+
_append_event(
|
|
1123
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1124
|
+
updated.id,
|
|
1125
|
+
"plan.approved",
|
|
1126
|
+
{
|
|
1127
|
+
"plan_version": target.plan_version,
|
|
1128
|
+
"approved_by": approved_by.to_dict(),
|
|
1129
|
+
"approval_note": approval_note,
|
|
1130
|
+
},
|
|
1131
|
+
)
|
|
1132
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
1133
|
+
payload = _lifecycle_payload(
|
|
1134
|
+
"plan approve",
|
|
1135
|
+
updated,
|
|
1136
|
+
warnings=[],
|
|
1137
|
+
changed=True,
|
|
1138
|
+
plan_version=target.plan_version,
|
|
1139
|
+
result=f"materialized_todos={materialized}",
|
|
1140
|
+
)
|
|
1141
|
+
payload["materialized_todos"] = materialized
|
|
1142
|
+
payload["mandatory_todos"] = len(
|
|
1143
|
+
[
|
|
1144
|
+
todo
|
|
1145
|
+
for todo in load_todos(workspace_root, updated.id).todos
|
|
1146
|
+
if todo.mandatory
|
|
1147
|
+
]
|
|
1148
|
+
)
|
|
1149
|
+
payload["next_action"] = "taskledger implement start"
|
|
1150
|
+
return payload
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
class PlanTodoMaterializationPayload(TypedDict):
|
|
1154
|
+
kind: str
|
|
1155
|
+
task_id: str
|
|
1156
|
+
plan_id: str
|
|
1157
|
+
materialized_todos: int
|
|
1158
|
+
todos: list[dict[str, object]]
|
|
1159
|
+
dry_run: bool
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def materialize_plan_todos(
|
|
1163
|
+
workspace_root: Path,
|
|
1164
|
+
task_ref: str,
|
|
1165
|
+
*,
|
|
1166
|
+
version: int,
|
|
1167
|
+
dry_run: bool = False,
|
|
1168
|
+
) -> PlanTodoMaterializationPayload:
|
|
1169
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
1170
|
+
plan = resolve_plan(workspace_root, task.id, version=version)
|
|
1171
|
+
existing_keys = {
|
|
1172
|
+
(todo.source_plan_id, _normalize_todo_text(todo.text)) for todo in task.todos
|
|
1173
|
+
}
|
|
1174
|
+
new_todos: list[TaskTodo] = []
|
|
1175
|
+
next_ids = [todo.id for todo in task.todos]
|
|
1176
|
+
for plan_todo in plan.todos:
|
|
1177
|
+
key = (plan.plan_id, _normalize_todo_text(plan_todo.text))
|
|
1178
|
+
if key in existing_keys:
|
|
1179
|
+
continue
|
|
1180
|
+
todo_id = next_project_id("todo", [*next_ids, *(todo.id for todo in new_todos)])
|
|
1181
|
+
new_todos.append(
|
|
1182
|
+
replace(
|
|
1183
|
+
plan_todo,
|
|
1184
|
+
id=todo_id,
|
|
1185
|
+
source="plan",
|
|
1186
|
+
source_plan_id=plan.plan_id,
|
|
1187
|
+
mandatory=plan_todo.mandatory,
|
|
1188
|
+
status="open",
|
|
1189
|
+
done=False,
|
|
1190
|
+
created_at=utc_now_iso(),
|
|
1191
|
+
updated_at=utc_now_iso(),
|
|
1192
|
+
)
|
|
1193
|
+
)
|
|
1194
|
+
if new_todos and not dry_run:
|
|
1195
|
+
updated = replace(
|
|
1196
|
+
task,
|
|
1197
|
+
todos=tuple([*task.todos, *new_todos]),
|
|
1198
|
+
updated_at=utc_now_iso(),
|
|
1199
|
+
)
|
|
1200
|
+
save_todos(
|
|
1201
|
+
workspace_root, TodoCollection(task_id=updated.id, todos=updated.todos)
|
|
1202
|
+
)
|
|
1203
|
+
save_task(workspace_root, updated)
|
|
1204
|
+
_append_event(
|
|
1205
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1206
|
+
updated.id,
|
|
1207
|
+
"todo.added",
|
|
1208
|
+
{
|
|
1209
|
+
"source_plan_id": plan.plan_id,
|
|
1210
|
+
"todo_ids": [todo.id for todo in new_todos],
|
|
1211
|
+
},
|
|
1212
|
+
)
|
|
1213
|
+
return PlanTodoMaterializationPayload(
|
|
1214
|
+
kind="plan_todo_materialization",
|
|
1215
|
+
task_id=task.id,
|
|
1216
|
+
plan_id=plan.plan_id,
|
|
1217
|
+
materialized_todos=len(new_todos),
|
|
1218
|
+
todos=[todo.to_dict() for todo in new_todos],
|
|
1219
|
+
dry_run=dry_run,
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def regenerate_plan_from_answers(
|
|
1224
|
+
workspace_root: Path,
|
|
1225
|
+
task_ref: str,
|
|
1226
|
+
*,
|
|
1227
|
+
body: str,
|
|
1228
|
+
criteria: tuple[str, ...] = (),
|
|
1229
|
+
allow_open_questions: bool = False,
|
|
1230
|
+
) -> dict[str, object]:
|
|
1231
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1232
|
+
questions = list_questions(workspace_root, task.id)
|
|
1233
|
+
open_required = [
|
|
1234
|
+
item.id
|
|
1235
|
+
for item in questions
|
|
1236
|
+
if item.status == "open" and item.required_for_plan
|
|
1237
|
+
]
|
|
1238
|
+
if open_required and not allow_open_questions:
|
|
1239
|
+
raise _cli_error(
|
|
1240
|
+
"Plan regeneration is blocked by required open questions: "
|
|
1241
|
+
+ ", ".join(open_required),
|
|
1242
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1243
|
+
)
|
|
1244
|
+
answered = [
|
|
1245
|
+
item
|
|
1246
|
+
for item in questions
|
|
1247
|
+
if item.status == "answered" and item.required_for_plan
|
|
1248
|
+
]
|
|
1249
|
+
plans = list_plans(workspace_root, task.id)
|
|
1250
|
+
if not answered and not plans:
|
|
1251
|
+
raise _cli_error(
|
|
1252
|
+
"Plan regeneration requires answered questions or a previous plan.",
|
|
1253
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1254
|
+
)
|
|
1255
|
+
front_matter, plan_body = _parse_plan_front_matter(body)
|
|
1256
|
+
version = plans[-1].plan_version + 1 if plans else 1
|
|
1257
|
+
plan = PlanRecord(
|
|
1258
|
+
task_id=task.id,
|
|
1259
|
+
plan_version=version,
|
|
1260
|
+
body=plan_body.strip(),
|
|
1261
|
+
status="proposed",
|
|
1262
|
+
created_by=_default_actor(),
|
|
1263
|
+
supersedes=plans[-1].plan_version if plans else None,
|
|
1264
|
+
question_refs=tuple(open_required),
|
|
1265
|
+
criteria=_criteria_from_plan_input(front_matter, criteria),
|
|
1266
|
+
todos=_todos_from_plan_input(front_matter),
|
|
1267
|
+
generation_reason="after_questions",
|
|
1268
|
+
based_on_question_ids=tuple(item.id for item in answered),
|
|
1269
|
+
based_on_answer_hash=_answer_snapshot_hash(questions),
|
|
1270
|
+
goal=_optional_front_matter_string(front_matter, "goal"),
|
|
1271
|
+
files=_string_tuple_from_front_matter(front_matter, "files"),
|
|
1272
|
+
test_commands=_string_tuple_from_front_matter(front_matter, "test_commands"),
|
|
1273
|
+
expected_outputs=_string_tuple_from_front_matter(
|
|
1274
|
+
front_matter, "expected_outputs"
|
|
1275
|
+
),
|
|
1276
|
+
todos_waived_reason=(
|
|
1277
|
+
_optional_front_matter_string(front_matter, "todos_waived_reason")
|
|
1278
|
+
or _optional_front_matter_string(front_matter, "todo_waiver_reason")
|
|
1279
|
+
or _optional_front_matter_string(front_matter, "no_todos_reason")
|
|
1280
|
+
),
|
|
1281
|
+
)
|
|
1282
|
+
save_plan(workspace_root, plan)
|
|
1283
|
+
if plans:
|
|
1284
|
+
previous = plans[-1]
|
|
1285
|
+
if previous.status == "proposed":
|
|
1286
|
+
overwrite_plan(workspace_root, replace(previous, status="superseded"))
|
|
1287
|
+
run_to_finish: TaskRunRecord | None = None
|
|
1288
|
+
lock_to_release = _current_lock(workspace_root, task.id)
|
|
1289
|
+
if task.latest_planning_run is not None:
|
|
1290
|
+
candidate_run = _optional_run(workspace_root, task, task.latest_planning_run)
|
|
1291
|
+
if (
|
|
1292
|
+
candidate_run is not None
|
|
1293
|
+
and candidate_run.run_type == "planning"
|
|
1294
|
+
and candidate_run.status == "running"
|
|
1295
|
+
and lock_to_release is not None
|
|
1296
|
+
and lock_to_release.stage == "planning"
|
|
1297
|
+
and lock_to_release.run_id == candidate_run.run_id
|
|
1298
|
+
):
|
|
1299
|
+
run_to_finish = candidate_run
|
|
1300
|
+
save_run(
|
|
1301
|
+
workspace_root,
|
|
1302
|
+
replace(
|
|
1303
|
+
candidate_run,
|
|
1304
|
+
status="finished",
|
|
1305
|
+
finished_at=utc_now_iso(),
|
|
1306
|
+
summary=_summary_line(plan_body),
|
|
1307
|
+
),
|
|
1308
|
+
)
|
|
1309
|
+
updated = replace(
|
|
1310
|
+
task,
|
|
1311
|
+
latest_plan_version=version,
|
|
1312
|
+
status_stage="plan_review",
|
|
1313
|
+
updated_at=utc_now_iso(),
|
|
1314
|
+
)
|
|
1315
|
+
save_task(workspace_root, updated)
|
|
1316
|
+
if run_to_finish is not None:
|
|
1317
|
+
_release_lock(
|
|
1318
|
+
workspace_root,
|
|
1319
|
+
task=updated,
|
|
1320
|
+
expected_stage="planning",
|
|
1321
|
+
run_id=run_to_finish.run_id,
|
|
1322
|
+
target_stage="plan_review",
|
|
1323
|
+
event_name="stage.completed",
|
|
1324
|
+
extra_data={"plan_version": version},
|
|
1325
|
+
delete_only=True,
|
|
1326
|
+
)
|
|
1327
|
+
_append_event(
|
|
1328
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1329
|
+
updated.id,
|
|
1330
|
+
"plan.proposed",
|
|
1331
|
+
{"plan_version": version, "generation_reason": "after_questions"},
|
|
1332
|
+
)
|
|
1333
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
1334
|
+
return _lifecycle_payload(
|
|
1335
|
+
"plan regenerate",
|
|
1336
|
+
updated,
|
|
1337
|
+
warnings=[],
|
|
1338
|
+
changed=True,
|
|
1339
|
+
plan_version=version,
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def reject_plan(
|
|
1344
|
+
workspace_root: Path,
|
|
1345
|
+
task_ref: str,
|
|
1346
|
+
*,
|
|
1347
|
+
reason: str | None = None,
|
|
1348
|
+
) -> dict[str, object]:
|
|
1349
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1350
|
+
_enforce_decision(
|
|
1351
|
+
plan_approve_decision(task, _current_lock(workspace_root, task.id))
|
|
1352
|
+
)
|
|
1353
|
+
latest = resolve_plan(workspace_root, task.id)
|
|
1354
|
+
overwrite_plan(workspace_root, replace(latest, status="rejected"))
|
|
1355
|
+
updated = replace(task, status_stage="plan_review", updated_at=utc_now_iso())
|
|
1356
|
+
save_task(workspace_root, updated)
|
|
1357
|
+
_append_event(
|
|
1358
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1359
|
+
updated.id,
|
|
1360
|
+
"plan.rejected",
|
|
1361
|
+
{"plan_version": latest.plan_version, "reason": reason},
|
|
1362
|
+
)
|
|
1363
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
1364
|
+
return _lifecycle_payload(
|
|
1365
|
+
"plan reject",
|
|
1366
|
+
updated,
|
|
1367
|
+
warnings=[],
|
|
1368
|
+
changed=True,
|
|
1369
|
+
plan_version=latest.plan_version,
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def revise_plan(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
1374
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1375
|
+
_enforce_decision(
|
|
1376
|
+
plan_revise_decision(task, _current_lock(workspace_root, task.id))
|
|
1377
|
+
)
|
|
1378
|
+
return start_planning(workspace_root, task_ref)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def add_question(
|
|
1382
|
+
workspace_root: Path,
|
|
1383
|
+
task_ref: str,
|
|
1384
|
+
*,
|
|
1385
|
+
text: str,
|
|
1386
|
+
required_for_plan: bool = False,
|
|
1387
|
+
actor: ActorRef | None = None,
|
|
1388
|
+
harness: HarnessRef | None = None,
|
|
1389
|
+
) -> QuestionRecord:
|
|
1390
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1391
|
+
_enforce_decision(
|
|
1392
|
+
question_add_decision(
|
|
1393
|
+
task,
|
|
1394
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1395
|
+
actor_role="planner",
|
|
1396
|
+
)
|
|
1397
|
+
)
|
|
1398
|
+
question = QuestionRecord(
|
|
1399
|
+
id=next_project_id(
|
|
1400
|
+
"q",
|
|
1401
|
+
[item.id for item in list_questions(workspace_root, task.id)],
|
|
1402
|
+
),
|
|
1403
|
+
task_id=task.id,
|
|
1404
|
+
question=text.strip(),
|
|
1405
|
+
plan_version=task.latest_plan_version,
|
|
1406
|
+
required_for_plan=required_for_plan,
|
|
1407
|
+
asked_by_actor=actor or _default_actor(),
|
|
1408
|
+
asked_in_harness=harness or _default_harness(),
|
|
1409
|
+
)
|
|
1410
|
+
save_question(workspace_root, question)
|
|
1411
|
+
_append_event(
|
|
1412
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1413
|
+
task.id,
|
|
1414
|
+
"question.added",
|
|
1415
|
+
{"question_id": question.id, "required_for_plan": required_for_plan},
|
|
1416
|
+
)
|
|
1417
|
+
return question
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def answer_question(
|
|
1421
|
+
workspace_root: Path,
|
|
1422
|
+
task_ref: str,
|
|
1423
|
+
question_id: str,
|
|
1424
|
+
*,
|
|
1425
|
+
text: str,
|
|
1426
|
+
actor: ActorRef | None = None,
|
|
1427
|
+
answer_source: str = "user",
|
|
1428
|
+
) -> QuestionRecord:
|
|
1429
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1430
|
+
_enforce_decision(
|
|
1431
|
+
question_mutation_decision(
|
|
1432
|
+
task,
|
|
1433
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1434
|
+
actor_role="user",
|
|
1435
|
+
)
|
|
1436
|
+
)
|
|
1437
|
+
stripped = text.strip()
|
|
1438
|
+
if not stripped:
|
|
1439
|
+
raise _cli_error(
|
|
1440
|
+
"Answer text must not be empty.",
|
|
1441
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1442
|
+
)
|
|
1443
|
+
question = resolve_question(workspace_root, task.id, question_id)
|
|
1444
|
+
answered = replace(
|
|
1445
|
+
question,
|
|
1446
|
+
status="answered",
|
|
1447
|
+
answer=stripped,
|
|
1448
|
+
answered_at=utc_now_iso(),
|
|
1449
|
+
answered_by=(actor.actor_name if actor is not None else "user"),
|
|
1450
|
+
answered_by_actor=actor or ActorRef(actor_type="user", actor_name="user"),
|
|
1451
|
+
answer_source=answer_source,
|
|
1452
|
+
)
|
|
1453
|
+
save_question(workspace_root, answered)
|
|
1454
|
+
_append_event(
|
|
1455
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1456
|
+
task.id,
|
|
1457
|
+
"question.answered",
|
|
1458
|
+
{"question_id": answered.id},
|
|
1459
|
+
)
|
|
1460
|
+
return answered
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def answer_questions(
|
|
1464
|
+
workspace_root: Path,
|
|
1465
|
+
task_ref: str,
|
|
1466
|
+
answers: Mapping[str, str],
|
|
1467
|
+
*,
|
|
1468
|
+
actor: ActorRef | None = None,
|
|
1469
|
+
answer_source: str = "harness",
|
|
1470
|
+
) -> dict[str, object]:
|
|
1471
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1472
|
+
if not answers:
|
|
1473
|
+
raise _cli_error("At least one answer is required.", EXIT_CODE_BAD_INPUT)
|
|
1474
|
+
known = {item.id: item for item in list_questions(workspace_root, task.id)}
|
|
1475
|
+
unknown = [question_id for question_id in answers if question_id not in known]
|
|
1476
|
+
if unknown:
|
|
1477
|
+
raise _cli_error(
|
|
1478
|
+
"Unknown question ids: " + ", ".join(unknown),
|
|
1479
|
+
EXIT_CODE_MISSING,
|
|
1480
|
+
)
|
|
1481
|
+
empty = [question_id for question_id, text in answers.items() if not text.strip()]
|
|
1482
|
+
if empty:
|
|
1483
|
+
raise _cli_error(
|
|
1484
|
+
"Answer text must not be empty for: " + ", ".join(empty),
|
|
1485
|
+
EXIT_CODE_BAD_INPUT,
|
|
1486
|
+
)
|
|
1487
|
+
answered_ids: list[str] = []
|
|
1488
|
+
answered_questions: list[dict[str, object]] = []
|
|
1489
|
+
for question_id, text in answers.items():
|
|
1490
|
+
question = answer_question(
|
|
1491
|
+
workspace_root,
|
|
1492
|
+
task.id,
|
|
1493
|
+
question_id,
|
|
1494
|
+
text=text,
|
|
1495
|
+
actor=actor,
|
|
1496
|
+
answer_source=answer_source,
|
|
1497
|
+
)
|
|
1498
|
+
answered_ids.append(question.id)
|
|
1499
|
+
answered_questions.append(question.to_dict())
|
|
1500
|
+
status = question_status(workspace_root, task.id)
|
|
1501
|
+
return {
|
|
1502
|
+
"kind": "question_answer_many",
|
|
1503
|
+
"task_id": task.id,
|
|
1504
|
+
"answered_question_ids": answered_ids,
|
|
1505
|
+
"answered": answered_questions,
|
|
1506
|
+
"required_open": status["required_open"],
|
|
1507
|
+
"required_open_questions": status["required_open_questions"],
|
|
1508
|
+
"plan_regeneration_needed": status["plan_regeneration_needed"],
|
|
1509
|
+
"next_action": status["next_action"],
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def question_status(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
1514
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1515
|
+
questions = list_questions(workspace_root, task.id)
|
|
1516
|
+
required_open = _required_open_question_ids(questions)
|
|
1517
|
+
answered = [item for item in questions if item.status == "answered"]
|
|
1518
|
+
latest_plan = _latest_plan_or_none(workspace_root, task.id)
|
|
1519
|
+
answered_since_latest_plan = (
|
|
1520
|
+
_stale_answer_question_ids(questions, latest_plan)
|
|
1521
|
+
if latest_plan is not None
|
|
1522
|
+
else [item.id for item in answered]
|
|
1523
|
+
)
|
|
1524
|
+
regeneration_needed = bool(answered_since_latest_plan) and not required_open
|
|
1525
|
+
return {
|
|
1526
|
+
"kind": "question_status",
|
|
1527
|
+
"task_id": task.id,
|
|
1528
|
+
"required_open": len(required_open),
|
|
1529
|
+
"required_open_questions": required_open,
|
|
1530
|
+
"answered": len([item for item in questions if item.status == "answered"]),
|
|
1531
|
+
"answered_since_latest_plan": answered_since_latest_plan,
|
|
1532
|
+
"plan_regeneration_needed": regeneration_needed,
|
|
1533
|
+
"next_action": (
|
|
1534
|
+
"taskledger plan upsert --from-answers --file plan.md"
|
|
1535
|
+
if regeneration_needed
|
|
1536
|
+
else (
|
|
1537
|
+
"taskledger question answer-many --file answers.yaml"
|
|
1538
|
+
if required_open
|
|
1539
|
+
else "taskledger plan propose --file plan.md"
|
|
1540
|
+
)
|
|
1541
|
+
),
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def dismiss_question(
|
|
1546
|
+
workspace_root: Path,
|
|
1547
|
+
task_ref: str,
|
|
1548
|
+
question_id: str,
|
|
1549
|
+
) -> QuestionRecord:
|
|
1550
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1551
|
+
_enforce_decision(
|
|
1552
|
+
question_mutation_decision(
|
|
1553
|
+
task,
|
|
1554
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1555
|
+
actor_role="user",
|
|
1556
|
+
)
|
|
1557
|
+
)
|
|
1558
|
+
question = resolve_question(workspace_root, task.id, question_id)
|
|
1559
|
+
dismissed = replace(question, status="dismissed")
|
|
1560
|
+
save_question(workspace_root, dismissed)
|
|
1561
|
+
_append_event(
|
|
1562
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1563
|
+
task.id,
|
|
1564
|
+
"question.dismissed",
|
|
1565
|
+
{"question_id": dismissed.id},
|
|
1566
|
+
)
|
|
1567
|
+
return dismissed
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def list_open_questions(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
1571
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1572
|
+
questions = [
|
|
1573
|
+
item.to_dict()
|
|
1574
|
+
for item in list_questions(workspace_root, task.id)
|
|
1575
|
+
if item.status == "open"
|
|
1576
|
+
]
|
|
1577
|
+
return {"kind": "task_questions", "task_id": task.id, "questions": questions}
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def start_implementation(
|
|
1581
|
+
workspace_root: Path,
|
|
1582
|
+
task_ref: str,
|
|
1583
|
+
*,
|
|
1584
|
+
actor: ActorRef | None = None,
|
|
1585
|
+
harness: HarnessRef | None = None,
|
|
1586
|
+
) -> dict[str, object]:
|
|
1587
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1588
|
+
if task.status_stage not in IMPLEMENTABLE_TASK_STAGES:
|
|
1589
|
+
raise _cli_error(
|
|
1590
|
+
"Implementation requires approved or failed_validation state.",
|
|
1591
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1592
|
+
)
|
|
1593
|
+
if task.accepted_plan_version is None:
|
|
1594
|
+
raise _cli_error(
|
|
1595
|
+
"Implementation requires an accepted plan version.",
|
|
1596
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1597
|
+
)
|
|
1598
|
+
try:
|
|
1599
|
+
accepted_plan = resolve_plan(
|
|
1600
|
+
workspace_root,
|
|
1601
|
+
task.id,
|
|
1602
|
+
version=task.accepted_plan_version,
|
|
1603
|
+
)
|
|
1604
|
+
except LaunchError as exc:
|
|
1605
|
+
raise _cli_error(
|
|
1606
|
+
"Implementation requires a stored accepted plan record.",
|
|
1607
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1608
|
+
) from exc
|
|
1609
|
+
if accepted_plan.status != "accepted":
|
|
1610
|
+
raise _cli_error(
|
|
1611
|
+
"Implementation requires an accepted plan record.",
|
|
1612
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1613
|
+
)
|
|
1614
|
+
_ensure_dependencies_done(workspace_root, task)
|
|
1615
|
+
run = _start_run(
|
|
1616
|
+
workspace_root,
|
|
1617
|
+
task,
|
|
1618
|
+
run_type="implementation",
|
|
1619
|
+
stage="implementing",
|
|
1620
|
+
actor=actor,
|
|
1621
|
+
harness=harness,
|
|
1622
|
+
)
|
|
1623
|
+
updated = replace(
|
|
1624
|
+
resolve_task(workspace_root, task.id),
|
|
1625
|
+
latest_implementation_run=run.run_id,
|
|
1626
|
+
status_stage="implementing",
|
|
1627
|
+
updated_at=utc_now_iso(),
|
|
1628
|
+
)
|
|
1629
|
+
save_task(workspace_root, updated)
|
|
1630
|
+
_append_event(
|
|
1631
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1632
|
+
updated.id,
|
|
1633
|
+
"implementation.started",
|
|
1634
|
+
{"run_id": run.run_id},
|
|
1635
|
+
)
|
|
1636
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
1637
|
+
return _lifecycle_payload(
|
|
1638
|
+
"implement start",
|
|
1639
|
+
replace(updated, status_stage=task.status_stage),
|
|
1640
|
+
warnings=[],
|
|
1641
|
+
changed=True,
|
|
1642
|
+
run=run,
|
|
1643
|
+
lock=_require_lock(workspace_root, updated.id),
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def restart_implementation(
|
|
1648
|
+
workspace_root: Path,
|
|
1649
|
+
task_ref: str,
|
|
1650
|
+
*,
|
|
1651
|
+
summary: str,
|
|
1652
|
+
actor: ActorRef | None = None,
|
|
1653
|
+
harness: HarnessRef | None = None,
|
|
1654
|
+
) -> dict[str, object]:
|
|
1655
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1656
|
+
if task.status_stage != "failed_validation":
|
|
1657
|
+
raise _cli_error(
|
|
1658
|
+
"Implementation restart requires failed_validation state.",
|
|
1659
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1660
|
+
)
|
|
1661
|
+
if task.accepted_plan_version is None:
|
|
1662
|
+
raise _cli_error(
|
|
1663
|
+
"Implementation restart requires an accepted plan version.",
|
|
1664
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1665
|
+
)
|
|
1666
|
+
try:
|
|
1667
|
+
accepted_plan = resolve_plan(
|
|
1668
|
+
workspace_root,
|
|
1669
|
+
task.id,
|
|
1670
|
+
version=task.accepted_plan_version,
|
|
1671
|
+
)
|
|
1672
|
+
except LaunchError as exc:
|
|
1673
|
+
raise _cli_error(
|
|
1674
|
+
"Implementation restart requires a stored accepted plan record.",
|
|
1675
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1676
|
+
) from exc
|
|
1677
|
+
if accepted_plan.status != "accepted":
|
|
1678
|
+
raise _cli_error(
|
|
1679
|
+
"Implementation restart requires an accepted plan record.",
|
|
1680
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
1681
|
+
)
|
|
1682
|
+
if task.latest_validation_run is None:
|
|
1683
|
+
raise _cli_error(
|
|
1684
|
+
"Implementation restart requires a failed validation run.",
|
|
1685
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1686
|
+
)
|
|
1687
|
+
validation_run = _require_run(workspace_root, task, task.latest_validation_run)
|
|
1688
|
+
if (
|
|
1689
|
+
validation_run.run_type != "validation"
|
|
1690
|
+
or validation_run.status not in {"failed", "blocked"}
|
|
1691
|
+
or validation_run.result not in {"failed", "blocked"}
|
|
1692
|
+
):
|
|
1693
|
+
raise _cli_error(
|
|
1694
|
+
(
|
|
1695
|
+
"Implementation restart requires the latest validation run "
|
|
1696
|
+
"to be failed or blocked."
|
|
1697
|
+
),
|
|
1698
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1699
|
+
)
|
|
1700
|
+
if task.latest_implementation_run is None:
|
|
1701
|
+
raise _cli_error(
|
|
1702
|
+
"Implementation restart requires a previous implementation run.",
|
|
1703
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1704
|
+
)
|
|
1705
|
+
previous_run = _require_run(workspace_root, task, task.latest_implementation_run)
|
|
1706
|
+
if previous_run.run_type != "implementation":
|
|
1707
|
+
raise _cli_error(
|
|
1708
|
+
"Implementation restart requires a previous implementation run.",
|
|
1709
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
1710
|
+
)
|
|
1711
|
+
restart_summary = summary.strip()
|
|
1712
|
+
if not restart_summary:
|
|
1713
|
+
raise _cli_error(
|
|
1714
|
+
"Implementation restart requires a non-empty summary.",
|
|
1715
|
+
EXIT_CODE_BAD_INPUT,
|
|
1716
|
+
)
|
|
1717
|
+
_ensure_dependencies_done(workspace_root, task)
|
|
1718
|
+
run = _start_run(
|
|
1719
|
+
workspace_root,
|
|
1720
|
+
task,
|
|
1721
|
+
run_type="implementation",
|
|
1722
|
+
stage="implementing",
|
|
1723
|
+
actor=actor,
|
|
1724
|
+
harness=harness,
|
|
1725
|
+
)
|
|
1726
|
+
restarted = replace(
|
|
1727
|
+
run,
|
|
1728
|
+
resumes_run_id=previous_run.run_id,
|
|
1729
|
+
worklog=(
|
|
1730
|
+
f"Restart summary: {restart_summary}",
|
|
1731
|
+
(
|
|
1732
|
+
"Restarted after "
|
|
1733
|
+
f"validation run {validation_run.run_id} "
|
|
1734
|
+
f"({validation_run.result})."
|
|
1735
|
+
),
|
|
1736
|
+
*run.worklog,
|
|
1737
|
+
),
|
|
1738
|
+
)
|
|
1739
|
+
save_run(workspace_root, restarted)
|
|
1740
|
+
updated = replace(
|
|
1741
|
+
resolve_task(workspace_root, task.id),
|
|
1742
|
+
latest_implementation_run=restarted.run_id,
|
|
1743
|
+
status_stage="implementing",
|
|
1744
|
+
updated_at=utc_now_iso(),
|
|
1745
|
+
)
|
|
1746
|
+
save_task(workspace_root, updated)
|
|
1747
|
+
_append_event(
|
|
1748
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1749
|
+
updated.id,
|
|
1750
|
+
"implementation.started",
|
|
1751
|
+
{
|
|
1752
|
+
"run_id": restarted.run_id,
|
|
1753
|
+
"restart": True,
|
|
1754
|
+
"summary": restart_summary,
|
|
1755
|
+
"after_validation_run": validation_run.run_id,
|
|
1756
|
+
"resumes_run_id": previous_run.run_id,
|
|
1757
|
+
},
|
|
1758
|
+
)
|
|
1759
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
1760
|
+
return _lifecycle_payload(
|
|
1761
|
+
"implement restart",
|
|
1762
|
+
replace(updated, status_stage=task.status_stage),
|
|
1763
|
+
warnings=[],
|
|
1764
|
+
changed=True,
|
|
1765
|
+
run=restarted,
|
|
1766
|
+
lock=_require_lock(workspace_root, updated.id),
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def log_implementation(
|
|
1771
|
+
workspace_root: Path,
|
|
1772
|
+
task_ref: str,
|
|
1773
|
+
*,
|
|
1774
|
+
message: str,
|
|
1775
|
+
) -> TaskRunRecord:
|
|
1776
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1777
|
+
run = _require_running_run(
|
|
1778
|
+
workspace_root,
|
|
1779
|
+
task,
|
|
1780
|
+
task.latest_implementation_run,
|
|
1781
|
+
expected_type="implementation",
|
|
1782
|
+
)
|
|
1783
|
+
_enforce_decision(
|
|
1784
|
+
implementation_mutation_decision(
|
|
1785
|
+
task,
|
|
1786
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1787
|
+
run=run,
|
|
1788
|
+
action="log implementation work",
|
|
1789
|
+
)
|
|
1790
|
+
)
|
|
1791
|
+
updated = replace(run, worklog=tuple([*run.worklog, message.strip()]))
|
|
1792
|
+
save_run(workspace_root, updated)
|
|
1793
|
+
_append_event(
|
|
1794
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1795
|
+
task.id,
|
|
1796
|
+
"implementation.logged",
|
|
1797
|
+
{"run_id": run.run_id, "message": message.strip()},
|
|
1798
|
+
)
|
|
1799
|
+
return updated
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def add_implementation_deviation(
|
|
1803
|
+
workspace_root: Path,
|
|
1804
|
+
task_ref: str,
|
|
1805
|
+
*,
|
|
1806
|
+
message: str,
|
|
1807
|
+
) -> TaskRunRecord:
|
|
1808
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1809
|
+
run = _require_running_run(
|
|
1810
|
+
workspace_root,
|
|
1811
|
+
task,
|
|
1812
|
+
task.latest_implementation_run,
|
|
1813
|
+
expected_type="implementation",
|
|
1814
|
+
)
|
|
1815
|
+
_enforce_decision(
|
|
1816
|
+
implementation_mutation_decision(
|
|
1817
|
+
task,
|
|
1818
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1819
|
+
run=run,
|
|
1820
|
+
action="record implementation deviations",
|
|
1821
|
+
)
|
|
1822
|
+
)
|
|
1823
|
+
updated = replace(
|
|
1824
|
+
run,
|
|
1825
|
+
deviations_from_plan=tuple([*run.deviations_from_plan, message.strip()]),
|
|
1826
|
+
)
|
|
1827
|
+
save_run(workspace_root, updated)
|
|
1828
|
+
_append_event(
|
|
1829
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1830
|
+
task.id,
|
|
1831
|
+
"implementation.logged",
|
|
1832
|
+
{"run_id": run.run_id, "deviation": message.strip()},
|
|
1833
|
+
)
|
|
1834
|
+
return updated
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def add_implementation_artifact(
|
|
1838
|
+
workspace_root: Path,
|
|
1839
|
+
task_ref: str,
|
|
1840
|
+
*,
|
|
1841
|
+
path: str,
|
|
1842
|
+
summary: str,
|
|
1843
|
+
) -> TaskRunRecord:
|
|
1844
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1845
|
+
run = _require_running_run(
|
|
1846
|
+
workspace_root,
|
|
1847
|
+
task,
|
|
1848
|
+
task.latest_implementation_run,
|
|
1849
|
+
expected_type="implementation",
|
|
1850
|
+
)
|
|
1851
|
+
_enforce_decision(
|
|
1852
|
+
implementation_mutation_decision(
|
|
1853
|
+
task,
|
|
1854
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1855
|
+
run=run,
|
|
1856
|
+
action="record implementation artifacts",
|
|
1857
|
+
)
|
|
1858
|
+
)
|
|
1859
|
+
updated = replace(
|
|
1860
|
+
run,
|
|
1861
|
+
artifact_refs=tuple([*run.artifact_refs, f"{path}: {summary.strip()}"]),
|
|
1862
|
+
)
|
|
1863
|
+
save_run(workspace_root, updated)
|
|
1864
|
+
_append_event(
|
|
1865
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1866
|
+
task.id,
|
|
1867
|
+
"implementation.logged",
|
|
1868
|
+
{"run_id": run.run_id, "artifact": path, "summary": summary.strip()},
|
|
1869
|
+
)
|
|
1870
|
+
return updated
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def add_change(
|
|
1874
|
+
workspace_root: Path,
|
|
1875
|
+
task_ref: str,
|
|
1876
|
+
*,
|
|
1877
|
+
path: str,
|
|
1878
|
+
kind: str,
|
|
1879
|
+
summary: str,
|
|
1880
|
+
git_commit: str | None = None,
|
|
1881
|
+
git_diff_stat: str | None = None,
|
|
1882
|
+
command: str | None = None,
|
|
1883
|
+
before_hash: str | None = None,
|
|
1884
|
+
after_hash: str | None = None,
|
|
1885
|
+
exit_code: int | None = None,
|
|
1886
|
+
artifact_refs: tuple[str, ...] = (),
|
|
1887
|
+
) -> CodeChangeRecord:
|
|
1888
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1889
|
+
run = _require_running_run(
|
|
1890
|
+
workspace_root,
|
|
1891
|
+
task,
|
|
1892
|
+
task.latest_implementation_run,
|
|
1893
|
+
expected_type="implementation",
|
|
1894
|
+
)
|
|
1895
|
+
_enforce_decision(
|
|
1896
|
+
implementation_mutation_decision(
|
|
1897
|
+
task,
|
|
1898
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1899
|
+
run=run,
|
|
1900
|
+
action="record code changes",
|
|
1901
|
+
)
|
|
1902
|
+
)
|
|
1903
|
+
change = CodeChangeRecord(
|
|
1904
|
+
change_id=next_project_id(
|
|
1905
|
+
"change",
|
|
1906
|
+
[item.change_id for item in list_changes(workspace_root, task.id)],
|
|
1907
|
+
),
|
|
1908
|
+
task_id=task.id,
|
|
1909
|
+
implementation_run=run.run_id,
|
|
1910
|
+
timestamp=utc_now_iso(),
|
|
1911
|
+
kind=kind,
|
|
1912
|
+
path=path,
|
|
1913
|
+
summary=summary.strip(),
|
|
1914
|
+
git_commit=git_commit,
|
|
1915
|
+
git_diff_stat=git_diff_stat,
|
|
1916
|
+
command=command,
|
|
1917
|
+
before_hash=before_hash,
|
|
1918
|
+
after_hash=after_hash,
|
|
1919
|
+
exit_code=exit_code,
|
|
1920
|
+
)
|
|
1921
|
+
save_change(workspace_root, change)
|
|
1922
|
+
save_run(
|
|
1923
|
+
workspace_root,
|
|
1924
|
+
replace(
|
|
1925
|
+
run,
|
|
1926
|
+
change_refs=tuple([*run.change_refs, change.change_id]),
|
|
1927
|
+
artifact_refs=tuple([*run.artifact_refs, *artifact_refs]),
|
|
1928
|
+
),
|
|
1929
|
+
)
|
|
1930
|
+
save_task(
|
|
1931
|
+
workspace_root,
|
|
1932
|
+
replace(
|
|
1933
|
+
task,
|
|
1934
|
+
code_change_log_refs=tuple([*task.code_change_log_refs, change.change_id]),
|
|
1935
|
+
updated_at=utc_now_iso(),
|
|
1936
|
+
),
|
|
1937
|
+
)
|
|
1938
|
+
_append_event(
|
|
1939
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
1940
|
+
task.id,
|
|
1941
|
+
"change.logged",
|
|
1942
|
+
{"change_id": change.change_id, "path": path},
|
|
1943
|
+
)
|
|
1944
|
+
return change
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def scan_changes(
|
|
1948
|
+
workspace_root: Path,
|
|
1949
|
+
task_ref: str,
|
|
1950
|
+
*,
|
|
1951
|
+
from_git: bool,
|
|
1952
|
+
summary: str,
|
|
1953
|
+
) -> CodeChangeRecord:
|
|
1954
|
+
if not from_git:
|
|
1955
|
+
raise _cli_error(
|
|
1956
|
+
"scan-changes currently requires --from-git.",
|
|
1957
|
+
EXIT_CODE_BAD_INPUT,
|
|
1958
|
+
)
|
|
1959
|
+
git_state = _git_change_state(workspace_root)
|
|
1960
|
+
diff_stat = "\n".join(
|
|
1961
|
+
[
|
|
1962
|
+
f"branch: {git_state['branch']}",
|
|
1963
|
+
"status:",
|
|
1964
|
+
git_state["status"] or "(clean)",
|
|
1965
|
+
"diff_stat:",
|
|
1966
|
+
git_state["diff_stat"] or "(no diff)",
|
|
1967
|
+
]
|
|
1968
|
+
)
|
|
1969
|
+
return add_change(
|
|
1970
|
+
workspace_root,
|
|
1971
|
+
task_ref,
|
|
1972
|
+
path=".",
|
|
1973
|
+
kind="scan",
|
|
1974
|
+
summary=summary.strip() or "Scanned Git changes.",
|
|
1975
|
+
command="git branch --show-current && git status --short && git diff --stat",
|
|
1976
|
+
git_diff_stat=diff_stat,
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def run_planning_command(
|
|
1981
|
+
workspace_root: Path,
|
|
1982
|
+
task_ref: str,
|
|
1983
|
+
*,
|
|
1984
|
+
argv: tuple[str, ...],
|
|
1985
|
+
) -> dict[str, object]:
|
|
1986
|
+
if not argv:
|
|
1987
|
+
raise _cli_error("plan command requires a command to run.", EXIT_CODE_BAD_INPUT)
|
|
1988
|
+
task = resolve_task(workspace_root, task_ref)
|
|
1989
|
+
run = _require_running_run(
|
|
1990
|
+
workspace_root,
|
|
1991
|
+
task,
|
|
1992
|
+
task.latest_planning_run,
|
|
1993
|
+
expected_type="planning",
|
|
1994
|
+
)
|
|
1995
|
+
_enforce_decision(
|
|
1996
|
+
plan_command_decision(
|
|
1997
|
+
task,
|
|
1998
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
1999
|
+
run=run,
|
|
2000
|
+
)
|
|
2001
|
+
)
|
|
2002
|
+
completed = subprocess.run(
|
|
2003
|
+
list(argv),
|
|
2004
|
+
cwd=workspace_root,
|
|
2005
|
+
capture_output=True,
|
|
2006
|
+
text=True,
|
|
2007
|
+
check=False,
|
|
2008
|
+
)
|
|
2009
|
+
output = _command_output(argv, completed.stdout, completed.stderr)
|
|
2010
|
+
artifact_ref: str | None = None
|
|
2011
|
+
if len(output) > 4000 or output.count("\n") > 50:
|
|
2012
|
+
artifact_ref = _write_command_artifact(
|
|
2013
|
+
workspace_root,
|
|
2014
|
+
task.id,
|
|
2015
|
+
run.run_id,
|
|
2016
|
+
output,
|
|
2017
|
+
)
|
|
2018
|
+
change = CodeChangeRecord(
|
|
2019
|
+
change_id=next_project_id(
|
|
2020
|
+
"change",
|
|
2021
|
+
[item.change_id for item in list_changes(workspace_root, task.id)],
|
|
2022
|
+
),
|
|
2023
|
+
task_id=task.id,
|
|
2024
|
+
implementation_run=run.run_id,
|
|
2025
|
+
timestamp=utc_now_iso(),
|
|
2026
|
+
kind="command",
|
|
2027
|
+
path=".",
|
|
2028
|
+
summary=_command_summary(argv, completed.returncode, artifact_ref),
|
|
2029
|
+
command=shlex.join(argv),
|
|
2030
|
+
exit_code=completed.returncode,
|
|
2031
|
+
)
|
|
2032
|
+
save_change(workspace_root, change)
|
|
2033
|
+
save_run(
|
|
2034
|
+
workspace_root,
|
|
2035
|
+
replace(
|
|
2036
|
+
run,
|
|
2037
|
+
change_refs=tuple([*run.change_refs, change.change_id]),
|
|
2038
|
+
artifact_refs=tuple(
|
|
2039
|
+
[*run.artifact_refs, *((artifact_ref,) if artifact_ref else ())]
|
|
2040
|
+
),
|
|
2041
|
+
),
|
|
2042
|
+
)
|
|
2043
|
+
save_task(
|
|
2044
|
+
workspace_root,
|
|
2045
|
+
replace(
|
|
2046
|
+
task,
|
|
2047
|
+
code_change_log_refs=tuple([*task.code_change_log_refs, change.change_id]),
|
|
2048
|
+
updated_at=utc_now_iso(),
|
|
2049
|
+
),
|
|
2050
|
+
)
|
|
2051
|
+
_append_event(
|
|
2052
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
2053
|
+
task.id,
|
|
2054
|
+
"change.logged",
|
|
2055
|
+
{"change_id": change.change_id, "path": "."},
|
|
2056
|
+
)
|
|
2057
|
+
return {
|
|
2058
|
+
"kind": "planning_command",
|
|
2059
|
+
"task_id": change.task_id,
|
|
2060
|
+
"change": change.to_dict(),
|
|
2061
|
+
"exit_code": completed.returncode,
|
|
2062
|
+
"artifact_path": artifact_ref,
|
|
2063
|
+
"stdout": completed.stdout,
|
|
2064
|
+
"stderr": completed.stderr,
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
def run_implementation_command(
|
|
2069
|
+
workspace_root: Path,
|
|
2070
|
+
task_ref: str,
|
|
2071
|
+
*,
|
|
2072
|
+
argv: tuple[str, ...],
|
|
2073
|
+
) -> dict[str, object]:
|
|
2074
|
+
if not argv:
|
|
2075
|
+
raise _cli_error(
|
|
2076
|
+
"implement command requires a command to run.", EXIT_CODE_BAD_INPUT
|
|
2077
|
+
)
|
|
2078
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2079
|
+
run = _require_running_run(
|
|
2080
|
+
workspace_root,
|
|
2081
|
+
task,
|
|
2082
|
+
task.latest_implementation_run,
|
|
2083
|
+
expected_type="implementation",
|
|
2084
|
+
)
|
|
2085
|
+
_enforce_decision(
|
|
2086
|
+
implementation_mutation_decision(
|
|
2087
|
+
task,
|
|
2088
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
2089
|
+
run=run,
|
|
2090
|
+
action="record implementation commands",
|
|
2091
|
+
)
|
|
2092
|
+
)
|
|
2093
|
+
completed = subprocess.run(
|
|
2094
|
+
list(argv),
|
|
2095
|
+
cwd=workspace_root,
|
|
2096
|
+
capture_output=True,
|
|
2097
|
+
text=True,
|
|
2098
|
+
check=False,
|
|
2099
|
+
)
|
|
2100
|
+
output = _command_output(argv, completed.stdout, completed.stderr)
|
|
2101
|
+
artifact_ref: str | None = None
|
|
2102
|
+
if len(output) > 4000 or output.count("\n") > 50:
|
|
2103
|
+
artifact_ref = _write_command_artifact(
|
|
2104
|
+
workspace_root,
|
|
2105
|
+
task.id,
|
|
2106
|
+
run.run_id,
|
|
2107
|
+
output,
|
|
2108
|
+
)
|
|
2109
|
+
change = add_change(
|
|
2110
|
+
workspace_root,
|
|
2111
|
+
task_ref,
|
|
2112
|
+
path=".",
|
|
2113
|
+
kind="command",
|
|
2114
|
+
summary=_command_summary(argv, completed.returncode, artifact_ref),
|
|
2115
|
+
command=shlex.join(argv),
|
|
2116
|
+
exit_code=completed.returncode,
|
|
2117
|
+
artifact_refs=((artifact_ref,) if artifact_ref else ()),
|
|
2118
|
+
)
|
|
2119
|
+
return {
|
|
2120
|
+
"kind": "implementation_command",
|
|
2121
|
+
"task_id": change.task_id,
|
|
2122
|
+
"change": change.to_dict(),
|
|
2123
|
+
"exit_code": completed.returncode,
|
|
2124
|
+
"artifact_path": artifact_ref,
|
|
2125
|
+
"stdout": completed.stdout,
|
|
2126
|
+
"stderr": completed.stderr,
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def _build_todo_gate_report(
|
|
2131
|
+
workspace_root: Path, task: TaskRecord
|
|
2132
|
+
) -> dict[str, object]:
|
|
2133
|
+
"""Build a report of todo completion status for finish gate validation."""
|
|
2134
|
+
task = _task_with_sidecars(workspace_root, task)
|
|
2135
|
+
todos = task.todos
|
|
2136
|
+
open_todos = [
|
|
2137
|
+
todo.id
|
|
2138
|
+
for todo in todos
|
|
2139
|
+
if not todo.done
|
|
2140
|
+
and todo.status not in {"done", "skipped"}
|
|
2141
|
+
and (
|
|
2142
|
+
not todo.mandatory
|
|
2143
|
+
or todo.active_at is not None
|
|
2144
|
+
or todo.source == "plan"
|
|
2145
|
+
or todo.source_plan_id is not None
|
|
2146
|
+
)
|
|
2147
|
+
]
|
|
2148
|
+
blockers = [
|
|
2149
|
+
{
|
|
2150
|
+
"kind": "todo_open",
|
|
2151
|
+
"ref": todo_id,
|
|
2152
|
+
"message": f"Todo {todo_id} is not done.",
|
|
2153
|
+
"command_hint": f'taskledger todo done {todo_id} --evidence "..."',
|
|
2154
|
+
}
|
|
2155
|
+
for todo_id in open_todos
|
|
2156
|
+
]
|
|
2157
|
+
return {
|
|
2158
|
+
"kind": "todo_gate_report",
|
|
2159
|
+
"task_id": task.id,
|
|
2160
|
+
"total": len(todos),
|
|
2161
|
+
"done": len(todos) - len(open_todos),
|
|
2162
|
+
"open_todos": open_todos,
|
|
2163
|
+
"blockers": blockers,
|
|
2164
|
+
"can_finish_implementation": not open_todos,
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
|
|
2168
|
+
def _require_todos_complete_for_implementation_finish(
|
|
2169
|
+
workspace_root: Path, task: TaskRecord
|
|
2170
|
+
) -> None:
|
|
2171
|
+
"""Enforce that all todos are done before finishing implementation."""
|
|
2172
|
+
report = _build_todo_gate_report(workspace_root, task)
|
|
2173
|
+
if report["can_finish_implementation"]:
|
|
2174
|
+
return
|
|
2175
|
+
error = LaunchError("Cannot finish implementation because todos are incomplete.")
|
|
2176
|
+
error.taskledger_exit_code = EXIT_CODE_VALIDATION_FAILED
|
|
2177
|
+
error.taskledger_error_code = "IMPLEMENTATION_TODOS_INCOMPLETE"
|
|
2178
|
+
error.taskledger_data = report
|
|
2179
|
+
raise error
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def todo_status(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
2183
|
+
"""Get todo status and progress for a task."""
|
|
2184
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2185
|
+
return _build_todo_gate_report(workspace_root, task)
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
def next_todo(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
2189
|
+
"""Get the next unfinished todo for a task."""
|
|
2190
|
+
task = _task_with_sidecars(workspace_root, resolve_task(workspace_root, task_ref))
|
|
2191
|
+
todos = task.todos
|
|
2192
|
+
|
|
2193
|
+
# Prefer active todos first, then first open todo
|
|
2194
|
+
for todo in todos:
|
|
2195
|
+
if not todo.done and hasattr(todo, "status") and todo.status == "active":
|
|
2196
|
+
return _next_todo_payload(task.id, todo)
|
|
2197
|
+
|
|
2198
|
+
for todo in todos:
|
|
2199
|
+
if not todo.done:
|
|
2200
|
+
return _next_todo_payload(task.id, todo)
|
|
2201
|
+
|
|
2202
|
+
return {
|
|
2203
|
+
"kind": "next_todo",
|
|
2204
|
+
"task_id": task.id,
|
|
2205
|
+
"next_todo_id": None,
|
|
2206
|
+
"next_todo": None,
|
|
2207
|
+
"commands": [],
|
|
2208
|
+
"can_finish_implementation": True,
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def finish_implementation(
|
|
2213
|
+
workspace_root: Path,
|
|
2214
|
+
task_ref: str,
|
|
2215
|
+
*,
|
|
2216
|
+
summary: str,
|
|
2217
|
+
) -> dict[str, object]:
|
|
2218
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2219
|
+
run = _require_running_run(
|
|
2220
|
+
workspace_root,
|
|
2221
|
+
task,
|
|
2222
|
+
task.latest_implementation_run,
|
|
2223
|
+
expected_type="implementation",
|
|
2224
|
+
)
|
|
2225
|
+
_require_todos_complete_for_implementation_finish(workspace_root, task)
|
|
2226
|
+
finished = replace(
|
|
2227
|
+
run,
|
|
2228
|
+
status="finished",
|
|
2229
|
+
finished_at=utc_now_iso(),
|
|
2230
|
+
summary=summary.strip(),
|
|
2231
|
+
)
|
|
2232
|
+
save_run(workspace_root, finished)
|
|
2233
|
+
updated = replace(task, status_stage="implemented", updated_at=utc_now_iso())
|
|
2234
|
+
save_task(workspace_root, updated)
|
|
2235
|
+
_release_lock(
|
|
2236
|
+
workspace_root,
|
|
2237
|
+
task=updated,
|
|
2238
|
+
expected_stage="implementing",
|
|
2239
|
+
run_id=run.run_id,
|
|
2240
|
+
target_stage="implemented",
|
|
2241
|
+
event_name="stage.completed",
|
|
2242
|
+
)
|
|
2243
|
+
_append_event(
|
|
2244
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
2245
|
+
updated.id,
|
|
2246
|
+
"implementation.finished",
|
|
2247
|
+
{"run_id": run.run_id},
|
|
2248
|
+
)
|
|
2249
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
2250
|
+
return _lifecycle_payload(
|
|
2251
|
+
"implement finish",
|
|
2252
|
+
updated,
|
|
2253
|
+
warnings=[],
|
|
2254
|
+
changed=True,
|
|
2255
|
+
run=finished,
|
|
2256
|
+
)
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
def start_validation(
|
|
2260
|
+
workspace_root: Path,
|
|
2261
|
+
task_ref: str,
|
|
2262
|
+
*,
|
|
2263
|
+
actor: ActorRef | None = None,
|
|
2264
|
+
harness: HarnessRef | None = None,
|
|
2265
|
+
) -> dict[str, object]:
|
|
2266
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2267
|
+
if task.status_stage != "implemented":
|
|
2268
|
+
raise _cli_error(
|
|
2269
|
+
"Validation requires implemented state.",
|
|
2270
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
2271
|
+
)
|
|
2272
|
+
impl_run = _require_run(workspace_root, task, task.latest_implementation_run)
|
|
2273
|
+
if impl_run.run_type != "implementation" or impl_run.status != "finished":
|
|
2274
|
+
raise _cli_error(
|
|
2275
|
+
"Validation requires a finished implementation run.",
|
|
2276
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
2277
|
+
)
|
|
2278
|
+
run = _start_run(
|
|
2279
|
+
workspace_root,
|
|
2280
|
+
task,
|
|
2281
|
+
run_type="validation",
|
|
2282
|
+
stage="validating",
|
|
2283
|
+
actor=actor,
|
|
2284
|
+
harness=harness,
|
|
2285
|
+
)
|
|
2286
|
+
updated_run = replace(run, based_on_implementation_run=impl_run.run_id)
|
|
2287
|
+
save_run(workspace_root, updated_run)
|
|
2288
|
+
updated = replace(
|
|
2289
|
+
resolve_task(workspace_root, task.id),
|
|
2290
|
+
latest_validation_run=updated_run.run_id,
|
|
2291
|
+
updated_at=utc_now_iso(),
|
|
2292
|
+
)
|
|
2293
|
+
save_task(workspace_root, updated)
|
|
2294
|
+
_append_event(
|
|
2295
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
2296
|
+
updated.id,
|
|
2297
|
+
"validation.started",
|
|
2298
|
+
{"run_id": updated_run.run_id},
|
|
2299
|
+
)
|
|
2300
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
2301
|
+
return _lifecycle_payload(
|
|
2302
|
+
"validate start",
|
|
2303
|
+
updated,
|
|
2304
|
+
warnings=[],
|
|
2305
|
+
changed=True,
|
|
2306
|
+
run=updated_run,
|
|
2307
|
+
lock=_require_lock(workspace_root, updated.id),
|
|
2308
|
+
)
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
def _resolve_criterion_ref(plan: PlanRecord, criterion_ref: str) -> str:
|
|
2312
|
+
"""Canonicalize criterion reference to the exact ID in the plan.
|
|
2313
|
+
|
|
2314
|
+
Accepts:
|
|
2315
|
+
- exact ID: ac-0001
|
|
2316
|
+
- different case: AC-0001
|
|
2317
|
+
- short AC form: ac-1 (should match ac-0001)
|
|
2318
|
+
- numeric form: 1 (should match ac-0001)
|
|
2319
|
+
|
|
2320
|
+
Raises LaunchError if criterion not found in plan.
|
|
2321
|
+
"""
|
|
2322
|
+
if not plan.criteria:
|
|
2323
|
+
raise _cli_error(
|
|
2324
|
+
"No acceptance criteria defined in plan.",
|
|
2325
|
+
EXIT_CODE_BAD_INPUT,
|
|
2326
|
+
)
|
|
2327
|
+
|
|
2328
|
+
normalized_ref = criterion_ref.strip().lower()
|
|
2329
|
+
|
|
2330
|
+
for c in plan.criteria:
|
|
2331
|
+
c_id_lower = c.id.lower()
|
|
2332
|
+
|
|
2333
|
+
if c_id_lower == normalized_ref:
|
|
2334
|
+
return c.id
|
|
2335
|
+
|
|
2336
|
+
parts = c_id_lower.split("-")
|
|
2337
|
+
if len(parts) == 2:
|
|
2338
|
+
prefix, number = parts
|
|
2339
|
+
|
|
2340
|
+
if normalized_ref == f"{prefix}-{number}":
|
|
2341
|
+
return c.id
|
|
2342
|
+
|
|
2343
|
+
ref_parts = normalized_ref.split("-")
|
|
2344
|
+
if len(ref_parts) == 2:
|
|
2345
|
+
ref_prefix, ref_number = ref_parts
|
|
2346
|
+
if ref_prefix == prefix:
|
|
2347
|
+
try:
|
|
2348
|
+
if int(ref_number) == int(number):
|
|
2349
|
+
return c.id
|
|
2350
|
+
except ValueError:
|
|
2351
|
+
pass
|
|
2352
|
+
|
|
2353
|
+
if normalized_ref == number:
|
|
2354
|
+
return c.id
|
|
2355
|
+
|
|
2356
|
+
try:
|
|
2357
|
+
if int(normalized_ref) == int(number):
|
|
2358
|
+
return c.id
|
|
2359
|
+
except ValueError:
|
|
2360
|
+
pass
|
|
2361
|
+
|
|
2362
|
+
criterion_ids = ", ".join(sorted(c.id for c in plan.criteria))
|
|
2363
|
+
raise _cli_error(
|
|
2364
|
+
f"Unknown acceptance criterion: {criterion_ref}.\n"
|
|
2365
|
+
f"Known criteria: {criterion_ids}.",
|
|
2366
|
+
EXIT_CODE_BAD_INPUT,
|
|
2367
|
+
)
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def _build_validation_gate_report(
|
|
2371
|
+
workspace_root: Path,
|
|
2372
|
+
task: TaskRecord,
|
|
2373
|
+
run: TaskRunRecord | None = None,
|
|
2374
|
+
) -> dict[str, object]:
|
|
2375
|
+
from taskledger.services.validation import build_validation_gate_report
|
|
2376
|
+
|
|
2377
|
+
return build_validation_gate_report(workspace_root, task, run)
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
def validation_status(
|
|
2381
|
+
workspace_root: Path,
|
|
2382
|
+
task_ref: str,
|
|
2383
|
+
*,
|
|
2384
|
+
run_id: str | None = None,
|
|
2385
|
+
) -> dict[str, object]:
|
|
2386
|
+
"""Get validation status report for a task."""
|
|
2387
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2388
|
+
run = None
|
|
2389
|
+
if run_id:
|
|
2390
|
+
from taskledger.storage.task_store import resolve_run
|
|
2391
|
+
|
|
2392
|
+
run = resolve_run(workspace_root, task.id, run_id)
|
|
2393
|
+
|
|
2394
|
+
report = _build_validation_gate_report(workspace_root, task, run)
|
|
2395
|
+
return {"kind": "validation_status", "result": report}
|
|
2396
|
+
|
|
2397
|
+
|
|
2398
|
+
def add_validation_check(
|
|
2399
|
+
workspace_root: Path,
|
|
2400
|
+
task_ref: str,
|
|
2401
|
+
*,
|
|
2402
|
+
name: str | None = None,
|
|
2403
|
+
criterion_id: str | None = None,
|
|
2404
|
+
status: str,
|
|
2405
|
+
details: str | None = None,
|
|
2406
|
+
evidence: tuple[str, ...] = (),
|
|
2407
|
+
) -> TaskRunRecord:
|
|
2408
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2409
|
+
run = _require_running_run(
|
|
2410
|
+
workspace_root,
|
|
2411
|
+
task,
|
|
2412
|
+
task.latest_validation_run,
|
|
2413
|
+
expected_type="validation",
|
|
2414
|
+
)
|
|
2415
|
+
_enforce_decision(
|
|
2416
|
+
validation_check_decision(
|
|
2417
|
+
task,
|
|
2418
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
2419
|
+
run=run,
|
|
2420
|
+
)
|
|
2421
|
+
)
|
|
2422
|
+
normalized_status = normalize_validation_check_status(status)
|
|
2423
|
+
check_id = f"check-{len(run.checks) + 1:04d}"
|
|
2424
|
+
resolved_criterion = criterion_id.strip() if criterion_id else None
|
|
2425
|
+
if normalized_status != "not_run" and resolved_criterion is None:
|
|
2426
|
+
raise _cli_error(
|
|
2427
|
+
"Validation checks must reference --criterion unless status is not_run.",
|
|
2428
|
+
EXIT_CODE_BAD_INPUT,
|
|
2429
|
+
)
|
|
2430
|
+
|
|
2431
|
+
if resolved_criterion is not None:
|
|
2432
|
+
if task.accepted_plan_version is None:
|
|
2433
|
+
raise _cli_error(
|
|
2434
|
+
"Cannot add criterion check without an accepted plan. "
|
|
2435
|
+
"Accept a plan first with: task accept-plan",
|
|
2436
|
+
EXIT_CODE_BAD_INPUT,
|
|
2437
|
+
)
|
|
2438
|
+
accepted_plan = resolve_plan(
|
|
2439
|
+
workspace_root,
|
|
2440
|
+
task.id,
|
|
2441
|
+
version=task.accepted_plan_version,
|
|
2442
|
+
)
|
|
2443
|
+
resolved_criterion = _resolve_criterion_ref(accepted_plan, resolved_criterion)
|
|
2444
|
+
|
|
2445
|
+
check = ValidationCheck(
|
|
2446
|
+
name=(name or resolved_criterion or check_id).strip(),
|
|
2447
|
+
id=check_id,
|
|
2448
|
+
criterion_id=resolved_criterion,
|
|
2449
|
+
status=normalized_status,
|
|
2450
|
+
details=details.strip() if details else None,
|
|
2451
|
+
evidence=tuple(item.strip() for item in evidence if item.strip()),
|
|
2452
|
+
)
|
|
2453
|
+
updated = replace(run, checks=tuple([*run.checks, check]))
|
|
2454
|
+
save_run(workspace_root, updated)
|
|
2455
|
+
return updated
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
def waive_criterion(
|
|
2459
|
+
workspace_root: Path,
|
|
2460
|
+
task_ref: str,
|
|
2461
|
+
*,
|
|
2462
|
+
criterion_id: str,
|
|
2463
|
+
reason: str,
|
|
2464
|
+
actor_name: str | None = None,
|
|
2465
|
+
) -> TaskRunRecord:
|
|
2466
|
+
"""Record a criterion waiver for a validation check."""
|
|
2467
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2468
|
+
run = _require_running_run(
|
|
2469
|
+
workspace_root,
|
|
2470
|
+
task,
|
|
2471
|
+
task.latest_validation_run,
|
|
2472
|
+
expected_type="validation",
|
|
2473
|
+
)
|
|
2474
|
+
_enforce_decision(
|
|
2475
|
+
validation_check_decision(
|
|
2476
|
+
task,
|
|
2477
|
+
_lock_for_mutation(workspace_root, task.id),
|
|
2478
|
+
run=run,
|
|
2479
|
+
)
|
|
2480
|
+
)
|
|
2481
|
+
|
|
2482
|
+
if task.accepted_plan_version is None:
|
|
2483
|
+
raise _cli_error(
|
|
2484
|
+
"Cannot waive criterion without an accepted plan.",
|
|
2485
|
+
EXIT_CODE_BAD_INPUT,
|
|
2486
|
+
)
|
|
2487
|
+
|
|
2488
|
+
accepted_plan = resolve_plan(
|
|
2489
|
+
workspace_root,
|
|
2490
|
+
task.id,
|
|
2491
|
+
version=task.accepted_plan_version,
|
|
2492
|
+
)
|
|
2493
|
+
resolved_criterion = _resolve_criterion_ref(accepted_plan, criterion_id)
|
|
2494
|
+
|
|
2495
|
+
if not reason.strip():
|
|
2496
|
+
raise _cli_error("Waiver reason is required.", EXIT_CODE_BAD_INPUT)
|
|
2497
|
+
|
|
2498
|
+
waiver = CriterionWaiver(
|
|
2499
|
+
actor=ActorRef(
|
|
2500
|
+
actor_type="user",
|
|
2501
|
+
actor_name=(actor_name or getpass.getuser() or "user").strip(),
|
|
2502
|
+
tool="manual",
|
|
2503
|
+
),
|
|
2504
|
+
reason=reason.strip(),
|
|
2505
|
+
)
|
|
2506
|
+
|
|
2507
|
+
check_id = f"check-{len(run.checks) + 1:04d}"
|
|
2508
|
+
check = ValidationCheck(
|
|
2509
|
+
name=resolved_criterion,
|
|
2510
|
+
id=check_id,
|
|
2511
|
+
criterion_id=resolved_criterion,
|
|
2512
|
+
status="pass",
|
|
2513
|
+
waiver=waiver,
|
|
2514
|
+
)
|
|
2515
|
+
|
|
2516
|
+
updated = replace(run, checks=tuple([*run.checks, check]))
|
|
2517
|
+
save_run(workspace_root, updated)
|
|
2518
|
+
return updated
|
|
2519
|
+
|
|
2520
|
+
|
|
2521
|
+
def finish_validation(
|
|
2522
|
+
workspace_root: Path,
|
|
2523
|
+
task_ref: str,
|
|
2524
|
+
*,
|
|
2525
|
+
result: str,
|
|
2526
|
+
summary: str,
|
|
2527
|
+
recommendation: str | None = None,
|
|
2528
|
+
) -> dict[str, object]:
|
|
2529
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2530
|
+
run = _require_running_run(
|
|
2531
|
+
workspace_root,
|
|
2532
|
+
task,
|
|
2533
|
+
task.latest_validation_run,
|
|
2534
|
+
expected_type="validation",
|
|
2535
|
+
)
|
|
2536
|
+
normalized_result = normalize_validation_result(result)
|
|
2537
|
+
if normalized_result == "passed":
|
|
2538
|
+
_ensure_validation_can_pass(workspace_root, task, run)
|
|
2539
|
+
target_stage: TaskStatusStage = (
|
|
2540
|
+
"done" if normalized_result == "passed" else "failed_validation"
|
|
2541
|
+
)
|
|
2542
|
+
if normalized_result == "passed":
|
|
2543
|
+
run_status = "finished"
|
|
2544
|
+
elif normalized_result == "blocked":
|
|
2545
|
+
run_status = "blocked"
|
|
2546
|
+
else:
|
|
2547
|
+
run_status = "failed"
|
|
2548
|
+
finished = replace(
|
|
2549
|
+
run,
|
|
2550
|
+
status=cast(
|
|
2551
|
+
Literal[
|
|
2552
|
+
"running",
|
|
2553
|
+
"paused",
|
|
2554
|
+
"finished",
|
|
2555
|
+
"passed",
|
|
2556
|
+
"failed",
|
|
2557
|
+
"blocked",
|
|
2558
|
+
"aborted",
|
|
2559
|
+
],
|
|
2560
|
+
run_status,
|
|
2561
|
+
),
|
|
2562
|
+
finished_at=utc_now_iso(),
|
|
2563
|
+
summary=summary.strip(),
|
|
2564
|
+
recommendation=recommendation,
|
|
2565
|
+
result=normalized_result,
|
|
2566
|
+
)
|
|
2567
|
+
save_run(workspace_root, finished)
|
|
2568
|
+
updated = replace(task, status_stage=target_stage, updated_at=utc_now_iso())
|
|
2569
|
+
save_task(workspace_root, updated)
|
|
2570
|
+
_release_lock(
|
|
2571
|
+
workspace_root,
|
|
2572
|
+
task=updated,
|
|
2573
|
+
expected_stage="validating",
|
|
2574
|
+
run_id=run.run_id,
|
|
2575
|
+
target_stage=target_stage,
|
|
2576
|
+
event_name="stage.completed"
|
|
2577
|
+
if normalized_result == "passed"
|
|
2578
|
+
else "stage.failed",
|
|
2579
|
+
extra_data={"result": normalized_result},
|
|
2580
|
+
)
|
|
2581
|
+
_append_event(
|
|
2582
|
+
resolve_v2_paths(workspace_root).project_dir,
|
|
2583
|
+
updated.id,
|
|
2584
|
+
"validation.finished",
|
|
2585
|
+
{"run_id": run.run_id, "result": normalized_result},
|
|
2586
|
+
)
|
|
2587
|
+
rebuild_v2_indexes(resolve_v2_paths(workspace_root))
|
|
2588
|
+
return _lifecycle_payload(
|
|
2589
|
+
"validate finish",
|
|
2590
|
+
updated,
|
|
2591
|
+
warnings=[],
|
|
2592
|
+
changed=True,
|
|
2593
|
+
run=finished,
|
|
2594
|
+
result=normalized_result,
|
|
2595
|
+
)
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
def show_task_run(
|
|
2599
|
+
workspace_root: Path,
|
|
2600
|
+
task_ref: str,
|
|
2601
|
+
*,
|
|
2602
|
+
run_id: str | None = None,
|
|
2603
|
+
run_type: str,
|
|
2604
|
+
) -> dict[str, object]:
|
|
2605
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2606
|
+
selected_run_id = run_id
|
|
2607
|
+
if selected_run_id is None:
|
|
2608
|
+
if run_type == "implementation":
|
|
2609
|
+
selected_run_id = task.latest_implementation_run
|
|
2610
|
+
elif run_type == "validation":
|
|
2611
|
+
selected_run_id = task.latest_validation_run
|
|
2612
|
+
else:
|
|
2613
|
+
selected_run_id = task.latest_planning_run
|
|
2614
|
+
run = _require_run(workspace_root, task, selected_run_id)
|
|
2615
|
+
if run.run_type != run_type:
|
|
2616
|
+
raise _cli_error(
|
|
2617
|
+
f"Run {run.run_id} is {run.run_type}, not {run_type}.",
|
|
2618
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
2619
|
+
)
|
|
2620
|
+
return {"kind": "task_run", "task_id": task.id, "run": run.to_dict()}
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def show_lock(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
2624
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2625
|
+
lock = _current_lock(workspace_root, task.id)
|
|
2626
|
+
return {
|
|
2627
|
+
"kind": "task_lock",
|
|
2628
|
+
"task_id": task.id,
|
|
2629
|
+
"lock": lock.to_dict() if lock is not None else None,
|
|
2630
|
+
"status": lock_status(lock),
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def break_lock(
|
|
2635
|
+
workspace_root: Path,
|
|
2636
|
+
task_ref: str,
|
|
2637
|
+
*,
|
|
2638
|
+
reason: str,
|
|
2639
|
+
) -> dict[str, object]:
|
|
2640
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2641
|
+
paths = resolve_v2_paths(workspace_root)
|
|
2642
|
+
lock_path = task_lock_path(paths, task.id)
|
|
2643
|
+
lock = read_lock(lock_path)
|
|
2644
|
+
if lock is None:
|
|
2645
|
+
raise _cli_error(
|
|
2646
|
+
"No active lock exists for the task. "
|
|
2647
|
+
"This is normal after plan propose, implement finish, or validate finish. "
|
|
2648
|
+
"Run `taskledger next-action` to see what to do next.",
|
|
2649
|
+
EXIT_CODE_MISSING,
|
|
2650
|
+
)
|
|
2651
|
+
broken_lock = replace(
|
|
2652
|
+
lock,
|
|
2653
|
+
broken_at=utc_now_iso(),
|
|
2654
|
+
broken_by=_default_actor(),
|
|
2655
|
+
broken_reason=reason.strip(),
|
|
2656
|
+
)
|
|
2657
|
+
audit_path = _write_broken_lock_audit(paths, task.id, broken_lock)
|
|
2658
|
+
_append_event(
|
|
2659
|
+
paths.project_dir,
|
|
2660
|
+
task.id,
|
|
2661
|
+
"lock.broken",
|
|
2662
|
+
{
|
|
2663
|
+
"lock_id": lock.lock_id,
|
|
2664
|
+
"reason": reason,
|
|
2665
|
+
"audit_path": str(audit_path.relative_to(paths.project_dir)),
|
|
2666
|
+
},
|
|
2667
|
+
)
|
|
2668
|
+
_append_event(
|
|
2669
|
+
paths.project_dir,
|
|
2670
|
+
task.id,
|
|
2671
|
+
"repair.lock_broken",
|
|
2672
|
+
{
|
|
2673
|
+
"lock_id": lock.lock_id,
|
|
2674
|
+
"reason": reason,
|
|
2675
|
+
"audit_path": str(audit_path.relative_to(paths.project_dir)),
|
|
2676
|
+
},
|
|
2677
|
+
)
|
|
2678
|
+
remove_lock(lock_path)
|
|
2679
|
+
rebuild_v2_indexes(paths)
|
|
2680
|
+
return {
|
|
2681
|
+
"ok": True,
|
|
2682
|
+
"command": "lock break",
|
|
2683
|
+
"task_id": task.id,
|
|
2684
|
+
"status_stage": task.status_stage,
|
|
2685
|
+
"changed": True,
|
|
2686
|
+
"warnings": [],
|
|
2687
|
+
"lock": broken_lock.to_dict(),
|
|
2688
|
+
"reason": reason,
|
|
2689
|
+
"audit_path": str(audit_path.relative_to(paths.project_dir)),
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
def list_locks(workspace_root: Path) -> dict[str, object]:
|
|
2694
|
+
locks = load_active_locks(workspace_root)
|
|
2695
|
+
return {
|
|
2696
|
+
"kind": "task_lock_list",
|
|
2697
|
+
"locks": [
|
|
2698
|
+
{
|
|
2699
|
+
**lock.to_dict(),
|
|
2700
|
+
"status": lock_status(lock),
|
|
2701
|
+
}
|
|
2702
|
+
for lock in locks
|
|
2703
|
+
],
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
|
|
2707
|
+
def next_action(workspace_root: Path, task_ref: str) -> dict[str, object]:
|
|
2708
|
+
from taskledger.services.navigation import next_action as navigation_next_action
|
|
2709
|
+
|
|
2710
|
+
return navigation_next_action(workspace_root, task_ref)
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
def can_perform(workspace_root: Path, task_ref: str, action: str) -> dict[str, object]:
|
|
2714
|
+
from taskledger.services.navigation import can_perform as navigation_can_perform
|
|
2715
|
+
|
|
2716
|
+
return navigation_can_perform(workspace_root, task_ref, action)
|
|
2717
|
+
|
|
2718
|
+
|
|
2719
|
+
def task_dossier(
|
|
2720
|
+
workspace_root: Path,
|
|
2721
|
+
task_ref: str,
|
|
2722
|
+
*,
|
|
2723
|
+
format_name: str = "markdown",
|
|
2724
|
+
) -> str | dict[str, object]:
|
|
2725
|
+
from taskledger.services.navigation import task_dossier as navigation_task_dossier
|
|
2726
|
+
|
|
2727
|
+
return navigation_task_dossier(
|
|
2728
|
+
workspace_root,
|
|
2729
|
+
task_ref,
|
|
2730
|
+
format_name=format_name,
|
|
2731
|
+
)
|
|
2732
|
+
|
|
2733
|
+
|
|
2734
|
+
def reindex(workspace_root: Path) -> dict[str, object]:
|
|
2735
|
+
paths = ensure_v2_layout(workspace_root)
|
|
2736
|
+
counts = rebuild_v2_indexes(paths)
|
|
2737
|
+
_append_event(
|
|
2738
|
+
paths.project_dir, "*", "repair.index", dict(cast(dict[str, object], counts))
|
|
2739
|
+
)
|
|
2740
|
+
return {"kind": "taskledger_reindex", "counts": counts}
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def repair_task_record(
|
|
2744
|
+
workspace_root: Path,
|
|
2745
|
+
task_ref: str,
|
|
2746
|
+
*,
|
|
2747
|
+
reason: str,
|
|
2748
|
+
) -> dict[str, object]:
|
|
2749
|
+
if not reason.strip():
|
|
2750
|
+
raise _cli_error("Task repair requires --reason.", EXIT_CODE_BAD_INPUT)
|
|
2751
|
+
task = resolve_task(workspace_root, task_ref)
|
|
2752
|
+
paths = resolve_v2_paths(workspace_root)
|
|
2753
|
+
_append_event(
|
|
2754
|
+
paths.project_dir,
|
|
2755
|
+
task.id,
|
|
2756
|
+
"repair.task",
|
|
2757
|
+
{"reason": reason.strip()},
|
|
2758
|
+
)
|
|
2759
|
+
return {
|
|
2760
|
+
"kind": "task_repair",
|
|
2761
|
+
"task_id": task.id,
|
|
2762
|
+
"changed": False,
|
|
2763
|
+
"reason": reason.strip(),
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
|
|
2767
|
+
def list_events(workspace_root: Path) -> list[dict[str, object]]:
|
|
2768
|
+
events_dir = resolve_v2_paths(workspace_root).events_dir
|
|
2769
|
+
return [item.to_dict() for item in load_events(events_dir)]
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
def _start_run(
|
|
2773
|
+
workspace_root: Path,
|
|
2774
|
+
task: TaskRecord,
|
|
2775
|
+
*,
|
|
2776
|
+
run_type: str,
|
|
2777
|
+
stage: str,
|
|
2778
|
+
actor: ActorRef | None = None,
|
|
2779
|
+
harness: HarnessRef | None = None,
|
|
2780
|
+
) -> TaskRunRecord:
|
|
2781
|
+
existing_lock = _current_lock(workspace_root, task.id)
|
|
2782
|
+
if existing_lock is not None:
|
|
2783
|
+
if lock_is_expired(existing_lock):
|
|
2784
|
+
raise _stale_lock_error(task.id, existing_lock)
|
|
2785
|
+
raise _cli_error(
|
|
2786
|
+
_lock_conflict_message(task.id, existing_lock),
|
|
2787
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
2788
|
+
)
|
|
2789
|
+
running_runs = [
|
|
2790
|
+
item for item in list_runs(workspace_root, task.id) if item.status == "running"
|
|
2791
|
+
]
|
|
2792
|
+
if running_runs:
|
|
2793
|
+
raise _cli_error(
|
|
2794
|
+
f"Task {task.id} already has a running {running_runs[0].run_type} run.",
|
|
2795
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
2796
|
+
)
|
|
2797
|
+
resolved_actor = actor or _default_actor()
|
|
2798
|
+
run = TaskRunRecord(
|
|
2799
|
+
run_id=next_project_id(
|
|
2800
|
+
"run",
|
|
2801
|
+
[item.run_id for item in list_runs(workspace_root, task.id)],
|
|
2802
|
+
),
|
|
2803
|
+
task_id=task.id,
|
|
2804
|
+
run_type=normalize_run_type(run_type),
|
|
2805
|
+
actor=resolved_actor,
|
|
2806
|
+
harness=harness,
|
|
2807
|
+
based_on_plan_version=task.accepted_plan_version or task.latest_plan_version,
|
|
2808
|
+
)
|
|
2809
|
+
save_run(workspace_root, run)
|
|
2810
|
+
_acquire_lock(
|
|
2811
|
+
workspace_root,
|
|
2812
|
+
task=task,
|
|
2813
|
+
stage=stage,
|
|
2814
|
+
run=run,
|
|
2815
|
+
reason={
|
|
2816
|
+
"planning": "plan task",
|
|
2817
|
+
"implementation": "implement approved plan",
|
|
2818
|
+
"validation": "validate implementation",
|
|
2819
|
+
}[run_type],
|
|
2820
|
+
actor=resolved_actor,
|
|
2821
|
+
harness=harness,
|
|
2822
|
+
)
|
|
2823
|
+
return run
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
def _acquire_lock(
|
|
2827
|
+
workspace_root: Path,
|
|
2828
|
+
*,
|
|
2829
|
+
task: TaskRecord,
|
|
2830
|
+
stage: str,
|
|
2831
|
+
run: TaskRunRecord,
|
|
2832
|
+
reason: str,
|
|
2833
|
+
actor: ActorRef | None = None,
|
|
2834
|
+
harness: HarnessRef | None = None,
|
|
2835
|
+
) -> TaskLock:
|
|
2836
|
+
if stage not in ACTIVE_TASK_STAGES:
|
|
2837
|
+
raise _cli_error("Only active stages can acquire locks.", EXIT_CODE_BAD_INPUT)
|
|
2838
|
+
paths = resolve_v2_paths(workspace_root)
|
|
2839
|
+
lock_path = task_lock_path(paths, task.id)
|
|
2840
|
+
existing = read_lock(lock_path)
|
|
2841
|
+
if existing is not None:
|
|
2842
|
+
if lock_is_expired(existing):
|
|
2843
|
+
raise _stale_lock_error(task.id, existing)
|
|
2844
|
+
if existing.run_id == run.run_id:
|
|
2845
|
+
return existing
|
|
2846
|
+
raise _cli_error(
|
|
2847
|
+
_lock_conflict_message(task.id, existing), EXIT_CODE_LOCK_CONFLICT
|
|
2848
|
+
)
|
|
2849
|
+
now = datetime.now(timezone.utc)
|
|
2850
|
+
resolved_actor = actor or _default_actor()
|
|
2851
|
+
lock = TaskLock(
|
|
2852
|
+
lock_id=_next_lock_id(workspace_root, now),
|
|
2853
|
+
task_id=task.id,
|
|
2854
|
+
stage=cast(Literal["planning", "implementing", "validating"], stage),
|
|
2855
|
+
run_id=run.run_id,
|
|
2856
|
+
created_at=now.isoformat(),
|
|
2857
|
+
expires_at=(now + timedelta(hours=2)).isoformat(),
|
|
2858
|
+
lease_seconds=7200,
|
|
2859
|
+
last_heartbeat_at=now.isoformat(),
|
|
2860
|
+
reason=reason,
|
|
2861
|
+
holder=resolved_actor,
|
|
2862
|
+
actor=resolved_actor,
|
|
2863
|
+
harness=harness,
|
|
2864
|
+
)
|
|
2865
|
+
try:
|
|
2866
|
+
write_lock(lock_path, lock)
|
|
2867
|
+
except LaunchError as exc:
|
|
2868
|
+
raise _cli_error(
|
|
2869
|
+
_lock_conflict_message(task.id, read_lock(lock_path) or lock),
|
|
2870
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
2871
|
+
) from exc
|
|
2872
|
+
_append_event(paths.project_dir, task.id, "lock.acquired", lock.to_dict())
|
|
2873
|
+
_append_event(
|
|
2874
|
+
paths.project_dir,
|
|
2875
|
+
task.id,
|
|
2876
|
+
"stage.entered",
|
|
2877
|
+
{"stage": stage, "run_id": run.run_id},
|
|
2878
|
+
)
|
|
2879
|
+
return lock
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
|
+
def _release_lock(
|
|
2883
|
+
workspace_root: Path,
|
|
2884
|
+
*,
|
|
2885
|
+
task: TaskRecord,
|
|
2886
|
+
expected_stage: str,
|
|
2887
|
+
run_id: str,
|
|
2888
|
+
target_stage: TaskStatusStage,
|
|
2889
|
+
event_name: str,
|
|
2890
|
+
extra_data: dict[str, object] | None = None,
|
|
2891
|
+
delete_only: bool = False,
|
|
2892
|
+
) -> None:
|
|
2893
|
+
paths = resolve_v2_paths(workspace_root)
|
|
2894
|
+
lock_path = task_lock_path(paths, task.id)
|
|
2895
|
+
lock = read_lock(lock_path)
|
|
2896
|
+
if lock is None:
|
|
2897
|
+
raise _cli_error(
|
|
2898
|
+
f"Task {task.id} has no active {expected_stage} lock to release.",
|
|
2899
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
2900
|
+
)
|
|
2901
|
+
if lock.stage != expected_stage or lock.run_id != run_id:
|
|
2902
|
+
raise _cli_error(
|
|
2903
|
+
"Active lock does not match the expected stage/run.",
|
|
2904
|
+
EXIT_CODE_LOCK_CONFLICT,
|
|
2905
|
+
)
|
|
2906
|
+
data = {"stage": expected_stage, "run_id": run_id, **(extra_data or {})}
|
|
2907
|
+
_append_event(paths.project_dir, task.id, event_name, data)
|
|
2908
|
+
remove_lock(lock_path)
|
|
2909
|
+
_append_event(
|
|
2910
|
+
paths.project_dir,
|
|
2911
|
+
task.id,
|
|
2912
|
+
"lock.released",
|
|
2913
|
+
{"lock_id": lock.lock_id, "stage": expected_stage},
|
|
2914
|
+
)
|
|
2915
|
+
if delete_only:
|
|
2916
|
+
return
|
|
2917
|
+
save_task(
|
|
2918
|
+
workspace_root,
|
|
2919
|
+
replace(task, status_stage=target_stage, updated_at=utc_now_iso()),
|
|
2920
|
+
)
|
|
2921
|
+
|
|
2922
|
+
|
|
2923
|
+
def _ensure_dependencies_done(workspace_root: Path, task: TaskRecord) -> None:
|
|
2924
|
+
blocked = []
|
|
2925
|
+
for requirement in load_requirements(workspace_root, task.id).requirements:
|
|
2926
|
+
if _has_user_waiver(requirement.waiver):
|
|
2927
|
+
continue
|
|
2928
|
+
required = resolve_task(workspace_root, requirement.task_id)
|
|
2929
|
+
if required.status_stage != "done":
|
|
2930
|
+
blocked.append(required.id)
|
|
2931
|
+
if blocked:
|
|
2932
|
+
raise _cli_error(
|
|
2933
|
+
"Implementation is blocked by incomplete requirements: "
|
|
2934
|
+
+ ", ".join(blocked),
|
|
2935
|
+
EXIT_CODE_DEPENDENCY_BLOCKED,
|
|
2936
|
+
)
|
|
2937
|
+
|
|
2938
|
+
|
|
2939
|
+
def _require_lock(workspace_root: Path, task_id: str) -> TaskLock:
|
|
2940
|
+
lock = _current_lock(workspace_root, task_id)
|
|
2941
|
+
if lock is None:
|
|
2942
|
+
raise _cli_error("No active lock found.", EXIT_CODE_LOCK_CONFLICT)
|
|
2943
|
+
if lock_is_expired(lock):
|
|
2944
|
+
raise _stale_lock_error(task_id, lock)
|
|
2945
|
+
return lock
|
|
2946
|
+
|
|
2947
|
+
|
|
2948
|
+
def _require_run(
|
|
2949
|
+
workspace_root: Path,
|
|
2950
|
+
task: TaskRecord,
|
|
2951
|
+
run_id: str | None,
|
|
2952
|
+
) -> TaskRunRecord:
|
|
2953
|
+
if run_id is None:
|
|
2954
|
+
raise _cli_error("No active run is recorded for the task.", EXIT_CODE_MISSING)
|
|
2955
|
+
return resolve_run(workspace_root, task.id, run_id)
|
|
2956
|
+
|
|
2957
|
+
|
|
2958
|
+
def _optional_run(
|
|
2959
|
+
workspace_root: Path,
|
|
2960
|
+
task: TaskRecord,
|
|
2961
|
+
run_id: str | None,
|
|
2962
|
+
) -> TaskRunRecord | None:
|
|
2963
|
+
if run_id is None:
|
|
2964
|
+
return None
|
|
2965
|
+
try:
|
|
2966
|
+
return resolve_run(workspace_root, task.id, run_id)
|
|
2967
|
+
except LaunchError:
|
|
2968
|
+
return None
|
|
2969
|
+
|
|
2970
|
+
|
|
2971
|
+
def _require_running_run(
|
|
2972
|
+
workspace_root: Path,
|
|
2973
|
+
task: TaskRecord,
|
|
2974
|
+
run_id: str | None,
|
|
2975
|
+
*,
|
|
2976
|
+
expected_type: str,
|
|
2977
|
+
) -> TaskRunRecord:
|
|
2978
|
+
run = _require_run(workspace_root, task, run_id)
|
|
2979
|
+
if run.run_type != expected_type or run.status != "running":
|
|
2980
|
+
raise _cli_error(
|
|
2981
|
+
f"Task does not have a running {expected_type} run.",
|
|
2982
|
+
EXIT_CODE_INVALID_TRANSITION,
|
|
2983
|
+
)
|
|
2984
|
+
return run
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
def _current_lock(workspace_root: Path, task_id: str) -> TaskLock | None:
|
|
2988
|
+
return read_lock(task_lock_path(resolve_v2_paths(workspace_root), task_id))
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _lock_for_mutation(workspace_root: Path, task_id: str) -> TaskLock | None:
|
|
2992
|
+
lock = _current_lock(workspace_root, task_id)
|
|
2993
|
+
if lock is not None and lock_is_expired(lock):
|
|
2994
|
+
raise _stale_lock_error(task_id, lock)
|
|
2995
|
+
return lock
|
|
2996
|
+
|
|
2997
|
+
|
|
2998
|
+
def _dependency_blockers(
|
|
2999
|
+
workspace_root: Path, task: TaskRecord
|
|
3000
|
+
) -> list[dict[str, str]]:
|
|
3001
|
+
blockers: list[dict[str, str]] = []
|
|
3002
|
+
for requirement in load_requirements(workspace_root, task.id).requirements:
|
|
3003
|
+
if _has_user_waiver(requirement.waiver):
|
|
3004
|
+
continue
|
|
3005
|
+
required = resolve_task(workspace_root, requirement.task_id)
|
|
3006
|
+
if required.status_stage != "done":
|
|
3007
|
+
blockers.append(
|
|
3008
|
+
{
|
|
3009
|
+
"kind": "dependency",
|
|
3010
|
+
"message": (
|
|
3011
|
+
f"Requirement {required.id} is still {required.status_stage}."
|
|
3012
|
+
),
|
|
3013
|
+
}
|
|
3014
|
+
)
|
|
3015
|
+
return blockers
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
def _lock_conflict_message(task_id: str, lock: TaskLock) -> str:
|
|
3019
|
+
if lock_is_expired(lock):
|
|
3020
|
+
return (
|
|
3021
|
+
f"Task {task_id} has an expired {lock.stage} lock from {lock.run_id}. "
|
|
3022
|
+
"Break it explicitly with: "
|
|
3023
|
+
f'taskledger lock break --task {task_id} --reason "..."'
|
|
3024
|
+
)
|
|
3025
|
+
return f"Task {task_id} is locked by {lock.run_id} for {lock.stage}."
|
|
3026
|
+
|
|
3027
|
+
|
|
3028
|
+
def _enforce_decision(decision: Decision) -> None:
|
|
3029
|
+
if decision.ok:
|
|
3030
|
+
return
|
|
3031
|
+
raise _cli_error(decision.reason, decision.exit_code)
|
|
3032
|
+
|
|
3033
|
+
|
|
3034
|
+
def _default_actor() -> ActorRef:
|
|
3035
|
+
return ActorRef(
|
|
3036
|
+
actor_type="agent",
|
|
3037
|
+
actor_name=getpass.getuser() or "taskledger",
|
|
3038
|
+
host=socket.gethostname(),
|
|
3039
|
+
pid=os.getpid(),
|
|
3040
|
+
)
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
def _default_harness() -> HarnessRef:
|
|
3044
|
+
return HarnessRef(
|
|
3045
|
+
harness_id="harness-unknown",
|
|
3046
|
+
name=os.getenv("TASKLEDGER_HARNESS") or "unknown",
|
|
3047
|
+
kind="unknown",
|
|
3048
|
+
session_id=os.getenv("TASKLEDGER_SESSION_ID"),
|
|
3049
|
+
working_directory=os.getcwd(),
|
|
3050
|
+
)
|
|
3051
|
+
|
|
3052
|
+
|
|
3053
|
+
def _append_event(
|
|
3054
|
+
project_dir: Path,
|
|
3055
|
+
task_id: str,
|
|
3056
|
+
event_name: str,
|
|
3057
|
+
data: dict[str, object],
|
|
3058
|
+
) -> None:
|
|
3059
|
+
timestamp = utc_now_iso()
|
|
3060
|
+
append_event(
|
|
3061
|
+
project_dir / "events",
|
|
3062
|
+
TaskEvent(
|
|
3063
|
+
ts=timestamp,
|
|
3064
|
+
event=event_name,
|
|
3065
|
+
task_id=task_id,
|
|
3066
|
+
actor=_default_actor(),
|
|
3067
|
+
harness=_default_harness(),
|
|
3068
|
+
event_id=next_event_id(project_dir / "events", timestamp),
|
|
3069
|
+
data=data,
|
|
3070
|
+
),
|
|
3071
|
+
)
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
def _summary_line(text: str | None) -> str | None:
|
|
3075
|
+
if text is None:
|
|
3076
|
+
return None
|
|
3077
|
+
stripped = " ".join(text.split())
|
|
3078
|
+
return stripped[:117] + "..." if len(stripped) > 120 else stripped
|
|
3079
|
+
|
|
3080
|
+
|
|
3081
|
+
def _git_change_state(workspace_root: Path) -> dict[str, str]:
|
|
3082
|
+
inside = _run_command(
|
|
3083
|
+
workspace_root,
|
|
3084
|
+
("git", "rev-parse", "--is-inside-work-tree"),
|
|
3085
|
+
not_git_message="Git change scan requires a Git work tree.",
|
|
3086
|
+
)
|
|
3087
|
+
if inside.strip() != "true":
|
|
3088
|
+
raise _cli_error(
|
|
3089
|
+
"Git change scan requires a Git work tree.", EXIT_CODE_BAD_INPUT
|
|
3090
|
+
)
|
|
3091
|
+
branch = _run_command(workspace_root, ("git", "branch", "--show-current")).strip()
|
|
3092
|
+
status = _run_command(workspace_root, ("git", "status", "--short")).strip()
|
|
3093
|
+
diff_stat = _run_command(workspace_root, ("git", "diff", "--stat")).strip()
|
|
3094
|
+
return {
|
|
3095
|
+
"branch": branch or "(detached)",
|
|
3096
|
+
"status": status,
|
|
3097
|
+
"diff_stat": diff_stat,
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
|
|
3101
|
+
def _run_command(
|
|
3102
|
+
workspace_root: Path,
|
|
3103
|
+
argv: tuple[str, ...],
|
|
3104
|
+
*,
|
|
3105
|
+
not_git_message: str | None = None,
|
|
3106
|
+
) -> str:
|
|
3107
|
+
completed = subprocess.run(
|
|
3108
|
+
list(argv),
|
|
3109
|
+
cwd=workspace_root,
|
|
3110
|
+
capture_output=True,
|
|
3111
|
+
text=True,
|
|
3112
|
+
check=False,
|
|
3113
|
+
)
|
|
3114
|
+
if completed.returncode == 0:
|
|
3115
|
+
return completed.stdout
|
|
3116
|
+
if not_git_message and "not a git repository" in completed.stderr.lower():
|
|
3117
|
+
raise _cli_error(not_git_message, EXIT_CODE_BAD_INPUT)
|
|
3118
|
+
raise _cli_error(
|
|
3119
|
+
completed.stderr.strip() or f"Command failed: {' '.join(argv)}",
|
|
3120
|
+
EXIT_CODE_GENERIC_FAILURE,
|
|
3121
|
+
)
|
|
3122
|
+
|
|
3123
|
+
|
|
3124
|
+
def _command_output(
|
|
3125
|
+
argv: tuple[str, ...],
|
|
3126
|
+
stdout: str,
|
|
3127
|
+
stderr: str,
|
|
3128
|
+
) -> str:
|
|
3129
|
+
return (
|
|
3130
|
+
f"$ {shlex.join(argv)}\n\n"
|
|
3131
|
+
f"stdout:\n{stdout or '(empty)'}\n\n"
|
|
3132
|
+
f"stderr:\n{stderr or '(empty)'}\n"
|
|
3133
|
+
)
|
|
3134
|
+
|
|
3135
|
+
|
|
3136
|
+
def _command_summary(
|
|
3137
|
+
argv: tuple[str, ...],
|
|
3138
|
+
exit_code: int,
|
|
3139
|
+
artifact_ref: str | None,
|
|
3140
|
+
) -> str:
|
|
3141
|
+
summary = f"Ran {shlex.join(argv)} (exit {exit_code})"
|
|
3142
|
+
if artifact_ref is not None:
|
|
3143
|
+
summary += f" output: @{artifact_ref}"
|
|
3144
|
+
return summary
|
|
3145
|
+
|
|
3146
|
+
|
|
3147
|
+
def _write_command_artifact(
|
|
3148
|
+
workspace_root: Path,
|
|
3149
|
+
task_id: str,
|
|
3150
|
+
run_id: str,
|
|
3151
|
+
output: str,
|
|
3152
|
+
) -> str:
|
|
3153
|
+
paths = resolve_v2_paths(workspace_root)
|
|
3154
|
+
artifact_dir = task_artifacts_dir(paths, task_id)
|
|
3155
|
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
3156
|
+
index = len(list(artifact_dir.glob(f"{run_id}-command-*.log"))) + 1
|
|
3157
|
+
artifact_path = artifact_dir / f"{run_id}-command-{index:04d}.log"
|
|
3158
|
+
atomic_write_text(artifact_path, output)
|
|
3159
|
+
return str(artifact_path.relative_to(paths.project_dir))
|
|
3160
|
+
|
|
3161
|
+
|
|
3162
|
+
def _parse_plan_front_matter(body: str) -> tuple[dict[str, object], str]:
|
|
3163
|
+
lines = body.splitlines()
|
|
3164
|
+
if not lines or lines[0].strip() != "---":
|
|
3165
|
+
return {}, body
|
|
3166
|
+
for index in range(1, len(lines)):
|
|
3167
|
+
if lines[index].strip() != "---":
|
|
3168
|
+
continue
|
|
3169
|
+
front_matter = yaml.safe_load("\n".join(lines[1:index])) or {}
|
|
3170
|
+
if not isinstance(front_matter, dict):
|
|
3171
|
+
raise _cli_error(
|
|
3172
|
+
"Plan front matter must be a YAML mapping.",
|
|
3173
|
+
EXIT_CODE_BAD_INPUT,
|
|
3174
|
+
)
|
|
3175
|
+
return front_matter, "\n".join(lines[index + 1 :])
|
|
3176
|
+
raise _cli_error("Unterminated plan front matter.", EXIT_CODE_BAD_INPUT)
|
|
3177
|
+
|
|
3178
|
+
|
|
3179
|
+
def _criteria_from_plan_input(
|
|
3180
|
+
front_matter: dict[str, object],
|
|
3181
|
+
criteria: tuple[str, ...],
|
|
3182
|
+
) -> tuple[AcceptanceCriterion, ...]:
|
|
3183
|
+
raw_criteria = front_matter.get("acceptance_criteria", front_matter.get("criteria"))
|
|
3184
|
+
items: list[AcceptanceCriterion] = []
|
|
3185
|
+
if raw_criteria is not None:
|
|
3186
|
+
if not isinstance(raw_criteria, list):
|
|
3187
|
+
raise _cli_error(
|
|
3188
|
+
"Plan criteria front matter must be a list.",
|
|
3189
|
+
EXIT_CODE_BAD_INPUT,
|
|
3190
|
+
)
|
|
3191
|
+
for index, item in enumerate(raw_criteria, start=1):
|
|
3192
|
+
if isinstance(item, str):
|
|
3193
|
+
text = item.strip()
|
|
3194
|
+
if not text:
|
|
3195
|
+
continue
|
|
3196
|
+
items.append(AcceptanceCriterion(id=_criterion_id(index), text=text))
|
|
3197
|
+
continue
|
|
3198
|
+
if not isinstance(item, dict):
|
|
3199
|
+
raise _cli_error(
|
|
3200
|
+
"Plan criteria must be strings or mappings.",
|
|
3201
|
+
EXIT_CODE_BAD_INPUT,
|
|
3202
|
+
)
|
|
3203
|
+
text = str(item.get("text") or "").strip()
|
|
3204
|
+
if not text:
|
|
3205
|
+
# Accept single-key shorthand: {ac-0001: "some text"}
|
|
3206
|
+
if len(item) == 1:
|
|
3207
|
+
criterion_key, text_value = next(iter(item.items()))
|
|
3208
|
+
text = str(text_value).strip()
|
|
3209
|
+
if not text:
|
|
3210
|
+
raise _cli_error(
|
|
3211
|
+
"Plan criteria mappings must include non-empty text.",
|
|
3212
|
+
EXIT_CODE_BAD_INPUT,
|
|
3213
|
+
)
|
|
3214
|
+
items.append(
|
|
3215
|
+
AcceptanceCriterion(
|
|
3216
|
+
id=str(criterion_key).strip(),
|
|
3217
|
+
text=text,
|
|
3218
|
+
mandatory=True,
|
|
3219
|
+
)
|
|
3220
|
+
)
|
|
3221
|
+
continue
|
|
3222
|
+
raise _cli_error(
|
|
3223
|
+
"Plan criteria mappings must include text.",
|
|
3224
|
+
EXIT_CODE_BAD_INPUT,
|
|
3225
|
+
)
|
|
3226
|
+
criterion_id = str(item.get("id") or _criterion_id(index)).strip()
|
|
3227
|
+
items.append(
|
|
3228
|
+
AcceptanceCriterion(
|
|
3229
|
+
id=criterion_id,
|
|
3230
|
+
text=text,
|
|
3231
|
+
mandatory=bool(item.get("mandatory", True)),
|
|
3232
|
+
)
|
|
3233
|
+
)
|
|
3234
|
+
else:
|
|
3235
|
+
for index, item in enumerate(criteria, start=1):
|
|
3236
|
+
text = item.strip()
|
|
3237
|
+
if text:
|
|
3238
|
+
items.append(AcceptanceCriterion(id=_criterion_id(index), text=text))
|
|
3239
|
+
ids = [item.id for item in items]
|
|
3240
|
+
if len(ids) != len(set(ids)):
|
|
3241
|
+
raise _cli_error("Plan criteria ids must be unique.", EXIT_CODE_BAD_INPUT)
|
|
3242
|
+
return tuple(items)
|
|
3243
|
+
|
|
3244
|
+
|
|
3245
|
+
def _todos_from_plan_input(front_matter: dict[str, object]) -> tuple[TaskTodo, ...]:
|
|
3246
|
+
raw_todos = front_matter.get("todos")
|
|
3247
|
+
if raw_todos is None:
|
|
3248
|
+
return ()
|
|
3249
|
+
if not isinstance(raw_todos, list):
|
|
3250
|
+
raise _cli_error("Plan todos front matter must be a list.", EXIT_CODE_BAD_INPUT)
|
|
3251
|
+
items: list[TaskTodo] = []
|
|
3252
|
+
for index, item in enumerate(raw_todos, start=1):
|
|
3253
|
+
if isinstance(item, str):
|
|
3254
|
+
text = item.strip()
|
|
3255
|
+
if not text:
|
|
3256
|
+
continue
|
|
3257
|
+
items.append(
|
|
3258
|
+
TaskTodo(
|
|
3259
|
+
id=f"plan-todo-{index:04d}",
|
|
3260
|
+
text=text,
|
|
3261
|
+
mandatory=True,
|
|
3262
|
+
source="plan",
|
|
3263
|
+
)
|
|
3264
|
+
)
|
|
3265
|
+
continue
|
|
3266
|
+
if not isinstance(item, dict):
|
|
3267
|
+
raise _cli_error(
|
|
3268
|
+
"Plan todos must be strings or mappings.",
|
|
3269
|
+
EXIT_CODE_BAD_INPUT,
|
|
3270
|
+
)
|
|
3271
|
+
text = str(item.get("text") or "").strip()
|
|
3272
|
+
if not text:
|
|
3273
|
+
raise _cli_error(
|
|
3274
|
+
"Plan todo mappings must include text.",
|
|
3275
|
+
EXIT_CODE_BAD_INPUT,
|
|
3276
|
+
)
|
|
3277
|
+
items.append(
|
|
3278
|
+
TaskTodo(
|
|
3279
|
+
id=str(
|
|
3280
|
+
item.get("id") or item.get("id_hint") or f"plan-todo-{index:04d}"
|
|
3281
|
+
),
|
|
3282
|
+
text=text,
|
|
3283
|
+
mandatory=bool(item.get("mandatory", True)),
|
|
3284
|
+
source="plan",
|
|
3285
|
+
validation_hint=_optional_string_value(item.get("validation_hint")),
|
|
3286
|
+
)
|
|
3287
|
+
)
|
|
3288
|
+
return tuple(items)
|
|
3289
|
+
|
|
3290
|
+
|
|
3291
|
+
def _answer_snapshot_hash(questions: list[QuestionRecord]) -> str | None:
|
|
3292
|
+
answered = [
|
|
3293
|
+
f"{item.id}\0{item.answer or ''}"
|
|
3294
|
+
for item in questions
|
|
3295
|
+
if item.status == "answered"
|
|
3296
|
+
]
|
|
3297
|
+
if not answered:
|
|
3298
|
+
return None
|
|
3299
|
+
digest = hashlib.sha256("\n".join(sorted(answered)).encode("utf-8")).hexdigest()
|
|
3300
|
+
return f"sha256:{digest}"
|
|
3301
|
+
|
|
3302
|
+
|
|
3303
|
+
def _required_open_question_ids(questions: list[QuestionRecord]) -> list[str]:
|
|
3304
|
+
return [
|
|
3305
|
+
item.id
|
|
3306
|
+
for item in questions
|
|
3307
|
+
if item.status == "open" and item.required_for_plan
|
|
3308
|
+
]
|
|
3309
|
+
|
|
3310
|
+
|
|
3311
|
+
def _latest_plan_or_none(workspace_root: Path, task_id: str) -> PlanRecord | None:
|
|
3312
|
+
plans = list_plans(workspace_root, task_id)
|
|
3313
|
+
return plans[-1] if plans else None
|
|
3314
|
+
|
|
3315
|
+
|
|
3316
|
+
def _stale_answer_question_ids(
|
|
3317
|
+
questions: list[QuestionRecord],
|
|
3318
|
+
plan: PlanRecord,
|
|
3319
|
+
) -> list[str]:
|
|
3320
|
+
answered = [
|
|
3321
|
+
item
|
|
3322
|
+
for item in questions
|
|
3323
|
+
if item.status == "answered" and item.required_for_plan
|
|
3324
|
+
]
|
|
3325
|
+
if not answered:
|
|
3326
|
+
return []
|
|
3327
|
+
current_hash = _answer_snapshot_hash(questions)
|
|
3328
|
+
if (
|
|
3329
|
+
plan.generation_reason == "after_questions"
|
|
3330
|
+
and plan.based_on_answer_hash == current_hash
|
|
3331
|
+
):
|
|
3332
|
+
return []
|
|
3333
|
+
return [item.id for item in answered]
|
|
3334
|
+
|
|
3335
|
+
|
|
3336
|
+
def _question_next_item(question: QuestionRecord) -> dict[str, object]:
|
|
3337
|
+
return {
|
|
3338
|
+
"kind": "question",
|
|
3339
|
+
"id": question.id,
|
|
3340
|
+
"text": question.question,
|
|
3341
|
+
"status": question.status,
|
|
3342
|
+
"required_for_plan": question.required_for_plan,
|
|
3343
|
+
"plan_version": question.plan_version,
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
|
|
3347
|
+
def _answered_question_next_item(question: QuestionRecord) -> dict[str, object]:
|
|
3348
|
+
return {
|
|
3349
|
+
"kind": "answered_question",
|
|
3350
|
+
"id": question.id,
|
|
3351
|
+
"text": question.question,
|
|
3352
|
+
"status": question.status,
|
|
3353
|
+
"answer": question.answer,
|
|
3354
|
+
"answered_at": question.answered_at,
|
|
3355
|
+
"required_for_plan": question.required_for_plan,
|
|
3356
|
+
"plan_version": question.plan_version,
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
|
|
3360
|
+
def _todo_next_item(todo: TaskTodo) -> dict[str, object]:
|
|
3361
|
+
return {
|
|
3362
|
+
"kind": "todo",
|
|
3363
|
+
"id": todo.id,
|
|
3364
|
+
"text": todo.text,
|
|
3365
|
+
"status": todo.status,
|
|
3366
|
+
"mandatory": todo.mandatory,
|
|
3367
|
+
"source": todo.source,
|
|
3368
|
+
"done": todo.done,
|
|
3369
|
+
"validation_hint": todo.validation_hint,
|
|
3370
|
+
"done_command_hint": _todo_done_command(todo.id),
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
|
|
3374
|
+
def _next_todo_payload(task_id: str, todo: TaskTodo) -> dict[str, object]:
|
|
3375
|
+
return {
|
|
3376
|
+
"kind": "next_todo",
|
|
3377
|
+
"task_id": task_id,
|
|
3378
|
+
"next_todo_id": todo.id,
|
|
3379
|
+
"next_todo": todo.to_dict(),
|
|
3380
|
+
"commands": _todo_command_hints(todo.id),
|
|
3381
|
+
"can_finish_implementation": False,
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
|
|
3385
|
+
def _criterion_next_item(criterion_report: Mapping[str, object]) -> dict[str, object]:
|
|
3386
|
+
return {
|
|
3387
|
+
"kind": "criterion",
|
|
3388
|
+
"id": criterion_report.get("id"),
|
|
3389
|
+
"text": criterion_report.get("text"),
|
|
3390
|
+
"mandatory": criterion_report.get("mandatory"),
|
|
3391
|
+
"latest_status": criterion_report.get("latest_status"),
|
|
3392
|
+
"satisfied": criterion_report.get("satisfied"),
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
|
|
3396
|
+
def _plan_next_item(plan: PlanRecord) -> dict[str, object]:
|
|
3397
|
+
return {
|
|
3398
|
+
"kind": "plan",
|
|
3399
|
+
"id": f"plan-v{plan.plan_version}",
|
|
3400
|
+
"version": plan.plan_version,
|
|
3401
|
+
"status": plan.status,
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
|
|
3405
|
+
def _task_next_item(task: TaskRecord) -> dict[str, object]:
|
|
3406
|
+
return {
|
|
3407
|
+
"kind": "task",
|
|
3408
|
+
"id": task.id,
|
|
3409
|
+
"status_stage": task.status_stage,
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
|
|
3413
|
+
def _lock_next_item(task: TaskRecord, lock: TaskLock) -> dict[str, object]:
|
|
3414
|
+
return {
|
|
3415
|
+
"kind": "lock",
|
|
3416
|
+
"id": lock.lock_id,
|
|
3417
|
+
"task_id": task.id,
|
|
3418
|
+
"stage": lock.stage,
|
|
3419
|
+
"run_id": lock.run_id,
|
|
3420
|
+
"expired": lock_is_expired(lock),
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
|
|
3424
|
+
def _command(
|
|
3425
|
+
kind: str,
|
|
3426
|
+
label: str,
|
|
3427
|
+
command: str,
|
|
3428
|
+
*,
|
|
3429
|
+
primary: bool = False,
|
|
3430
|
+
) -> dict[str, object]:
|
|
3431
|
+
return {
|
|
3432
|
+
"kind": kind,
|
|
3433
|
+
"label": label,
|
|
3434
|
+
"command": command,
|
|
3435
|
+
"primary": primary,
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
|
|
3439
|
+
def _todo_done_command(todo_id: str) -> str:
|
|
3440
|
+
return f'taskledger todo done {todo_id} --evidence "..."'
|
|
3441
|
+
|
|
3442
|
+
|
|
3443
|
+
def _todo_command_hints(todo_id: str) -> list[dict[str, object]]:
|
|
3444
|
+
return [
|
|
3445
|
+
_command(
|
|
3446
|
+
"inspect",
|
|
3447
|
+
"Show next todo",
|
|
3448
|
+
f"taskledger todo show {todo_id}",
|
|
3449
|
+
primary=True,
|
|
3450
|
+
),
|
|
3451
|
+
_command(
|
|
3452
|
+
"complete",
|
|
3453
|
+
"Mark todo done after evidence exists",
|
|
3454
|
+
_todo_done_command(todo_id),
|
|
3455
|
+
),
|
|
3456
|
+
]
|
|
3457
|
+
|
|
3458
|
+
|
|
3459
|
+
def _first_question_by_ids(
|
|
3460
|
+
questions: Sequence[QuestionRecord],
|
|
3461
|
+
ids: Sequence[str],
|
|
3462
|
+
) -> QuestionRecord | None:
|
|
3463
|
+
wanted = set(ids)
|
|
3464
|
+
for question in questions:
|
|
3465
|
+
if question.id in wanted:
|
|
3466
|
+
return question
|
|
3467
|
+
return None
|
|
3468
|
+
|
|
3469
|
+
|
|
3470
|
+
def _first_open_todo_from_report(
|
|
3471
|
+
workspace_root: Path,
|
|
3472
|
+
task: TaskRecord,
|
|
3473
|
+
open_ids: Sequence[str],
|
|
3474
|
+
) -> TaskTodo | None:
|
|
3475
|
+
task = _task_with_sidecars(workspace_root, task)
|
|
3476
|
+
wanted = set(open_ids)
|
|
3477
|
+
for todo in task.todos:
|
|
3478
|
+
if todo.id in wanted and todo.status == "active" and not todo.done:
|
|
3479
|
+
return todo
|
|
3480
|
+
for todo in task.todos:
|
|
3481
|
+
if todo.id in wanted and not todo.done:
|
|
3482
|
+
return todo
|
|
3483
|
+
return None
|
|
3484
|
+
|
|
3485
|
+
|
|
3486
|
+
def _criterion_report_by_id(
|
|
3487
|
+
gate_report: Mapping[str, object],
|
|
3488
|
+
criterion_id: str,
|
|
3489
|
+
) -> dict[str, object] | None:
|
|
3490
|
+
criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
|
|
3491
|
+
for criterion in criteria:
|
|
3492
|
+
if criterion.get("id") == criterion_id:
|
|
3493
|
+
return criterion
|
|
3494
|
+
return None
|
|
3495
|
+
|
|
3496
|
+
|
|
3497
|
+
def _compact_next_action_blockers(
|
|
3498
|
+
blockers: Sequence[Mapping[str, object]],
|
|
3499
|
+
) -> list[dict[str, object]]:
|
|
3500
|
+
compact: list[dict[str, object]] = []
|
|
3501
|
+
for blocker in blockers:
|
|
3502
|
+
item: dict[str, object] = {
|
|
3503
|
+
"kind": str(blocker.get("kind", "blocker")),
|
|
3504
|
+
"message": str(blocker.get("message", "Next-action blocker")),
|
|
3505
|
+
}
|
|
3506
|
+
ref = blocker.get("ref")
|
|
3507
|
+
if isinstance(ref, str) and ref:
|
|
3508
|
+
item["ref"] = ref
|
|
3509
|
+
command_hint = _optional_string_value(blocker.get("command_hint"))
|
|
3510
|
+
if command_hint is not None:
|
|
3511
|
+
item["command_hint"] = command_hint
|
|
3512
|
+
compact.append(item)
|
|
3513
|
+
return compact
|
|
3514
|
+
|
|
3515
|
+
|
|
3516
|
+
def _validation_progress(gate_report: Mapping[str, object]) -> dict[str, object]:
|
|
3517
|
+
criteria = cast(list[dict[str, object]], gate_report.get("criteria", []))
|
|
3518
|
+
satisfied = sum(1 for criterion in criteria if criterion.get("satisfied") is True)
|
|
3519
|
+
blocking_ids: list[str] = []
|
|
3520
|
+
for blocker in cast(list[dict[str, object]], gate_report.get("blockers", [])):
|
|
3521
|
+
ref = blocker.get("ref")
|
|
3522
|
+
if isinstance(ref, str) and ref and ref not in blocking_ids:
|
|
3523
|
+
blocking_ids.append(ref)
|
|
3524
|
+
return {
|
|
3525
|
+
"total": len(criteria),
|
|
3526
|
+
"satisfied": satisfied,
|
|
3527
|
+
"remaining": max(len(blocking_ids), len(criteria) - satisfied),
|
|
3528
|
+
"blocking_ids": blocking_ids,
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
|
|
3532
|
+
def _next_validation_item(
|
|
3533
|
+
workspace_root: Path,
|
|
3534
|
+
task: TaskRecord,
|
|
3535
|
+
gate_report: Mapping[str, object],
|
|
3536
|
+
blockers: Sequence[Mapping[str, object]],
|
|
3537
|
+
) -> dict[str, object] | None:
|
|
3538
|
+
priority = (
|
|
3539
|
+
"criterion_fail",
|
|
3540
|
+
"criterion_missing",
|
|
3541
|
+
"criterion_unsatisfied",
|
|
3542
|
+
"todo_open",
|
|
3543
|
+
"no_finished_implementation",
|
|
3544
|
+
"dependency_blocker",
|
|
3545
|
+
"no_accepted_plan",
|
|
3546
|
+
"plan_not_accepted",
|
|
3547
|
+
)
|
|
3548
|
+
for kind in priority:
|
|
3549
|
+
for blocker in blockers:
|
|
3550
|
+
if blocker.get("kind") != kind:
|
|
3551
|
+
continue
|
|
3552
|
+
ref = blocker.get("ref")
|
|
3553
|
+
if kind.startswith("criterion_") and isinstance(ref, str):
|
|
3554
|
+
criterion = _criterion_report_by_id(gate_report, ref)
|
|
3555
|
+
if criterion is not None:
|
|
3556
|
+
return _criterion_next_item(criterion)
|
|
3557
|
+
if kind == "todo_open" and isinstance(ref, str):
|
|
3558
|
+
todo = _first_open_todo_from_report(workspace_root, task, (ref,))
|
|
3559
|
+
if todo is not None:
|
|
3560
|
+
return _todo_next_item(todo)
|
|
3561
|
+
if kind == "dependency_blocker" and isinstance(ref, str):
|
|
3562
|
+
return {"kind": "dependency", "id": ref}
|
|
3563
|
+
if kind == "no_finished_implementation":
|
|
3564
|
+
return _task_next_item(task)
|
|
3565
|
+
if kind in {"no_accepted_plan", "plan_not_accepted"}:
|
|
3566
|
+
plan = _latest_plan_or_none(workspace_root, task.id)
|
|
3567
|
+
if plan is not None:
|
|
3568
|
+
return _plan_next_item(plan)
|
|
3569
|
+
return _task_next_item(task)
|
|
3570
|
+
|
|
3571
|
+
for criterion in cast(list[dict[str, object]], gate_report.get("criteria", [])):
|
|
3572
|
+
criterion_blockers = criterion.get("blockers")
|
|
3573
|
+
if isinstance(criterion_blockers, list) and criterion_blockers:
|
|
3574
|
+
return _criterion_next_item(criterion)
|
|
3575
|
+
return None
|
|
3576
|
+
|
|
3577
|
+
|
|
3578
|
+
def _next_action_command(action: str) -> str | None:
|
|
3579
|
+
return {
|
|
3580
|
+
"plan": "taskledger plan start",
|
|
3581
|
+
"plan-propose": "taskledger plan upsert --file plan.md",
|
|
3582
|
+
"question-answer": "taskledger question answer-many --file answers.yaml",
|
|
3583
|
+
"plan-regenerate": "taskledger plan upsert --from-answers --file plan.md",
|
|
3584
|
+
"plan-approve": "taskledger plan approve --version VERSION --actor user",
|
|
3585
|
+
"implement": "taskledger implement start",
|
|
3586
|
+
"todo-work": "taskledger implement checklist",
|
|
3587
|
+
"implement-finish": "taskledger implement finish --summary SUMMARY",
|
|
3588
|
+
"validate": "taskledger validate start",
|
|
3589
|
+
"validate-check": (
|
|
3590
|
+
"taskledger validate check --criterion CRITERION "
|
|
3591
|
+
'--status pass --evidence "..."'
|
|
3592
|
+
),
|
|
3593
|
+
"validate-finish": (
|
|
3594
|
+
"taskledger validate finish --result passed --summary SUMMARY"
|
|
3595
|
+
),
|
|
3596
|
+
"repair-lock": "taskledger lock show",
|
|
3597
|
+
}.get(action)
|
|
3598
|
+
|
|
3599
|
+
|
|
3600
|
+
def _primary_command_for_next_item(
|
|
3601
|
+
action: str,
|
|
3602
|
+
next_item: dict[str, object] | None,
|
|
3603
|
+
) -> str | None:
|
|
3604
|
+
if not next_item:
|
|
3605
|
+
return _next_action_command(action)
|
|
3606
|
+
|
|
3607
|
+
kind = next_item.get("kind")
|
|
3608
|
+
item_id = next_item.get("id")
|
|
3609
|
+
|
|
3610
|
+
if kind == "question" and isinstance(item_id, str):
|
|
3611
|
+
return f'taskledger question answer {item_id} --text "..."'
|
|
3612
|
+
if kind == "todo" and isinstance(item_id, str):
|
|
3613
|
+
return f"taskledger todo show {item_id}"
|
|
3614
|
+
if kind == "criterion" and isinstance(item_id, str):
|
|
3615
|
+
return (
|
|
3616
|
+
f"taskledger validate check --criterion {item_id} "
|
|
3617
|
+
'--status pass --evidence "..."'
|
|
3618
|
+
)
|
|
3619
|
+
if kind == "plan":
|
|
3620
|
+
version = next_item.get("version")
|
|
3621
|
+
if isinstance(version, int):
|
|
3622
|
+
return f"taskledger plan show --version {version}"
|
|
3623
|
+
if kind == "lock":
|
|
3624
|
+
task_id = next_item.get("task_id")
|
|
3625
|
+
if isinstance(task_id, str):
|
|
3626
|
+
return f'taskledger lock break --task {task_id} --reason "..."'
|
|
3627
|
+
|
|
3628
|
+
return _next_action_command(action)
|
|
3629
|
+
|
|
3630
|
+
|
|
3631
|
+
def _commands_for_next_item(
|
|
3632
|
+
action: str,
|
|
3633
|
+
next_item: dict[str, object] | None,
|
|
3634
|
+
) -> list[dict[str, object]]:
|
|
3635
|
+
if next_item is None:
|
|
3636
|
+
primary = _primary_command_for_next_item(action, next_item)
|
|
3637
|
+
if primary is None:
|
|
3638
|
+
return []
|
|
3639
|
+
label = {
|
|
3640
|
+
"plan": "Start planning",
|
|
3641
|
+
"plan-propose": "Propose plan",
|
|
3642
|
+
"plan-regenerate": "Regenerate plan from answers",
|
|
3643
|
+
"plan-approve": "Approve plan",
|
|
3644
|
+
"implement": "Start implementation",
|
|
3645
|
+
"todo-work": "Show implementation checklist",
|
|
3646
|
+
"implement-finish": "Finish implementation",
|
|
3647
|
+
"validate": "Start validation",
|
|
3648
|
+
"validate-check": "Record validation check",
|
|
3649
|
+
"validate-finish": "Finish validation",
|
|
3650
|
+
"repair-lock": "Show current lock",
|
|
3651
|
+
}.get(action, "Show next action")
|
|
3652
|
+
command_kind = {
|
|
3653
|
+
"plan": "start",
|
|
3654
|
+
"plan-propose": "regenerate",
|
|
3655
|
+
"plan-regenerate": "regenerate",
|
|
3656
|
+
"plan-approve": "approve",
|
|
3657
|
+
"implement": "start",
|
|
3658
|
+
"todo-work": "context",
|
|
3659
|
+
"implement-finish": "finish",
|
|
3660
|
+
"validate": "start",
|
|
3661
|
+
"validate-check": "check",
|
|
3662
|
+
"validate-finish": "finish",
|
|
3663
|
+
"repair-lock": "inspect",
|
|
3664
|
+
}.get(action, "context")
|
|
3665
|
+
return [_command(command_kind, label, primary, primary=True)]
|
|
3666
|
+
|
|
3667
|
+
item_kind = next_item.get("kind")
|
|
3668
|
+
item_id = next_item.get("id")
|
|
3669
|
+
if item_kind == "question" and isinstance(item_id, str):
|
|
3670
|
+
return [
|
|
3671
|
+
_command(
|
|
3672
|
+
"answer",
|
|
3673
|
+
"Answer required question",
|
|
3674
|
+
f'taskledger question answer {item_id} --text "..."',
|
|
3675
|
+
primary=True,
|
|
3676
|
+
),
|
|
3677
|
+
_command("context", "Show question status", "taskledger question status"),
|
|
3678
|
+
]
|
|
3679
|
+
if item_kind == "answered_question":
|
|
3680
|
+
return [
|
|
3681
|
+
_command(
|
|
3682
|
+
"regenerate",
|
|
3683
|
+
"Regenerate plan from answers",
|
|
3684
|
+
"taskledger plan upsert --from-answers --file plan.md",
|
|
3685
|
+
primary=True,
|
|
3686
|
+
),
|
|
3687
|
+
_command(
|
|
3688
|
+
"context",
|
|
3689
|
+
"Show answered questions",
|
|
3690
|
+
"taskledger question answers",
|
|
3691
|
+
),
|
|
3692
|
+
]
|
|
3693
|
+
if item_kind == "todo" and isinstance(item_id, str):
|
|
3694
|
+
return [
|
|
3695
|
+
*_todo_command_hints(item_id),
|
|
3696
|
+
_command(
|
|
3697
|
+
"context",
|
|
3698
|
+
"Show implementation checklist",
|
|
3699
|
+
"taskledger implement checklist",
|
|
3700
|
+
),
|
|
3701
|
+
]
|
|
3702
|
+
if item_kind == "criterion" and isinstance(item_id, str):
|
|
3703
|
+
return [
|
|
3704
|
+
_command(
|
|
3705
|
+
"check",
|
|
3706
|
+
"Record validation check",
|
|
3707
|
+
(
|
|
3708
|
+
f"taskledger validate check --criterion {item_id} "
|
|
3709
|
+
'--status pass --evidence "..."'
|
|
3710
|
+
),
|
|
3711
|
+
primary=True,
|
|
3712
|
+
),
|
|
3713
|
+
_command("context", "Show validation status", "taskledger validate status"),
|
|
3714
|
+
]
|
|
3715
|
+
if item_kind == "plan":
|
|
3716
|
+
version = next_item.get("version")
|
|
3717
|
+
if isinstance(version, int):
|
|
3718
|
+
commands = [
|
|
3719
|
+
_command(
|
|
3720
|
+
"inspect",
|
|
3721
|
+
"Show proposed plan",
|
|
3722
|
+
f"taskledger plan show --version {version}",
|
|
3723
|
+
primary=True,
|
|
3724
|
+
)
|
|
3725
|
+
]
|
|
3726
|
+
if action == "plan-approve":
|
|
3727
|
+
commands.append(
|
|
3728
|
+
_command(
|
|
3729
|
+
"approve",
|
|
3730
|
+
"Approve plan",
|
|
3731
|
+
f"taskledger plan approve --version {version} --actor user",
|
|
3732
|
+
)
|
|
3733
|
+
)
|
|
3734
|
+
return commands
|
|
3735
|
+
if item_kind == "lock":
|
|
3736
|
+
task_id = next_item.get("task_id")
|
|
3737
|
+
if isinstance(task_id, str):
|
|
3738
|
+
return [
|
|
3739
|
+
_command(
|
|
3740
|
+
"repair",
|
|
3741
|
+
"Break stale lock",
|
|
3742
|
+
f'taskledger lock break --task {task_id} --reason "..."',
|
|
3743
|
+
primary=True,
|
|
3744
|
+
),
|
|
3745
|
+
_command("inspect", "Show current lock", "taskledger lock show"),
|
|
3746
|
+
]
|
|
3747
|
+
|
|
3748
|
+
primary = _primary_command_for_next_item(action, next_item)
|
|
3749
|
+
if primary is None:
|
|
3750
|
+
return []
|
|
3751
|
+
label = {
|
|
3752
|
+
"implement": "Start implementation",
|
|
3753
|
+
"implement-finish": "Finish implementation",
|
|
3754
|
+
"validate": "Start validation",
|
|
3755
|
+
"validate-finish": "Finish validation",
|
|
3756
|
+
}.get(action, "Show next action")
|
|
3757
|
+
kind_name = {
|
|
3758
|
+
"implement": "start",
|
|
3759
|
+
"implement-finish": "finish",
|
|
3760
|
+
"validate": "start",
|
|
3761
|
+
"validate-finish": "finish",
|
|
3762
|
+
}.get(action, "context")
|
|
3763
|
+
commands = [_command(kind_name, label, primary, primary=True)]
|
|
3764
|
+
if action == "implement-finish":
|
|
3765
|
+
commands.append(
|
|
3766
|
+
_command(
|
|
3767
|
+
"context",
|
|
3768
|
+
"Show implementation checklist",
|
|
3769
|
+
"taskledger implement checklist",
|
|
3770
|
+
)
|
|
3771
|
+
)
|
|
3772
|
+
if action == "validate-finish":
|
|
3773
|
+
commands.append(
|
|
3774
|
+
_command(
|
|
3775
|
+
"context",
|
|
3776
|
+
"Show validation status",
|
|
3777
|
+
"taskledger validate status",
|
|
3778
|
+
)
|
|
3779
|
+
)
|
|
3780
|
+
return commands
|
|
3781
|
+
|
|
3782
|
+
|
|
3783
|
+
def _normalize_todo_text(text: str) -> str:
|
|
3784
|
+
return " ".join(text.casefold().split())
|
|
3785
|
+
|
|
3786
|
+
|
|
3787
|
+
def _optional_front_matter_string(
|
|
3788
|
+
front_matter: dict[str, object],
|
|
3789
|
+
key: str,
|
|
3790
|
+
) -> str | None:
|
|
3791
|
+
return _optional_string_value(front_matter.get(key))
|
|
3792
|
+
|
|
3793
|
+
|
|
3794
|
+
def _optional_string_value(value: object) -> str | None:
|
|
3795
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
3796
|
+
|
|
3797
|
+
|
|
3798
|
+
def _string_tuple_from_front_matter(
|
|
3799
|
+
front_matter: dict[str, object], key: str
|
|
3800
|
+
) -> tuple[str, ...]:
|
|
3801
|
+
raw = front_matter.get(key)
|
|
3802
|
+
if raw is None:
|
|
3803
|
+
return ()
|
|
3804
|
+
if not isinstance(raw, list):
|
|
3805
|
+
raise _cli_error(
|
|
3806
|
+
f"Plan front matter '{key}' must be a list.", EXIT_CODE_BAD_INPUT
|
|
3807
|
+
)
|
|
3808
|
+
items: list[str] = []
|
|
3809
|
+
for item in raw:
|
|
3810
|
+
if not isinstance(item, str) or not item.strip():
|
|
3811
|
+
raise _cli_error(
|
|
3812
|
+
f"Plan front matter '{key}' must contain non-empty strings.",
|
|
3813
|
+
EXIT_CODE_BAD_INPUT,
|
|
3814
|
+
)
|
|
3815
|
+
items.append(item.strip())
|
|
3816
|
+
return tuple(items)
|
|
3817
|
+
|
|
3818
|
+
|
|
3819
|
+
def _criterion_id(index: int) -> str:
|
|
3820
|
+
return f"ac-{index:04d}"
|
|
3821
|
+
|
|
3822
|
+
|
|
3823
|
+
def _normalize_local_id(ref: str, prefix: str) -> str:
|
|
3824
|
+
raw_prefix = f"{prefix}-"
|
|
3825
|
+
if not ref.startswith(raw_prefix):
|
|
3826
|
+
return ref
|
|
3827
|
+
suffix = ref.removeprefix(raw_prefix)
|
|
3828
|
+
if not suffix.isdigit():
|
|
3829
|
+
return ref
|
|
3830
|
+
return f"{prefix}-{int(suffix):04d}"
|
|
3831
|
+
|
|
3832
|
+
|
|
3833
|
+
def _next_lock_id(workspace_root: Path, now: datetime) -> str:
|
|
3834
|
+
paths = resolve_v2_paths(workspace_root)
|
|
3835
|
+
prefix = now.strftime("lock-%Y%m%dT%H%M%SZ")
|
|
3836
|
+
existing = [item.lock_id for item in load_active_locks(workspace_root)]
|
|
3837
|
+
existing.extend(
|
|
3838
|
+
path.stem.removeprefix("broken-")
|
|
3839
|
+
for path in paths.tasks_dir.glob("task-*/audit/broken-lock-*.yaml")
|
|
3840
|
+
)
|
|
3841
|
+
sequence = sum(1 for item in existing if item.startswith(prefix)) + 1
|
|
3842
|
+
return f"{prefix}-{sequence:04d}"
|
|
3843
|
+
|
|
3844
|
+
|
|
3845
|
+
def _has_user_waiver(waiver: DependencyWaiver | None) -> bool:
|
|
3846
|
+
return waiver is not None and waiver.actor.actor_type == "user"
|
|
3847
|
+
|
|
3848
|
+
|
|
3849
|
+
def _ensure_validation_can_pass(
|
|
3850
|
+
workspace_root: Path,
|
|
3851
|
+
task: TaskRecord,
|
|
3852
|
+
run: TaskRunRecord,
|
|
3853
|
+
) -> None:
|
|
3854
|
+
report = _build_validation_gate_report(workspace_root, task, run)
|
|
3855
|
+
|
|
3856
|
+
if not cast(bool, report["can_finish_passed"]):
|
|
3857
|
+
blockers = cast(list[dict[str, object]], report["blockers"])
|
|
3858
|
+
missing_criteria = []
|
|
3859
|
+
failing_criteria = []
|
|
3860
|
+
open_todos = []
|
|
3861
|
+
dependency_blockers = []
|
|
3862
|
+
|
|
3863
|
+
for blocker in blockers:
|
|
3864
|
+
kind = blocker.get("kind")
|
|
3865
|
+
if kind == "criterion_missing":
|
|
3866
|
+
missing_criteria.append(blocker.get("ref"))
|
|
3867
|
+
elif kind == "criterion_fail":
|
|
3868
|
+
failing_criteria.append(blocker.get("ref"))
|
|
3869
|
+
elif kind == "todo_open":
|
|
3870
|
+
open_todos.append(blocker.get("ref"))
|
|
3871
|
+
elif kind == "dependency_blocker":
|
|
3872
|
+
dependency_blockers.append(blocker.get("ref"))
|
|
3873
|
+
|
|
3874
|
+
raise _validation_incomplete(
|
|
3875
|
+
"Cannot mark validation passed because "
|
|
3876
|
+
"mandatory validation gates are incomplete.",
|
|
3877
|
+
{
|
|
3878
|
+
"missing_criteria": missing_criteria,
|
|
3879
|
+
"failing_criteria": failing_criteria,
|
|
3880
|
+
"open_mandatory_todos": open_todos,
|
|
3881
|
+
"dependency_blockers": dependency_blockers,
|
|
3882
|
+
"blockers": blockers,
|
|
3883
|
+
},
|
|
3884
|
+
)
|
|
3885
|
+
|
|
3886
|
+
|
|
3887
|
+
def _criterion_has_user_waiver(check: ValidationCheck) -> bool:
|
|
3888
|
+
return check.waiver is not None and check.waiver.actor.actor_type == "user"
|
|
3889
|
+
|
|
3890
|
+
|
|
3891
|
+
def _validation_incomplete(message: str, details: dict[str, object]) -> LaunchError:
|
|
3892
|
+
error = LaunchError(message)
|
|
3893
|
+
error.taskledger_exit_code = EXIT_CODE_VALIDATION_FAILED
|
|
3894
|
+
error.taskledger_error_code = "VALIDATION_INCOMPLETE"
|
|
3895
|
+
error.taskledger_data = details
|
|
3896
|
+
return error
|
|
3897
|
+
|
|
3898
|
+
|
|
3899
|
+
def _render_validation_status(payload: dict[str, object]) -> str: # noqa: C901
|
|
3900
|
+
"""Render the validation gate report in human-readable text."""
|
|
3901
|
+
lines: list[str] = []
|
|
3902
|
+
|
|
3903
|
+
task_slug = payload.get("task_slug", payload.get("task_id", "unknown"))
|
|
3904
|
+
task_id = payload.get("task_id", "")
|
|
3905
|
+
lines.append(f"# Validation Status: {task_slug}")
|
|
3906
|
+
if task_id:
|
|
3907
|
+
lines.append(f"Task ID: {task_id}")
|
|
3908
|
+
lines.append("")
|
|
3909
|
+
|
|
3910
|
+
status_stage = payload.get("status_stage", "unknown")
|
|
3911
|
+
run_id = payload.get("run_id")
|
|
3912
|
+
lines.append(f"**Status Stage:** {status_stage}")
|
|
3913
|
+
if run_id:
|
|
3914
|
+
lines.append(f"**Run ID:** {run_id}")
|
|
3915
|
+
lines.append("")
|
|
3916
|
+
|
|
3917
|
+
active_stage = payload.get("active_stage")
|
|
3918
|
+
if active_stage:
|
|
3919
|
+
lines.append(f"**Active Stage:** {active_stage}")
|
|
3920
|
+
lines.append("")
|
|
3921
|
+
|
|
3922
|
+
accepted_plan = payload.get("accepted_plan", {})
|
|
3923
|
+
if isinstance(accepted_plan, dict):
|
|
3924
|
+
if accepted_plan:
|
|
3925
|
+
plan_version = accepted_plan.get("version")
|
|
3926
|
+
plan_status = accepted_plan.get("status", "unknown")
|
|
3927
|
+
lines.append(
|
|
3928
|
+
f"**Accepted Plan:** Version {plan_version}, Status: {plan_status}"
|
|
3929
|
+
)
|
|
3930
|
+
else:
|
|
3931
|
+
lines.append("**Accepted Plan:** None")
|
|
3932
|
+
lines.append("")
|
|
3933
|
+
|
|
3934
|
+
implementation = payload.get("implementation", {})
|
|
3935
|
+
if isinstance(implementation, dict):
|
|
3936
|
+
if implementation:
|
|
3937
|
+
impl_run_id = implementation.get("run_id")
|
|
3938
|
+
impl_status = implementation.get("status", "unknown")
|
|
3939
|
+
impl_satisfied = implementation.get("satisfied", False)
|
|
3940
|
+
lines.append(
|
|
3941
|
+
f"**Implementation:** Run {impl_run_id}, Status: {impl_status}"
|
|
3942
|
+
)
|
|
3943
|
+
lines.append(f" Satisfied: {'✓' if impl_satisfied else '✗'}")
|
|
3944
|
+
else:
|
|
3945
|
+
lines.append("**Implementation:** None")
|
|
3946
|
+
lines.append("")
|
|
3947
|
+
|
|
3948
|
+
criteria = cast(list[dict[str, object]], payload.get("criteria", []))
|
|
3949
|
+
if criteria:
|
|
3950
|
+
lines.append("## Acceptance Criteria")
|
|
3951
|
+
for criterion in criteria:
|
|
3952
|
+
if isinstance(criterion, dict):
|
|
3953
|
+
criterion_id = criterion.get("id", "unknown")
|
|
3954
|
+
text = str(criterion.get("text", ""))
|
|
3955
|
+
mandatory = criterion.get("mandatory", False)
|
|
3956
|
+
satisfied = criterion.get("satisfied", False)
|
|
3957
|
+
has_waiver = criterion.get("has_waiver", False)
|
|
3958
|
+
latest_status = criterion.get("latest_status", "unknown")
|
|
3959
|
+
|
|
3960
|
+
checkbox = "☒" if satisfied else "☐"
|
|
3961
|
+
mandatory_marker = " (mandatory)" if mandatory else ""
|
|
3962
|
+
lines.append(f" {checkbox} {criterion_id}{mandatory_marker}")
|
|
3963
|
+
if text:
|
|
3964
|
+
lines.append(f" {text[:80]}...")
|
|
3965
|
+
lines.append(f" Status: {latest_status}")
|
|
3966
|
+
if has_waiver:
|
|
3967
|
+
lines.append(" ✓ Waived")
|
|
3968
|
+
lines.append("")
|
|
3969
|
+
|
|
3970
|
+
todos_obj = payload.get("todos", {})
|
|
3971
|
+
if isinstance(todos_obj, dict):
|
|
3972
|
+
open_todos = todos_obj.get("open_mandatory", [])
|
|
3973
|
+
if open_todos:
|
|
3974
|
+
lines.append("## Open Mandatory Todos")
|
|
3975
|
+
for todo_id in open_todos:
|
|
3976
|
+
lines.append(f" - {todo_id}")
|
|
3977
|
+
lines.append("")
|
|
3978
|
+
|
|
3979
|
+
dependencies_obj = payload.get("dependencies", {})
|
|
3980
|
+
if isinstance(dependencies_obj, dict):
|
|
3981
|
+
dep_blockers = dependencies_obj.get("blockers", [])
|
|
3982
|
+
if dep_blockers:
|
|
3983
|
+
lines.append("## Dependency Blockers")
|
|
3984
|
+
for blocker_id in dep_blockers:
|
|
3985
|
+
lines.append(f" - {blocker_id}")
|
|
3986
|
+
lines.append("")
|
|
3987
|
+
|
|
3988
|
+
can_finish_passed = payload.get("can_finish_passed", False)
|
|
3989
|
+
lines.append("## Result")
|
|
3990
|
+
lines.append(f"**Can Finish Passed:** {'✓ Yes' if can_finish_passed else '✗ No'}")
|
|
3991
|
+
|
|
3992
|
+
blockers = cast(list[dict[str, object]], payload.get("blockers", []))
|
|
3993
|
+
if blockers and not can_finish_passed:
|
|
3994
|
+
lines.append("")
|
|
3995
|
+
lines.append("### Blocking Issues")
|
|
3996
|
+
for blocker in blockers:
|
|
3997
|
+
if isinstance(blocker, dict):
|
|
3998
|
+
kind = blocker.get("kind", "unknown")
|
|
3999
|
+
message = blocker.get("message", "")
|
|
4000
|
+
lines.append(f" - **{kind}**: {message}")
|
|
4001
|
+
hint = blocker.get("command_hint")
|
|
4002
|
+
if hint:
|
|
4003
|
+
lines.append(f" Hint: `{hint}`")
|
|
4004
|
+
|
|
4005
|
+
return "\n".join(lines)
|
|
4006
|
+
|
|
4007
|
+
|
|
4008
|
+
def _approval_actor(
|
|
4009
|
+
*,
|
|
4010
|
+
actor_type: str,
|
|
4011
|
+
actor_name: str | None,
|
|
4012
|
+
note: str | None,
|
|
4013
|
+
allow_agent_approval: bool,
|
|
4014
|
+
reason: str | None,
|
|
4015
|
+
) -> ActorRef:
|
|
4016
|
+
normalized_actor = actor_type.strip()
|
|
4017
|
+
if normalized_actor == "user":
|
|
4018
|
+
if not (note or "").strip():
|
|
4019
|
+
raise _cli_error("Plan approval requires --note.", EXIT_CODE_BAD_INPUT)
|
|
4020
|
+
return ActorRef(
|
|
4021
|
+
actor_type="user",
|
|
4022
|
+
actor_name=(actor_name or getpass.getuser() or "user").strip(),
|
|
4023
|
+
tool="manual",
|
|
4024
|
+
)
|
|
4025
|
+
if normalized_actor == "agent":
|
|
4026
|
+
if not allow_agent_approval or not (reason or "").strip():
|
|
4027
|
+
raise _cli_error(
|
|
4028
|
+
"Agent approval requires --allow-agent-approval and --reason.",
|
|
4029
|
+
EXIT_CODE_APPROVAL_REQUIRED,
|
|
4030
|
+
)
|
|
4031
|
+
return ActorRef(
|
|
4032
|
+
actor_type="agent",
|
|
4033
|
+
actor_name=(actor_name or getpass.getuser() or "taskledger").strip(),
|
|
4034
|
+
tool="taskledger",
|
|
4035
|
+
host=socket.gethostname(),
|
|
4036
|
+
pid=os.getpid(),
|
|
4037
|
+
)
|
|
4038
|
+
raise _cli_error(
|
|
4039
|
+
f"Unsupported approval actor: {actor_type}",
|
|
4040
|
+
EXIT_CODE_BAD_INPUT,
|
|
4041
|
+
)
|
|
4042
|
+
|
|
4043
|
+
|
|
4044
|
+
def _unique_slug(existing: list, value: str) -> str:
|
|
4045
|
+
base = slugify_project_ref(value, empty="task")
|
|
4046
|
+
taken = {item.slug for item in existing}
|
|
4047
|
+
if base not in taken:
|
|
4048
|
+
return base
|
|
4049
|
+
suffix = 2
|
|
4050
|
+
while f"{base}-{suffix}" in taken:
|
|
4051
|
+
suffix += 1
|
|
4052
|
+
return f"{base}-{suffix}"
|
|
4053
|
+
|
|
4054
|
+
|
|
4055
|
+
def _lifecycle_payload(
|
|
4056
|
+
command: str,
|
|
4057
|
+
task: TaskRecord,
|
|
4058
|
+
*,
|
|
4059
|
+
warnings: list[str],
|
|
4060
|
+
changed: bool,
|
|
4061
|
+
plan_version: int | None = None,
|
|
4062
|
+
run: TaskRunRecord | None = None,
|
|
4063
|
+
lock: TaskLock | None = None,
|
|
4064
|
+
result: str | None = None,
|
|
4065
|
+
) -> dict[str, object]:
|
|
4066
|
+
active_stage = (
|
|
4067
|
+
derive_active_stage(lock, (run,))
|
|
4068
|
+
if lock is not None and run is not None
|
|
4069
|
+
else None
|
|
4070
|
+
)
|
|
4071
|
+
payload: dict[str, object] = {
|
|
4072
|
+
"ok": True,
|
|
4073
|
+
"command": command,
|
|
4074
|
+
"task_id": task.id,
|
|
4075
|
+
"status": task.status_stage,
|
|
4076
|
+
"status_stage": task.status_stage,
|
|
4077
|
+
"active_stage": active_stage,
|
|
4078
|
+
"changed": changed,
|
|
4079
|
+
"warnings": warnings,
|
|
4080
|
+
"lock": lock.to_dict() if lock is not None else None,
|
|
4081
|
+
}
|
|
4082
|
+
if plan_version is not None:
|
|
4083
|
+
payload["plan_version"] = plan_version
|
|
4084
|
+
if run is not None:
|
|
4085
|
+
payload["run_id"] = run.run_id
|
|
4086
|
+
payload["run"] = run.to_dict()
|
|
4087
|
+
if result is not None:
|
|
4088
|
+
payload["result"] = result
|
|
4089
|
+
return payload
|
|
4090
|
+
|
|
4091
|
+
|
|
4092
|
+
def _cli_error(message: str, exit_code: int) -> LaunchError:
|
|
4093
|
+
error = LaunchError(message)
|
|
4094
|
+
error.taskledger_exit_code = exit_code
|
|
4095
|
+
return error
|
|
4096
|
+
|
|
4097
|
+
|
|
4098
|
+
def _stale_lock_error(task_id: str, lock: TaskLock) -> LaunchError:
|
|
4099
|
+
error = LaunchError(
|
|
4100
|
+
f"Task {task_id} has an expired {lock.stage} lock from {lock.run_id}. "
|
|
4101
|
+
"Break it explicitly before continuing."
|
|
4102
|
+
)
|
|
4103
|
+
error.taskledger_exit_code = EXIT_CODE_STALE_LOCK_REQUIRES_BREAK
|
|
4104
|
+
error.taskledger_error_type = "StaleLockRequiresBreak"
|
|
4105
|
+
error.taskledger_remediation = [
|
|
4106
|
+
(
|
|
4107
|
+
f"taskledger lock break --task {task_id} "
|
|
4108
|
+
f'--reason "recover stale {lock.stage} lock"'
|
|
4109
|
+
)
|
|
4110
|
+
]
|
|
4111
|
+
error.taskledger_data = {
|
|
4112
|
+
"task_id": task_id,
|
|
4113
|
+
"lock": lock.to_dict(),
|
|
4114
|
+
}
|
|
4115
|
+
return error
|
|
4116
|
+
|
|
4117
|
+
|
|
4118
|
+
def _task_with_sidecars(workspace_root: Path, task: TaskRecord) -> TaskRecord:
|
|
4119
|
+
return replace(
|
|
4120
|
+
task,
|
|
4121
|
+
requirements=_task_requirements(workspace_root, task),
|
|
4122
|
+
file_links=load_links(workspace_root, task.id).links,
|
|
4123
|
+
todos=load_todos(workspace_root, task.id).todos,
|
|
4124
|
+
)
|
|
4125
|
+
|
|
4126
|
+
|
|
4127
|
+
def _task_payload(task: TaskRecord, *, active_stage: str | None) -> dict[str, object]:
|
|
4128
|
+
payload = task.to_dict()
|
|
4129
|
+
payload["active_stage"] = active_stage
|
|
4130
|
+
return payload
|
|
4131
|
+
|
|
4132
|
+
|
|
4133
|
+
def _active_task_payload(
|
|
4134
|
+
workspace_root: Path,
|
|
4135
|
+
task: TaskRecord,
|
|
4136
|
+
*,
|
|
4137
|
+
state: ActiveTaskState,
|
|
4138
|
+
changed: bool,
|
|
4139
|
+
previous_task_id: str | None,
|
|
4140
|
+
active: bool = True,
|
|
4141
|
+
) -> dict[str, object]:
|
|
4142
|
+
return {
|
|
4143
|
+
"kind": "active_task",
|
|
4144
|
+
"task_id": task.id,
|
|
4145
|
+
"slug": task.slug,
|
|
4146
|
+
"title": task.title,
|
|
4147
|
+
"status_stage": task.status_stage,
|
|
4148
|
+
"active_stage": _task_active_stage(workspace_root, task) if active else None,
|
|
4149
|
+
"active": active,
|
|
4150
|
+
"changed": changed,
|
|
4151
|
+
"previous_task_id": previous_task_id,
|
|
4152
|
+
"state": state.to_dict(),
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
|
|
4156
|
+
def _actor_for_active_task(actor_type: str) -> ActorRef:
|
|
4157
|
+
if actor_type not in {"agent", "user", "system"}:
|
|
4158
|
+
raise _cli_error(
|
|
4159
|
+
f"Unsupported actor type: {actor_type}",
|
|
4160
|
+
EXIT_CODE_BAD_INPUT,
|
|
4161
|
+
)
|
|
4162
|
+
base = _default_actor()
|
|
4163
|
+
return replace(
|
|
4164
|
+
base,
|
|
4165
|
+
actor_type=cast(Literal["agent", "user", "system"], actor_type),
|
|
4166
|
+
)
|
|
4167
|
+
|
|
4168
|
+
|
|
4169
|
+
def _ensure_active_switch_allowed(
|
|
4170
|
+
workspace_root: Path,
|
|
4171
|
+
task_id: str,
|
|
4172
|
+
*,
|
|
4173
|
+
force: bool,
|
|
4174
|
+
reason: str | None,
|
|
4175
|
+
) -> None:
|
|
4176
|
+
lock = _current_lock(workspace_root, task_id)
|
|
4177
|
+
if lock is None or lock_is_expired(lock):
|
|
4178
|
+
return
|
|
4179
|
+
if force and reason and reason.strip():
|
|
4180
|
+
return
|
|
4181
|
+
raise LockConflict(
|
|
4182
|
+
f"Active task {task_id} has a live {lock.stage} lock from {lock.run_id}. "
|
|
4183
|
+
"Pass --force with --reason to switch or clear the active task.",
|
|
4184
|
+
task_id=task_id,
|
|
4185
|
+
details={"lock": lock.to_dict()},
|
|
4186
|
+
remediation=[
|
|
4187
|
+
f"taskledger lock show --task {task_id}",
|
|
4188
|
+
(
|
|
4189
|
+
'Pass --force --reason "..." only if you intend to leave '
|
|
4190
|
+
"the lock in place."
|
|
4191
|
+
),
|
|
4192
|
+
],
|
|
4193
|
+
)
|
|
4194
|
+
|
|
4195
|
+
|
|
4196
|
+
def _task_active_stage(
|
|
4197
|
+
workspace_root: Path,
|
|
4198
|
+
task: TaskRecord,
|
|
4199
|
+
*,
|
|
4200
|
+
lock: TaskLock | None = None,
|
|
4201
|
+
runs: list[TaskRunRecord] | None = None,
|
|
4202
|
+
) -> str | None:
|
|
4203
|
+
current_lock = lock or _current_lock(workspace_root, task.id)
|
|
4204
|
+
if current_lock is None or lock_is_expired(current_lock):
|
|
4205
|
+
return None
|
|
4206
|
+
task_runs = runs if runs is not None else list_runs(workspace_root, task.id)
|
|
4207
|
+
return derive_active_stage(current_lock, task_runs)
|
|
4208
|
+
|
|
4209
|
+
|
|
4210
|
+
def _task_requirements(workspace_root: Path, task: TaskRecord) -> tuple[str, ...]:
|
|
4211
|
+
return tuple(
|
|
4212
|
+
item.task_id for item in load_requirements(workspace_root, task.id).requirements
|
|
4213
|
+
)
|
|
4214
|
+
|
|
4215
|
+
|
|
4216
|
+
def _write_broken_lock_audit(paths: V2Paths, task_id: str, lock: TaskLock) -> Path:
|
|
4217
|
+
timestamp = lock.broken_at or utc_now_iso()
|
|
4218
|
+
filename = timestamp.replace(":", "").replace("-", "").replace("+00:00", "Z")
|
|
4219
|
+
path = task_audit_dir(paths, task_id) / f"broken-lock-{filename}.yaml"
|
|
4220
|
+
atomic_write_text(
|
|
4221
|
+
path,
|
|
4222
|
+
yaml.safe_dump(lock.to_dict(), sort_keys=False, allow_unicode=True),
|
|
4223
|
+
)
|
|
4224
|
+
return path
|