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,836 @@
|
|
|
1
|
+
# Derived index files
|
|
2
|
+
#
|
|
3
|
+
# Index files under .taskledger/indexes/ are derived caches.
|
|
4
|
+
# They are rebuilt from canonical Markdown/YAML records by 'taskledger reindex'.
|
|
5
|
+
# They may be plain JSON arrays with no version metadata.
|
|
6
|
+
# They are never the authoritative source of truth.
|
|
7
|
+
# 'doctor indexes' checks staleness but not schema mismatches as migration blockers.
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TypeVar
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from taskledger.domain.models import (
|
|
19
|
+
ActiveActorState,
|
|
20
|
+
ActiveHarnessState,
|
|
21
|
+
ActiveTaskState,
|
|
22
|
+
CodeChangeRecord,
|
|
23
|
+
DependencyRequirement,
|
|
24
|
+
FileLink,
|
|
25
|
+
IntroductionRecord,
|
|
26
|
+
LinkCollection,
|
|
27
|
+
PlanRecord,
|
|
28
|
+
QuestionRecord,
|
|
29
|
+
RequirementCollection,
|
|
30
|
+
TaskHandoffRecord,
|
|
31
|
+
TaskLock,
|
|
32
|
+
TaskRecord,
|
|
33
|
+
TaskRunRecord,
|
|
34
|
+
TaskTodo,
|
|
35
|
+
TodoCollection,
|
|
36
|
+
)
|
|
37
|
+
from taskledger.domain.states import (
|
|
38
|
+
TASKLEDGER_SCHEMA_VERSION,
|
|
39
|
+
TASKLEDGER_V2_FILE_VERSION,
|
|
40
|
+
)
|
|
41
|
+
from taskledger.errors import ActiveTaskNotFound, LaunchError, NoActiveTask
|
|
42
|
+
from taskledger.storage.atomic import atomic_write_text
|
|
43
|
+
from taskledger.storage.frontmatter import (
|
|
44
|
+
normalize_front_matter_newlines,
|
|
45
|
+
read_markdown_front_matter,
|
|
46
|
+
write_markdown_front_matter,
|
|
47
|
+
)
|
|
48
|
+
from taskledger.storage.locks import read_lock, update_lock, write_lock
|
|
49
|
+
from taskledger.storage.paths import resolve_taskledger_root
|
|
50
|
+
from taskledger.timeutils import utc_now_iso
|
|
51
|
+
|
|
52
|
+
T = TypeVar("T")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _link_id_from_path(path: str) -> str:
|
|
56
|
+
"""Generate a deterministic link id from the path."""
|
|
57
|
+
import hashlib
|
|
58
|
+
|
|
59
|
+
digest = hashlib.sha256(path.encode("utf-8")).hexdigest()[:8]
|
|
60
|
+
return f"link-{digest}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _requirement_id_from_task(task_id: str) -> str:
|
|
64
|
+
"""Generate a deterministic requirement id from the required task id."""
|
|
65
|
+
import re
|
|
66
|
+
|
|
67
|
+
match = re.match(r"task-(\d+)", task_id)
|
|
68
|
+
if match:
|
|
69
|
+
return f"req-{match.group(1)}"
|
|
70
|
+
return f"req-{task_id}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True, frozen=True)
|
|
74
|
+
class V2Paths:
|
|
75
|
+
workspace_root: Path
|
|
76
|
+
project_dir: Path
|
|
77
|
+
introductions_dir: Path
|
|
78
|
+
tasks_dir: Path
|
|
79
|
+
plans_dir: Path
|
|
80
|
+
questions_dir: Path
|
|
81
|
+
runs_dir: Path
|
|
82
|
+
changes_dir: Path
|
|
83
|
+
events_dir: Path
|
|
84
|
+
indexes_dir: Path
|
|
85
|
+
active_task_path: Path
|
|
86
|
+
actor_path: Path
|
|
87
|
+
harness_path: Path
|
|
88
|
+
active_locks_index_path: Path
|
|
89
|
+
dependencies_index_path: Path
|
|
90
|
+
introductions_index_path: Path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_v2_paths(workspace_root: Path) -> V2Paths:
|
|
94
|
+
project_dir = resolve_taskledger_root(workspace_root)
|
|
95
|
+
indexes_dir = project_dir / "indexes"
|
|
96
|
+
return V2Paths(
|
|
97
|
+
workspace_root=workspace_root,
|
|
98
|
+
project_dir=project_dir,
|
|
99
|
+
introductions_dir=project_dir / "intros",
|
|
100
|
+
tasks_dir=project_dir / "tasks",
|
|
101
|
+
plans_dir=project_dir / "plans",
|
|
102
|
+
questions_dir=project_dir / "questions",
|
|
103
|
+
runs_dir=project_dir / "runs",
|
|
104
|
+
changes_dir=project_dir / "changes",
|
|
105
|
+
events_dir=project_dir / "events",
|
|
106
|
+
indexes_dir=indexes_dir,
|
|
107
|
+
active_task_path=project_dir / "active-task.yaml",
|
|
108
|
+
actor_path=project_dir / "actor.yaml",
|
|
109
|
+
harness_path=project_dir / "harness.yaml",
|
|
110
|
+
active_locks_index_path=indexes_dir / "active_locks.json",
|
|
111
|
+
dependencies_index_path=indexes_dir / "dependencies.json",
|
|
112
|
+
introductions_index_path=indexes_dir / "introductions.json",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ensure_v2_layout(workspace_root: Path) -> V2Paths:
|
|
117
|
+
paths = resolve_v2_paths(workspace_root)
|
|
118
|
+
for directory in (
|
|
119
|
+
paths.project_dir,
|
|
120
|
+
paths.introductions_dir,
|
|
121
|
+
paths.tasks_dir,
|
|
122
|
+
paths.events_dir,
|
|
123
|
+
paths.indexes_dir,
|
|
124
|
+
):
|
|
125
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
for index_path in (
|
|
127
|
+
paths.active_locks_index_path,
|
|
128
|
+
paths.dependencies_index_path,
|
|
129
|
+
paths.introductions_index_path,
|
|
130
|
+
):
|
|
131
|
+
if index_path.exists():
|
|
132
|
+
continue
|
|
133
|
+
atomic_write_text(index_path, "[]\n")
|
|
134
|
+
return paths
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def list_tasks(workspace_root: Path) -> list[TaskRecord]:
|
|
138
|
+
paths = ensure_v2_layout(workspace_root)
|
|
139
|
+
return sorted(
|
|
140
|
+
[_load_task(path) for path in paths.tasks_dir.glob("task-*/task.md")],
|
|
141
|
+
key=lambda item: item.id,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_task(workspace_root: Path, ref: str) -> TaskRecord:
|
|
146
|
+
normalized_ref = ref.strip().lower()
|
|
147
|
+
normalized_id = _normalize_numeric_ref(normalized_ref, "task")
|
|
148
|
+
for task in list_tasks(workspace_root):
|
|
149
|
+
if task.id == ref or task.id == normalized_id or task.slug == normalized_ref:
|
|
150
|
+
return task
|
|
151
|
+
raise LaunchError(f"Task not found: {ref}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_active_task_state(workspace_root: Path) -> ActiveTaskState | None:
|
|
155
|
+
paths = ensure_v2_layout(workspace_root)
|
|
156
|
+
if not paths.active_task_path.exists():
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
payload = yaml.safe_load(paths.active_task_path.read_text(encoding="utf-8"))
|
|
160
|
+
except yaml.YAMLError as exc:
|
|
161
|
+
raise LaunchError(f"Invalid active task state: {exc}") from exc
|
|
162
|
+
if not isinstance(payload, dict):
|
|
163
|
+
raise LaunchError("Invalid active task state: expected mapping.")
|
|
164
|
+
return ActiveTaskState.from_dict(payload)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def save_active_task_state(
|
|
168
|
+
workspace_root: Path,
|
|
169
|
+
state: ActiveTaskState,
|
|
170
|
+
) -> ActiveTaskState:
|
|
171
|
+
paths = ensure_v2_layout(workspace_root)
|
|
172
|
+
_write_yaml(paths.active_task_path, state.to_dict())
|
|
173
|
+
return state
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def clear_active_task_state(workspace_root: Path) -> ActiveTaskState | None:
|
|
177
|
+
paths = ensure_v2_layout(workspace_root)
|
|
178
|
+
state = load_active_task_state(workspace_root)
|
|
179
|
+
if paths.active_task_path.exists():
|
|
180
|
+
paths.active_task_path.unlink()
|
|
181
|
+
return state
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_actor_state(workspace_root: Path) -> ActiveActorState | None:
|
|
185
|
+
paths = ensure_v2_layout(workspace_root)
|
|
186
|
+
if not paths.actor_path.exists():
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
payload = yaml.safe_load(paths.actor_path.read_text(encoding="utf-8"))
|
|
190
|
+
except yaml.YAMLError as exc:
|
|
191
|
+
raise LaunchError(f"Invalid actor state: {exc}") from exc
|
|
192
|
+
if not isinstance(payload, dict):
|
|
193
|
+
raise LaunchError("Invalid actor state: expected mapping.")
|
|
194
|
+
return ActiveActorState.from_dict(payload)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def save_actor_state(
|
|
198
|
+
workspace_root: Path,
|
|
199
|
+
state: ActiveActorState,
|
|
200
|
+
) -> ActiveActorState:
|
|
201
|
+
paths = ensure_v2_layout(workspace_root)
|
|
202
|
+
_write_yaml(paths.actor_path, state.to_dict())
|
|
203
|
+
return state
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def clear_actor_state(workspace_root: Path) -> ActiveActorState | None:
|
|
207
|
+
paths = ensure_v2_layout(workspace_root)
|
|
208
|
+
state = load_actor_state(workspace_root)
|
|
209
|
+
if paths.actor_path.exists():
|
|
210
|
+
paths.actor_path.unlink()
|
|
211
|
+
return state
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_harness_state(workspace_root: Path) -> ActiveHarnessState | None:
|
|
215
|
+
paths = ensure_v2_layout(workspace_root)
|
|
216
|
+
if not paths.harness_path.exists():
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
payload = yaml.safe_load(paths.harness_path.read_text(encoding="utf-8"))
|
|
220
|
+
except yaml.YAMLError as exc:
|
|
221
|
+
raise LaunchError(f"Invalid harness state: {exc}") from exc
|
|
222
|
+
if not isinstance(payload, dict):
|
|
223
|
+
raise LaunchError("Invalid harness state: expected mapping.")
|
|
224
|
+
return ActiveHarnessState.from_dict(payload)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def save_harness_state(
|
|
228
|
+
workspace_root: Path,
|
|
229
|
+
state: ActiveHarnessState,
|
|
230
|
+
) -> ActiveHarnessState:
|
|
231
|
+
paths = ensure_v2_layout(workspace_root)
|
|
232
|
+
_write_yaml(paths.harness_path, state.to_dict())
|
|
233
|
+
return state
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def clear_harness_state(workspace_root: Path) -> ActiveHarnessState | None:
|
|
237
|
+
paths = ensure_v2_layout(workspace_root)
|
|
238
|
+
state = load_harness_state(workspace_root)
|
|
239
|
+
if paths.harness_path.exists():
|
|
240
|
+
paths.harness_path.unlink()
|
|
241
|
+
return state
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def resolve_active_task(workspace_root: Path) -> TaskRecord:
|
|
245
|
+
state = load_active_task_state(workspace_root)
|
|
246
|
+
if state is None:
|
|
247
|
+
raise NoActiveTask()
|
|
248
|
+
try:
|
|
249
|
+
return resolve_task(workspace_root, state.task_id)
|
|
250
|
+
except LaunchError as exc:
|
|
251
|
+
raise ActiveTaskNotFound(
|
|
252
|
+
f"Active task points to missing task: {state.task_id}",
|
|
253
|
+
details={"task_id": state.task_id},
|
|
254
|
+
task_id=state.task_id,
|
|
255
|
+
) from exc
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def resolve_task_or_active(
|
|
259
|
+
workspace_root: Path,
|
|
260
|
+
ref: str | None = None,
|
|
261
|
+
) -> TaskRecord:
|
|
262
|
+
if ref is not None and ref.strip():
|
|
263
|
+
return resolve_task(workspace_root, ref)
|
|
264
|
+
return resolve_active_task(workspace_root)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def save_task(workspace_root: Path, task: TaskRecord) -> TaskRecord:
|
|
268
|
+
paths = ensure_v2_layout(workspace_root)
|
|
269
|
+
_ensure_task_bundle(paths, task.id)
|
|
270
|
+
path = task_markdown_path(paths, task.id)
|
|
271
|
+
if path.parent.name != task.id:
|
|
272
|
+
raise LaunchError(f"Task id/path mismatch for {task.id}")
|
|
273
|
+
metadata = task.to_dict()
|
|
274
|
+
metadata.pop("todos", None)
|
|
275
|
+
metadata.pop("file_links", None)
|
|
276
|
+
metadata.pop("requirements", None)
|
|
277
|
+
_write_markdown_record(path, metadata, task.body)
|
|
278
|
+
return task
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def list_introductions(workspace_root: Path) -> list[IntroductionRecord]:
|
|
282
|
+
paths = ensure_v2_layout(workspace_root)
|
|
283
|
+
return sorted(
|
|
284
|
+
[_load_intro(path) for path in paths.introductions_dir.glob("intro-*.md")],
|
|
285
|
+
key=lambda item: item.id,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def resolve_introduction(workspace_root: Path, ref: str) -> IntroductionRecord:
|
|
290
|
+
normalized_ref = ref.strip().lower()
|
|
291
|
+
for intro in list_introductions(workspace_root):
|
|
292
|
+
if intro.id == ref or intro.slug == normalized_ref:
|
|
293
|
+
return intro
|
|
294
|
+
raise LaunchError(f"Introduction not found: {ref}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def save_introduction(
|
|
298
|
+
workspace_root: Path, introduction: IntroductionRecord
|
|
299
|
+
) -> IntroductionRecord:
|
|
300
|
+
paths = ensure_v2_layout(workspace_root)
|
|
301
|
+
path = paths.introductions_dir / f"{introduction.id}.md"
|
|
302
|
+
_write_markdown_record(path, introduction.to_dict(), introduction.body)
|
|
303
|
+
return introduction
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def list_plans(workspace_root: Path, task_id: str) -> list[PlanRecord]:
|
|
307
|
+
paths = ensure_v2_layout(workspace_root)
|
|
308
|
+
directory = task_plans_dir(paths, task_id)
|
|
309
|
+
return sorted(
|
|
310
|
+
[_load_plan(path) for path in directory.glob("plan-v*.md")],
|
|
311
|
+
key=lambda item: item.plan_version,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def save_plan(workspace_root: Path, plan: PlanRecord) -> PlanRecord:
|
|
316
|
+
paths = ensure_v2_layout(workspace_root)
|
|
317
|
+
path = plan_markdown_path(paths, plan.task_id, plan.plan_version)
|
|
318
|
+
if path.exists():
|
|
319
|
+
raise LaunchError(
|
|
320
|
+
f"Plan version already exists: {plan.task_id} v{plan.plan_version}"
|
|
321
|
+
)
|
|
322
|
+
_write_markdown_record(path, plan.to_dict(), plan.body)
|
|
323
|
+
return plan
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def overwrite_plan(workspace_root: Path, plan: PlanRecord) -> PlanRecord:
|
|
327
|
+
paths = ensure_v2_layout(workspace_root)
|
|
328
|
+
path = plan_markdown_path(paths, plan.task_id, plan.plan_version)
|
|
329
|
+
_write_markdown_record(path, plan.to_dict(), plan.body)
|
|
330
|
+
return plan
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def resolve_plan(
|
|
334
|
+
workspace_root: Path,
|
|
335
|
+
task_id: str,
|
|
336
|
+
*,
|
|
337
|
+
version: int | None = None,
|
|
338
|
+
) -> PlanRecord:
|
|
339
|
+
plans = list_plans(workspace_root, task_id)
|
|
340
|
+
if not plans:
|
|
341
|
+
raise LaunchError(f"No plans found for task {task_id}")
|
|
342
|
+
if version is None:
|
|
343
|
+
return plans[-1]
|
|
344
|
+
for plan in plans:
|
|
345
|
+
if plan.plan_version == version:
|
|
346
|
+
return plan
|
|
347
|
+
raise LaunchError(f"Plan version not found for task {task_id}: {version}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def list_questions(workspace_root: Path, task_id: str) -> list[QuestionRecord]:
|
|
351
|
+
paths = ensure_v2_layout(workspace_root)
|
|
352
|
+
directory = task_questions_dir(paths, task_id)
|
|
353
|
+
return sorted(
|
|
354
|
+
[_load_question(path) for path in directory.glob("q-*.md")],
|
|
355
|
+
key=lambda item: item.id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def resolve_question(
|
|
360
|
+
workspace_root: Path, task_id: str, question_id: str
|
|
361
|
+
) -> QuestionRecord:
|
|
362
|
+
normalized_id = _normalize_numeric_ref(question_id, "q")
|
|
363
|
+
for question in list_questions(workspace_root, task_id):
|
|
364
|
+
if question.id == question_id or question.id == normalized_id:
|
|
365
|
+
return question
|
|
366
|
+
raise LaunchError(f"Question not found: {question_id}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def save_question(workspace_root: Path, question: QuestionRecord) -> QuestionRecord:
|
|
370
|
+
paths = ensure_v2_layout(workspace_root)
|
|
371
|
+
path = question_markdown_path(paths, question.task_id, question.id)
|
|
372
|
+
_write_markdown_record(path, question.to_dict(), _render_question_body(question))
|
|
373
|
+
return question
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def list_runs(workspace_root: Path, task_id: str) -> list[TaskRunRecord]:
|
|
377
|
+
paths = ensure_v2_layout(workspace_root)
|
|
378
|
+
directory = task_runs_dir(paths, task_id)
|
|
379
|
+
return sorted(
|
|
380
|
+
[_load_run(path) for path in directory.glob("*.md")],
|
|
381
|
+
key=lambda item: item.run_id,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def resolve_run(workspace_root: Path, task_id: str, run_id: str) -> TaskRunRecord:
|
|
386
|
+
normalized_id = _normalize_numeric_ref(run_id, "run")
|
|
387
|
+
for run in list_runs(workspace_root, task_id):
|
|
388
|
+
if run.run_id == run_id or run.run_id == normalized_id:
|
|
389
|
+
return run
|
|
390
|
+
raise LaunchError(f"Run not found: {run_id}")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def save_run(workspace_root: Path, run: TaskRunRecord) -> TaskRunRecord:
|
|
394
|
+
paths = ensure_v2_layout(workspace_root)
|
|
395
|
+
path = run_markdown_path(paths, run.task_id, run.run_id)
|
|
396
|
+
_write_markdown_record(path, run.to_dict(), _render_run_body(run))
|
|
397
|
+
return run
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def list_changes(workspace_root: Path, task_id: str) -> list[CodeChangeRecord]:
|
|
401
|
+
paths = ensure_v2_layout(workspace_root)
|
|
402
|
+
directory = task_changes_dir(paths, task_id)
|
|
403
|
+
return sorted(
|
|
404
|
+
[_load_change(path) for path in directory.glob("change-*.md")],
|
|
405
|
+
key=lambda item: item.change_id,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def save_change(workspace_root: Path, change: CodeChangeRecord) -> CodeChangeRecord:
|
|
410
|
+
paths = ensure_v2_layout(workspace_root)
|
|
411
|
+
path = change_markdown_path(paths, change.task_id, change.change_id)
|
|
412
|
+
_write_markdown_record(path, change.to_dict(), change.summary)
|
|
413
|
+
return change
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def resolve_change(
|
|
417
|
+
workspace_root: Path, task_id: str, change_id: str
|
|
418
|
+
) -> CodeChangeRecord:
|
|
419
|
+
normalized_id = _normalize_numeric_ref(change_id, "change")
|
|
420
|
+
for change in list_changes(workspace_root, task_id):
|
|
421
|
+
if change.change_id == change_id or change.change_id == normalized_id:
|
|
422
|
+
return change
|
|
423
|
+
raise LaunchError(f"Change not found: {change_id}")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def load_active_locks(workspace_root: Path) -> list[TaskLock]:
|
|
427
|
+
paths = ensure_v2_layout(workspace_root)
|
|
428
|
+
locks: list[TaskLock] = []
|
|
429
|
+
for path in sorted(paths.tasks_dir.glob("task-*/lock.yaml")):
|
|
430
|
+
lock = read_lock(path)
|
|
431
|
+
if lock is not None:
|
|
432
|
+
locks.append(lock)
|
|
433
|
+
return locks
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def load_todos(workspace_root: Path, task_id: str) -> TodoCollection:
|
|
437
|
+
paths = ensure_v2_layout(workspace_root)
|
|
438
|
+
directory = task_todos_dir(paths, task_id)
|
|
439
|
+
records = sorted(
|
|
440
|
+
[_load_record(p, TaskTodo.from_dict) for p in directory.glob("todo-*.md")],
|
|
441
|
+
key=lambda t: t.id,
|
|
442
|
+
)
|
|
443
|
+
return TodoCollection(task_id=task_id, todos=tuple(records))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def save_todos(workspace_root: Path, collection: TodoCollection) -> TodoCollection:
|
|
447
|
+
paths = ensure_v2_layout(workspace_root)
|
|
448
|
+
_ensure_task_bundle(paths, collection.task_id)
|
|
449
|
+
directory = task_todos_dir(paths, collection.task_id)
|
|
450
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
keep_ids = set()
|
|
452
|
+
now = utc_now_iso()
|
|
453
|
+
for todo in collection.todos:
|
|
454
|
+
keep_ids.add(todo.id)
|
|
455
|
+
metadata = todo.to_dict()
|
|
456
|
+
metadata["task_id"] = collection.task_id
|
|
457
|
+
metadata["file_version"] = TASKLEDGER_V2_FILE_VERSION
|
|
458
|
+
metadata["schema_version"] = TASKLEDGER_SCHEMA_VERSION
|
|
459
|
+
metadata["object_type"] = "todo"
|
|
460
|
+
if "updated_at" not in metadata or metadata["updated_at"] is None:
|
|
461
|
+
metadata["updated_at"] = now
|
|
462
|
+
body = todo.text
|
|
463
|
+
path = todo_markdown_path(paths, collection.task_id, todo.id)
|
|
464
|
+
_write_markdown_record(path, metadata, body)
|
|
465
|
+
# Remove stale files
|
|
466
|
+
for path in directory.glob("todo-*.md"):
|
|
467
|
+
if path.stem not in keep_ids:
|
|
468
|
+
path.unlink()
|
|
469
|
+
return collection
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def load_links(workspace_root: Path, task_id: str) -> LinkCollection:
|
|
473
|
+
paths = ensure_v2_layout(workspace_root)
|
|
474
|
+
directory = task_links_dir(paths, task_id)
|
|
475
|
+
records = sorted(
|
|
476
|
+
[_load_record(p, FileLink.from_dict) for p in directory.glob("link-*.md")],
|
|
477
|
+
key=lambda lk: lk.id or "",
|
|
478
|
+
)
|
|
479
|
+
return LinkCollection(task_id=task_id, links=tuple(records))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def save_links(workspace_root: Path, collection: LinkCollection) -> LinkCollection:
|
|
483
|
+
paths = ensure_v2_layout(workspace_root)
|
|
484
|
+
_ensure_task_bundle(paths, collection.task_id)
|
|
485
|
+
directory = task_links_dir(paths, collection.task_id)
|
|
486
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
keep_ids = set()
|
|
488
|
+
now = utc_now_iso()
|
|
489
|
+
for link in collection.links:
|
|
490
|
+
link_id = link.id or _link_id_from_path(link.path)
|
|
491
|
+
keep_ids.add(link_id)
|
|
492
|
+
metadata = link.to_dict()
|
|
493
|
+
metadata["id"] = link_id
|
|
494
|
+
metadata["task_id"] = collection.task_id
|
|
495
|
+
metadata["file_version"] = TASKLEDGER_V2_FILE_VERSION
|
|
496
|
+
metadata["schema_version"] = TASKLEDGER_SCHEMA_VERSION
|
|
497
|
+
metadata["object_type"] = "link"
|
|
498
|
+
if metadata.get("created_at") is None:
|
|
499
|
+
metadata["created_at"] = now
|
|
500
|
+
if metadata.get("updated_at") is None:
|
|
501
|
+
metadata["updated_at"] = now
|
|
502
|
+
body = link.path
|
|
503
|
+
path = link_markdown_path(paths, collection.task_id, link_id)
|
|
504
|
+
_write_markdown_record(path, metadata, body)
|
|
505
|
+
# Remove stale files
|
|
506
|
+
for path in directory.glob("link-*.md"):
|
|
507
|
+
if path.stem not in keep_ids:
|
|
508
|
+
path.unlink()
|
|
509
|
+
return collection
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def load_requirements(workspace_root: Path, task_id: str) -> RequirementCollection:
|
|
513
|
+
paths = ensure_v2_layout(workspace_root)
|
|
514
|
+
directory = task_requirements_dir(paths, task_id)
|
|
515
|
+
records = sorted(
|
|
516
|
+
[
|
|
517
|
+
_load_record(p, DependencyRequirement.from_dict)
|
|
518
|
+
for p in directory.glob("req-*.md")
|
|
519
|
+
],
|
|
520
|
+
key=lambda r: r.id or "",
|
|
521
|
+
)
|
|
522
|
+
return RequirementCollection(task_id=task_id, requirements=tuple(records))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def save_requirements(
|
|
526
|
+
workspace_root: Path, collection: RequirementCollection
|
|
527
|
+
) -> RequirementCollection:
|
|
528
|
+
paths = ensure_v2_layout(workspace_root)
|
|
529
|
+
_ensure_task_bundle(paths, collection.task_id)
|
|
530
|
+
directory = task_requirements_dir(paths, collection.task_id)
|
|
531
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
532
|
+
keep_ids = set()
|
|
533
|
+
now = utc_now_iso()
|
|
534
|
+
for req in collection.requirements:
|
|
535
|
+
req_id = req.id or _requirement_id_from_task(req.task_id)
|
|
536
|
+
keep_ids.add(req_id)
|
|
537
|
+
metadata = req.to_dict()
|
|
538
|
+
metadata["id"] = req_id
|
|
539
|
+
metadata["task_id"] = collection.task_id
|
|
540
|
+
metadata["required_task_id"] = req.required_task_id or req.task_id
|
|
541
|
+
metadata["file_version"] = TASKLEDGER_V2_FILE_VERSION
|
|
542
|
+
metadata["schema_version"] = TASKLEDGER_SCHEMA_VERSION
|
|
543
|
+
metadata["object_type"] = "requirement"
|
|
544
|
+
if metadata.get("created_at") is None:
|
|
545
|
+
metadata["created_at"] = now
|
|
546
|
+
if metadata.get("updated_at") is None:
|
|
547
|
+
metadata["updated_at"] = now
|
|
548
|
+
body = (
|
|
549
|
+
f"Requires {req.required_task_id or req.task_id}"
|
|
550
|
+
f" to be {req.required_status}."
|
|
551
|
+
)
|
|
552
|
+
path = requirement_markdown_path(paths, collection.task_id, req_id)
|
|
553
|
+
_write_markdown_record(path, metadata, body)
|
|
554
|
+
# Remove stale files
|
|
555
|
+
for path in directory.glob("req-*.md"):
|
|
556
|
+
if path.stem not in keep_ids:
|
|
557
|
+
path.unlink()
|
|
558
|
+
return collection
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def task_dir(paths: V2Paths, task_id: str) -> Path:
|
|
562
|
+
return paths.tasks_dir / task_id
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def task_markdown_path(paths: V2Paths, task_id: str) -> Path:
|
|
566
|
+
return task_dir(paths, task_id) / "task.md"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def task_lock_path(paths: V2Paths, task_id: str) -> Path:
|
|
570
|
+
return task_dir(paths, task_id) / "lock.yaml"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def task_todos_dir(paths: V2Paths, task_id: str) -> Path:
|
|
574
|
+
return task_dir(paths, task_id) / "todos"
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def task_links_dir(paths: V2Paths, task_id: str) -> Path:
|
|
578
|
+
return task_dir(paths, task_id) / "links"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def task_requirements_dir(paths: V2Paths, task_id: str) -> Path:
|
|
582
|
+
return task_dir(paths, task_id) / "requirements"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def task_todos_path(paths: V2Paths, task_id: str) -> Path:
|
|
586
|
+
return task_dir(paths, task_id) / "todos.yaml"
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def task_links_path(paths: V2Paths, task_id: str) -> Path:
|
|
590
|
+
return task_dir(paths, task_id) / "links.yaml"
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def task_requirements_path(paths: V2Paths, task_id: str) -> Path:
|
|
594
|
+
return task_dir(paths, task_id) / "requirements.yaml"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def todo_markdown_path(paths: V2Paths, task_id: str, todo_id: str) -> Path:
|
|
598
|
+
return task_todos_dir(paths, task_id) / f"{todo_id}.md"
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def link_markdown_path(paths: V2Paths, task_id: str, link_id: str) -> Path:
|
|
602
|
+
return task_links_dir(paths, task_id) / f"{link_id}.md"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def requirement_markdown_path(paths: V2Paths, task_id: str, req_id: str) -> Path:
|
|
606
|
+
return task_requirements_dir(paths, task_id) / f"{req_id}.md"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def task_plans_dir(paths: V2Paths, task_id: str) -> Path:
|
|
610
|
+
return task_dir(paths, task_id) / "plans"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def task_questions_dir(paths: V2Paths, task_id: str) -> Path:
|
|
614
|
+
return task_dir(paths, task_id) / "questions"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def task_runs_dir(paths: V2Paths, task_id: str) -> Path:
|
|
618
|
+
return task_dir(paths, task_id) / "runs"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def task_changes_dir(paths: V2Paths, task_id: str) -> Path:
|
|
622
|
+
return task_dir(paths, task_id) / "changes"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def task_artifacts_dir(paths: V2Paths, task_id: str) -> Path:
|
|
626
|
+
return task_dir(paths, task_id) / "artifacts"
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def task_audit_dir(paths: V2Paths, task_id: str) -> Path:
|
|
630
|
+
return task_dir(paths, task_id) / "audit"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def task_handoffs_dir(paths: V2Paths, task_id: str) -> Path:
|
|
634
|
+
return task_dir(paths, task_id) / "handoffs"
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def handoff_markdown_path(paths: V2Paths, task_id: str, handoff_id: str) -> Path:
|
|
638
|
+
return task_handoffs_dir(paths, task_id) / f"{handoff_id}.md"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def plan_markdown_path(paths: V2Paths, task_id: str, version: int) -> Path:
|
|
642
|
+
return task_plans_dir(paths, task_id) / f"plan-v{version}.md"
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def question_markdown_path(paths: V2Paths, task_id: str, question_id: str) -> Path:
|
|
646
|
+
return task_questions_dir(paths, task_id) / f"{question_id}.md"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def run_markdown_path(paths: V2Paths, task_id: str, run_id: str) -> Path:
|
|
650
|
+
return task_runs_dir(paths, task_id) / f"{run_id}.md"
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def change_markdown_path(paths: V2Paths, task_id: str, change_id: str) -> Path:
|
|
654
|
+
return task_changes_dir(paths, task_id) / f"{change_id}.md"
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _load_task(path: Path) -> TaskRecord:
|
|
658
|
+
return _load_record(path, TaskRecord.from_dict)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _load_intro(path: Path) -> IntroductionRecord:
|
|
662
|
+
return _load_record(path, IntroductionRecord.from_dict)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _load_plan(path: Path) -> PlanRecord:
|
|
666
|
+
return _load_record(path, PlanRecord.from_dict)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _load_question(path: Path) -> QuestionRecord:
|
|
670
|
+
return _load_record(path, QuestionRecord.from_dict)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _load_run(path: Path) -> TaskRunRecord:
|
|
674
|
+
return _load_record(path, TaskRunRecord.from_dict)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _load_change(path: Path) -> CodeChangeRecord:
|
|
678
|
+
return _load_record(path, CodeChangeRecord.from_dict)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _load_record(path: Path, parser: Callable[[dict[str, object]], T]) -> T:
|
|
682
|
+
metadata, body = read_markdown_front_matter(path)
|
|
683
|
+
metadata["body"] = normalize_front_matter_newlines(body).rstrip("\n")
|
|
684
|
+
metadata = _ensure_schema_compat(metadata)
|
|
685
|
+
return parser(metadata)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _ensure_schema_compat(record: dict) -> dict:
|
|
689
|
+
"""Ensure record schema is compatible."""
|
|
690
|
+
version = record.get("schema_version", 1)
|
|
691
|
+
if version > TASKLEDGER_SCHEMA_VERSION:
|
|
692
|
+
raise LaunchError(
|
|
693
|
+
f"Record schema too new: {version} "
|
|
694
|
+
f"(current max: {TASKLEDGER_SCHEMA_VERSION}). "
|
|
695
|
+
"Please upgrade taskledger."
|
|
696
|
+
)
|
|
697
|
+
return record
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _write_markdown_record(path: Path, metadata: dict[str, object], body: str) -> None:
|
|
701
|
+
metadata = dict(metadata)
|
|
702
|
+
metadata.pop("body", None)
|
|
703
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
704
|
+
write_markdown_front_matter(path, metadata, body.rstrip() + "\n")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _ensure_task_bundle(paths: V2Paths, task_id: str) -> None:
|
|
708
|
+
for directory in (
|
|
709
|
+
task_dir(paths, task_id),
|
|
710
|
+
task_plans_dir(paths, task_id),
|
|
711
|
+
task_questions_dir(paths, task_id),
|
|
712
|
+
task_todos_dir(paths, task_id),
|
|
713
|
+
task_links_dir(paths, task_id),
|
|
714
|
+
task_requirements_dir(paths, task_id),
|
|
715
|
+
task_runs_dir(paths, task_id),
|
|
716
|
+
task_changes_dir(paths, task_id),
|
|
717
|
+
task_artifacts_dir(paths, task_id),
|
|
718
|
+
task_audit_dir(paths, task_id),
|
|
719
|
+
task_handoffs_dir(paths, task_id),
|
|
720
|
+
):
|
|
721
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _write_yaml(path: Path, payload: dict[str, object]) -> None:
|
|
725
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
726
|
+
atomic_write_text(path, yaml.safe_dump(payload, sort_keys=False))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _normalize_numeric_ref(ref: str, prefix: str) -> str:
|
|
730
|
+
raw_prefix = f"{prefix}-"
|
|
731
|
+
if not ref.startswith(raw_prefix):
|
|
732
|
+
return ref
|
|
733
|
+
suffix = ref.removeprefix(raw_prefix)
|
|
734
|
+
if not suffix.isdigit():
|
|
735
|
+
return ref
|
|
736
|
+
return f"{prefix}-{int(suffix):04d}"
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _render_question_body(question: QuestionRecord) -> str:
|
|
740
|
+
lines = ["## Question", "", question.question.strip()]
|
|
741
|
+
lines.extend(["", "## Answer", "", (question.answer or "").strip()])
|
|
742
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _render_run_body(run: TaskRunRecord) -> str:
|
|
746
|
+
lines: list[str] = ["## Summary", "", (run.summary or "").strip()]
|
|
747
|
+
if run.run_type == "validation":
|
|
748
|
+
lines.extend(["", "## Checks", ""])
|
|
749
|
+
for check in run.checks:
|
|
750
|
+
mark = "x" if check.status == "pass" else " "
|
|
751
|
+
lines.append(f"- [{mark}] {check.name}")
|
|
752
|
+
lines.extend(["", "## Evidence", ""])
|
|
753
|
+
for entry in run.evidence:
|
|
754
|
+
lines.append(f"- {entry}")
|
|
755
|
+
lines.extend(["", "## Recommendation", "", (run.recommendation or "").strip()])
|
|
756
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def list_handoffs(workspace_root: Path, task_id: str) -> list[TaskHandoffRecord]:
|
|
760
|
+
handoffs, errors = list_handoffs_with_errors(workspace_root, task_id)
|
|
761
|
+
if errors:
|
|
762
|
+
raise LaunchError(errors[0])
|
|
763
|
+
return handoffs
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def list_handoffs_with_errors(
|
|
767
|
+
workspace_root: Path,
|
|
768
|
+
task_id: str,
|
|
769
|
+
) -> tuple[list[TaskHandoffRecord], list[str]]:
|
|
770
|
+
paths = resolve_v2_paths(workspace_root)
|
|
771
|
+
handoffs_dir = task_handoffs_dir(paths, task_id)
|
|
772
|
+
if not handoffs_dir.exists():
|
|
773
|
+
return [], []
|
|
774
|
+
result: list[TaskHandoffRecord] = []
|
|
775
|
+
errors: list[str] = []
|
|
776
|
+
for md_file in handoffs_dir.glob("*.md"):
|
|
777
|
+
try:
|
|
778
|
+
metadata, _ = read_markdown_front_matter(md_file)
|
|
779
|
+
metadata = dict(metadata)
|
|
780
|
+
metadata["context_body"] = ""
|
|
781
|
+
handoff = TaskHandoffRecord.from_dict(metadata)
|
|
782
|
+
result.append(handoff)
|
|
783
|
+
except Exception as exc:
|
|
784
|
+
label = _path_label(workspace_root, md_file)
|
|
785
|
+
errors.append(f"Malformed handoff record {label}: {exc}")
|
|
786
|
+
return sorted(result, key=lambda h: h.created_at), errors
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def resolve_handoff(
|
|
790
|
+
workspace_root: Path, task_id: str, handoff_ref: str
|
|
791
|
+
) -> TaskHandoffRecord:
|
|
792
|
+
paths = resolve_v2_paths(workspace_root)
|
|
793
|
+
path = handoff_markdown_path(paths, task_id, handoff_ref)
|
|
794
|
+
if not path.exists():
|
|
795
|
+
raise LaunchError(f"Handoff not found: {handoff_ref}")
|
|
796
|
+
metadata, body = read_markdown_front_matter(path)
|
|
797
|
+
metadata = dict(metadata)
|
|
798
|
+
metadata["context_body"] = body or str(metadata.get("context_body") or "")
|
|
799
|
+
return TaskHandoffRecord.from_dict(metadata)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _path_label(workspace_root: Path, path: Path) -> str:
|
|
803
|
+
try:
|
|
804
|
+
return str(path.relative_to(workspace_root))
|
|
805
|
+
except ValueError:
|
|
806
|
+
return str(path)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def save_handoff(workspace_root: Path, handoff: TaskHandoffRecord) -> Path:
|
|
810
|
+
paths = resolve_v2_paths(workspace_root)
|
|
811
|
+
handoffs_dir = task_handoffs_dir(paths, handoff.task_id)
|
|
812
|
+
handoffs_dir.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
path = handoff_markdown_path(paths, handoff.task_id, handoff.handoff_id)
|
|
814
|
+
metadata = handoff.to_dict()
|
|
815
|
+
metadata.pop("context_body", None)
|
|
816
|
+
content = handoff.context_body or ""
|
|
817
|
+
_write_markdown_record(path, metadata, content)
|
|
818
|
+
return path
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def resolve_lock(workspace_root: Path, task_id: str) -> TaskLock | None:
|
|
822
|
+
"""Resolve a lock by task ID."""
|
|
823
|
+
paths = resolve_v2_paths(workspace_root)
|
|
824
|
+
lock_path = task_lock_path(paths, task_id)
|
|
825
|
+
return read_lock(lock_path)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def save_lock(workspace_root: Path, task_id: str, lock: TaskLock) -> Path:
|
|
829
|
+
"""Save a lock record (creates if new, updates if exists)."""
|
|
830
|
+
paths = resolve_v2_paths(workspace_root)
|
|
831
|
+
lock_path = task_lock_path(paths, task_id)
|
|
832
|
+
if lock_path.exists():
|
|
833
|
+
update_lock(lock_path, lock)
|
|
834
|
+
else:
|
|
835
|
+
write_lock(lock_path, lock)
|
|
836
|
+
return lock_path
|