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.
Files changed (67) hide show
  1. taskledger/__init__.py +5 -0
  2. taskledger/__main__.py +6 -0
  3. taskledger/_version.py +24 -0
  4. taskledger/api/__init__.py +13 -0
  5. taskledger/api/handoff.py +247 -0
  6. taskledger/api/introductions.py +9 -0
  7. taskledger/api/locks.py +4 -0
  8. taskledger/api/plans.py +31 -0
  9. taskledger/api/project.py +185 -0
  10. taskledger/api/questions.py +19 -0
  11. taskledger/api/search.py +87 -0
  12. taskledger/api/task_runs.py +38 -0
  13. taskledger/api/tasks.py +61 -0
  14. taskledger/cli.py +600 -0
  15. taskledger/cli_actor.py +196 -0
  16. taskledger/cli_common.py +617 -0
  17. taskledger/cli_implement.py +409 -0
  18. taskledger/cli_migrate.py +328 -0
  19. taskledger/cli_misc.py +984 -0
  20. taskledger/cli_plan.py +478 -0
  21. taskledger/cli_question.py +350 -0
  22. taskledger/cli_task.py +257 -0
  23. taskledger/cli_validate.py +285 -0
  24. taskledger/command_inventory.py +125 -0
  25. taskledger/domain/__init__.py +2 -0
  26. taskledger/domain/models.py +1697 -0
  27. taskledger/domain/policies.py +542 -0
  28. taskledger/domain/states.py +320 -0
  29. taskledger/errors.py +165 -0
  30. taskledger/exchange.py +343 -0
  31. taskledger/ids.py +19 -0
  32. taskledger/py.typed +0 -0
  33. taskledger/search.py +349 -0
  34. taskledger/services/__init__.py +1 -0
  35. taskledger/services/actors.py +245 -0
  36. taskledger/services/dashboard.py +306 -0
  37. taskledger/services/doctor.py +435 -0
  38. taskledger/services/handoff.py +1029 -0
  39. taskledger/services/handoff_lifecycle.py +154 -0
  40. taskledger/services/navigation.py +930 -0
  41. taskledger/services/phase5_lock_transfer.py +96 -0
  42. taskledger/services/plan_lint.py +397 -0
  43. taskledger/services/serve_read_model.py +852 -0
  44. taskledger/services/tasks.py +4224 -0
  45. taskledger/services/validation.py +221 -0
  46. taskledger/services/web_dashboard.py +1742 -0
  47. taskledger/storage/__init__.py +39 -0
  48. taskledger/storage/atomic.py +57 -0
  49. taskledger/storage/common.py +90 -0
  50. taskledger/storage/events.py +98 -0
  51. taskledger/storage/frontmatter.py +57 -0
  52. taskledger/storage/indexes.py +42 -0
  53. taskledger/storage/init.py +187 -0
  54. taskledger/storage/locks.py +83 -0
  55. taskledger/storage/meta.py +103 -0
  56. taskledger/storage/migrations.py +207 -0
  57. taskledger/storage/paths.py +166 -0
  58. taskledger/storage/project_config.py +393 -0
  59. taskledger/storage/repos.py +256 -0
  60. taskledger/storage/task_store.py +836 -0
  61. taskledger/timeutils.py +7 -0
  62. taskledger-0.1.0.dist-info/METADATA +411 -0
  63. taskledger-0.1.0.dist-info/RECORD +67 -0
  64. taskledger-0.1.0.dist-info/WHEEL +5 -0
  65. taskledger-0.1.0.dist-info/entry_points.txt +2 -0
  66. taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  67. taskledger-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1697 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal, cast
5
+
6
+ from taskledger.domain.states import (
7
+ TASKLEDGER_SCHEMA_VERSION,
8
+ TASKLEDGER_V2_FILE_VERSION,
9
+ ActiveTaskStatusStage,
10
+ ContextFor,
11
+ ContextFormat,
12
+ ContextScope,
13
+ FileLinkKind,
14
+ PlanStatus,
15
+ QuestionStatus,
16
+ RunStatus,
17
+ RunType,
18
+ TaskStatusStage,
19
+ ValidationCheckStatus,
20
+ ValidationResult,
21
+ normalize_actor_role,
22
+ normalize_actor_type,
23
+ normalize_context_for,
24
+ normalize_context_format,
25
+ normalize_context_scope,
26
+ normalize_file_link_kind,
27
+ normalize_handoff_mode,
28
+ normalize_handoff_status,
29
+ normalize_harness_kind,
30
+ normalize_lock_policy,
31
+ normalize_plan_status,
32
+ normalize_question_status,
33
+ normalize_run_status,
34
+ normalize_run_type,
35
+ normalize_task_status_stage,
36
+ normalize_validation_check_status,
37
+ normalize_validation_result,
38
+ )
39
+ from taskledger.errors import LaunchError
40
+ from taskledger.timeutils import utc_now_iso
41
+
42
+
43
+ @dataclass(slots=True, frozen=True)
44
+ class ActorRef:
45
+ actor_type: Literal["agent", "user", "system"] = "agent"
46
+ actor_name: str = "taskledger"
47
+ tool: str | None = None
48
+ session_id: str | None = None
49
+ host: str | None = None
50
+ pid: int | None = None
51
+ actor_id: str | None = None
52
+ role: (
53
+ Literal["planner", "implementer", "validator", "reviewer", "operator"] | None
54
+ ) = None
55
+ harness_id: str | None = None
56
+
57
+ def to_dict(self) -> dict[str, object]:
58
+ return {
59
+ "actor_type": self.actor_type,
60
+ "actor_name": self.actor_name,
61
+ "tool": self.tool,
62
+ "session_id": self.session_id,
63
+ "host": self.host,
64
+ "pid": self.pid,
65
+ "actor_id": self.actor_id,
66
+ "role": self.role,
67
+ "harness_id": self.harness_id,
68
+ }
69
+
70
+ @classmethod
71
+ def from_dict(cls, data: object) -> ActorRef:
72
+ if not isinstance(data, dict):
73
+ return cls()
74
+ raw_actor_type = _optional_string(data.get("actor_type")) or "agent"
75
+ actor_type = normalize_actor_type(raw_actor_type)
76
+ pid = data.get("pid")
77
+ raw_role = _optional_string(data.get("role"))
78
+ role = normalize_actor_role(raw_role) if raw_role else None
79
+ return cls(
80
+ actor_type=actor_type,
81
+ actor_name=_optional_string(data.get("actor_name")) or "taskledger",
82
+ tool=_optional_string(data.get("tool")),
83
+ session_id=_optional_string(data.get("session_id")),
84
+ host=_optional_string(data.get("host")),
85
+ pid=pid if isinstance(pid, int) else None,
86
+ actor_id=_optional_string(data.get("actor_id")),
87
+ role=role,
88
+ harness_id=_optional_string(data.get("harness_id")),
89
+ )
90
+
91
+
92
+ @dataclass(slots=True, frozen=True)
93
+ class HarnessRef:
94
+ harness_id: str
95
+ name: str
96
+ kind: Literal["agent_harness", "manual", "ci", "unknown"] = "unknown"
97
+ session_id: str | None = None
98
+ working_directory: str | None = None
99
+ command: str | None = None
100
+ version: str | None = None
101
+ capabilities: tuple[str, ...] = ()
102
+ created_at: str = field(default_factory=utc_now_iso)
103
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
104
+ object_type: str = "harness"
105
+
106
+ def to_dict(self) -> dict[str, object]:
107
+ return {
108
+ "harness_id": self.harness_id,
109
+ "name": self.name,
110
+ "kind": self.kind,
111
+ "session_id": self.session_id,
112
+ "working_directory": self.working_directory,
113
+ "command": self.command,
114
+ "version": self.version,
115
+ "capabilities": self.capabilities,
116
+ "created_at": self.created_at,
117
+ "schema_version": self.schema_version,
118
+ "object_type": self.object_type,
119
+ }
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: object) -> HarnessRef:
123
+ if not isinstance(data, dict):
124
+ raise LaunchError("Invalid harness data: expected mapping")
125
+ _require_contract(data, expected_object_type="harness")
126
+ return cls(
127
+ harness_id=_string_value(data, "harness_id"),
128
+ name=_string_value(data, "name"),
129
+ kind=normalize_harness_kind(
130
+ _optional_string(data.get("kind")) or "unknown"
131
+ ),
132
+ session_id=_optional_string(data.get("session_id")),
133
+ working_directory=_optional_string(data.get("working_directory")),
134
+ command=_optional_string(data.get("command")),
135
+ version=_optional_string(data.get("version")),
136
+ capabilities=tuple(_optional_list_string(data.get("capabilities")) or []),
137
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
138
+ schema_version=_int_value(data, "schema_version"),
139
+ )
140
+
141
+
142
+ @dataclass(slots=True, frozen=True)
143
+ class TaskHandoffRecord:
144
+ handoff_id: str
145
+ task_id: str
146
+ mode: Literal["planning", "implementation", "validation", "review", "full"]
147
+ context_for: ContextFor | None = field(default=None)
148
+ scope: ContextScope = field(default="task")
149
+ todo_id: str | None = field(default=None)
150
+ focus_run_id: str | None = field(default=None)
151
+ context_format: ContextFormat = field(default="markdown")
152
+ context_hash: str | None = field(default=None)
153
+ generated_at: str | None = field(default=None)
154
+
155
+ status: Literal["open", "claimed", "closed", "cancelled"] = field(default="open")
156
+ lock_policy: Literal["none", "retain", "release", "transfer"] = field(
157
+ default="none"
158
+ )
159
+ context_body: str = field(default="")
160
+ file_version: str = field(default=TASKLEDGER_V2_FILE_VERSION)
161
+ schema_version: int = field(default=TASKLEDGER_SCHEMA_VERSION)
162
+ object_type: str = field(default="handoff")
163
+
164
+ created_from_harness: HarnessRef | None = field(default=None)
165
+ intended_actor_type: Literal["agent", "user", "system"] | None = field(default=None)
166
+ intended_actor_name: str | None = field(default=None)
167
+ intended_harness: str | None = field(default=None)
168
+ source_run_id: str | None = field(default=None)
169
+ resumes_run_id: str | None = field(default=None)
170
+ claim_run_id: str | None = field(default=None)
171
+ released_lock_id: str | None = field(default=None)
172
+ claimed_at: str | None = field(default=None)
173
+ claimed_by: ActorRef | None = field(default=None)
174
+ claimed_in_harness: HarnessRef | None = field(default=None)
175
+ summary: str | None = field(default=None)
176
+ next_action: str | None = field(default=None)
177
+
178
+ created_at: str = field(default_factory=utc_now_iso)
179
+ created_by: ActorRef = field(default_factory=ActorRef)
180
+
181
+ def to_dict(self) -> dict[str, object]:
182
+ return {
183
+ "handoff_id": self.handoff_id,
184
+ "task_id": self.task_id,
185
+ "mode": self.mode,
186
+ "context_for": self.context_for,
187
+ "scope": self.scope,
188
+ "todo_id": self.todo_id,
189
+ "focus_run_id": self.focus_run_id,
190
+ "context_format": self.context_format,
191
+ "context_hash": self.context_hash,
192
+ "generated_at": self.generated_at,
193
+ "status": self.status,
194
+ "created_at": self.created_at,
195
+ "created_by": self.created_by.to_dict(),
196
+ "created_from_harness": self.created_from_harness.to_dict()
197
+ if self.created_from_harness
198
+ else None,
199
+ "intended_actor_type": self.intended_actor_type,
200
+ "intended_actor_name": self.intended_actor_name,
201
+ "intended_harness": self.intended_harness,
202
+ "source_run_id": self.source_run_id,
203
+ "resumes_run_id": self.resumes_run_id,
204
+ "claim_run_id": self.claim_run_id,
205
+ "lock_policy": self.lock_policy,
206
+ "released_lock_id": self.released_lock_id,
207
+ "claimed_at": self.claimed_at,
208
+ "claimed_by": self.claimed_by.to_dict() if self.claimed_by else None,
209
+ "claimed_in_harness": self.claimed_in_harness.to_dict()
210
+ if self.claimed_in_harness
211
+ else None,
212
+ "summary": self.summary,
213
+ "next_action": self.next_action,
214
+ "context_body": self.context_body,
215
+ "file_version": self.file_version,
216
+ "schema_version": self.schema_version,
217
+ "object_type": self.object_type,
218
+ }
219
+
220
+ @classmethod
221
+ def from_dict(cls, data: object) -> TaskHandoffRecord:
222
+ if not isinstance(data, dict):
223
+ raise LaunchError("Invalid handoff record: expected mapping")
224
+ _require_contract(data, expected_object_type="handoff")
225
+ return cls(
226
+ handoff_id=_string_value(data, "handoff_id"),
227
+ task_id=_string_value(data, "task_id"),
228
+ mode=normalize_handoff_mode(_string_value(data, "mode")),
229
+ context_for=(
230
+ normalize_context_for(v)
231
+ if (v := _optional_string(data.get("context_for")))
232
+ else None
233
+ ),
234
+ scope=normalize_context_scope(
235
+ _optional_string(data.get("scope")) or "task"
236
+ ),
237
+ todo_id=_optional_string(data.get("todo_id")),
238
+ focus_run_id=_optional_string(data.get("focus_run_id")),
239
+ context_format=normalize_context_format(
240
+ _optional_string(data.get("context_format")) or "markdown"
241
+ ),
242
+ context_hash=_optional_string(data.get("context_hash")),
243
+ generated_at=_optional_string(data.get("generated_at")),
244
+ status=normalize_handoff_status(
245
+ _optional_string(data.get("status")) or "open"
246
+ ),
247
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
248
+ created_by=ActorRef.from_dict(data.get("created_by")),
249
+ created_from_harness=HarnessRef.from_dict(data.get("created_from_harness"))
250
+ if data.get("created_from_harness")
251
+ else None,
252
+ intended_actor_type=(
253
+ normalize_actor_type(v)
254
+ if (v := _optional_string(data.get("intended_actor_type")))
255
+ else None
256
+ ),
257
+ intended_actor_name=_optional_string(data.get("intended_actor_name")),
258
+ intended_harness=_optional_string(data.get("intended_harness")),
259
+ source_run_id=_optional_string(data.get("source_run_id")),
260
+ resumes_run_id=_optional_string(data.get("resumes_run_id")),
261
+ claim_run_id=_optional_string(data.get("claim_run_id")),
262
+ lock_policy=normalize_lock_policy(
263
+ _optional_string(data.get("lock_policy")) or "none"
264
+ ),
265
+ released_lock_id=_optional_string(data.get("released_lock_id")),
266
+ claimed_at=_optional_string(data.get("claimed_at")),
267
+ claimed_by=ActorRef.from_dict(data.get("claimed_by"))
268
+ if data.get("claimed_by")
269
+ else None,
270
+ claimed_in_harness=HarnessRef.from_dict(data.get("claimed_in_harness"))
271
+ if data.get("claimed_in_harness")
272
+ else None,
273
+ summary=_optional_string(data.get("summary")),
274
+ next_action=_optional_string(data.get("next_action")),
275
+ context_body=_optional_string(data.get("context_body")) or "",
276
+ file_version=_optional_string(data.get("file_version"))
277
+ or TASKLEDGER_V2_FILE_VERSION,
278
+ schema_version=_int_value(data, "schema_version"),
279
+ )
280
+
281
+
282
+ @dataclass(slots=True, frozen=True)
283
+ class ActiveTaskState:
284
+ task_id: str
285
+ activated_at: str = field(default_factory=utc_now_iso)
286
+ activated_by: ActorRef = field(default_factory=ActorRef)
287
+ reason: str | None = None
288
+ previous_task_id: str | None = None
289
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
290
+ object_type: str = "active_task"
291
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
292
+
293
+ def to_dict(self) -> dict[str, object]:
294
+ return {
295
+ "schema_version": self.schema_version,
296
+ "object_type": self.object_type,
297
+ "file_version": self.file_version,
298
+ "task_id": self.task_id,
299
+ "activated_at": self.activated_at,
300
+ "activated_by": self.activated_by.to_dict(),
301
+ "reason": self.reason,
302
+ "previous_task_id": self.previous_task_id,
303
+ }
304
+
305
+ @classmethod
306
+ def from_dict(cls, data: object) -> ActiveTaskState:
307
+ if not isinstance(data, dict):
308
+ raise LaunchError("Invalid active task state: expected mapping.")
309
+ _require_contract(data, expected_object_type="active_task")
310
+ return cls(
311
+ task_id=_string_value(data, "task_id"),
312
+ activated_at=_optional_string(data.get("activated_at")) or utc_now_iso(),
313
+ activated_by=ActorRef.from_dict(data.get("activated_by")),
314
+ reason=_optional_string(data.get("reason")),
315
+ previous_task_id=_optional_string(data.get("previous_task_id")),
316
+ schema_version=_int_value(data, "schema_version"),
317
+ object_type=_string_value(data, "object_type"),
318
+ file_version=_optional_string(data.get("file_version"))
319
+ or TASKLEDGER_V2_FILE_VERSION,
320
+ )
321
+
322
+
323
+ @dataclass(slots=True, frozen=True)
324
+ class ActiveActorState:
325
+ actor_type: Literal["agent", "user", "system"] = "agent"
326
+ actor_name: str = "taskledger"
327
+ role: (
328
+ Literal["planner", "implementer", "validator", "reviewer", "operator"] | None
329
+ ) = None
330
+ tool: str | None = None
331
+ session_id: str | None = None
332
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
333
+ object_type: str = "active_actor"
334
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
335
+
336
+ def to_dict(self) -> dict[str, object]:
337
+ return {
338
+ "schema_version": self.schema_version,
339
+ "object_type": self.object_type,
340
+ "file_version": self.file_version,
341
+ "actor_type": self.actor_type,
342
+ "actor_name": self.actor_name,
343
+ "role": self.role,
344
+ "tool": self.tool,
345
+ "session_id": self.session_id,
346
+ }
347
+
348
+ @classmethod
349
+ def from_dict(cls, data: object) -> ActiveActorState:
350
+ if not isinstance(data, dict):
351
+ raise LaunchError("Invalid active actor state: expected mapping.")
352
+ _require_contract(data, expected_object_type="active_actor")
353
+ raw_role = _optional_string(data.get("role"))
354
+ return cls(
355
+ actor_type=normalize_actor_type(
356
+ _optional_string(data.get("actor_type")) or "agent"
357
+ ),
358
+ actor_name=_optional_string(data.get("actor_name")) or "taskledger",
359
+ role=normalize_actor_role(raw_role) if raw_role else None,
360
+ tool=_optional_string(data.get("tool")),
361
+ session_id=_optional_string(data.get("session_id")),
362
+ schema_version=_int_value(data, "schema_version"),
363
+ object_type=_string_value(data, "object_type"),
364
+ file_version=_optional_string(data.get("file_version"))
365
+ or TASKLEDGER_V2_FILE_VERSION,
366
+ )
367
+
368
+
369
+ @dataclass(slots=True, frozen=True)
370
+ class ActiveHarnessState:
371
+ name: str = "unknown"
372
+ kind: Literal["agent_harness", "manual", "ci", "unknown"] = "unknown"
373
+ session_id: str | None = None
374
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
375
+ object_type: str = "active_harness"
376
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
377
+
378
+ def to_dict(self) -> dict[str, object]:
379
+ return {
380
+ "schema_version": self.schema_version,
381
+ "object_type": self.object_type,
382
+ "file_version": self.file_version,
383
+ "name": self.name,
384
+ "kind": self.kind,
385
+ "session_id": self.session_id,
386
+ }
387
+
388
+ @classmethod
389
+ def from_dict(cls, data: object) -> ActiveHarnessState:
390
+ if not isinstance(data, dict):
391
+ raise LaunchError("Invalid active harness state: expected mapping.")
392
+ _require_contract(data, expected_object_type="active_harness")
393
+ raw_kind = _optional_string(data.get("kind"))
394
+ return cls(
395
+ name=_optional_string(data.get("name")) or "unknown",
396
+ kind=normalize_harness_kind(raw_kind) if raw_kind else "unknown",
397
+ session_id=_optional_string(data.get("session_id")),
398
+ schema_version=_int_value(data, "schema_version"),
399
+ object_type=_string_value(data, "object_type"),
400
+ file_version=_optional_string(data.get("file_version"))
401
+ or TASKLEDGER_V2_FILE_VERSION,
402
+ )
403
+
404
+
405
+ @dataclass(slots=True, frozen=True)
406
+ class FileLink:
407
+ path: str
408
+ kind: FileLinkKind = "code"
409
+ label: str | None = None
410
+ required_for_validation: bool = False
411
+ id: str | None = None
412
+ task_id: str | None = None
413
+ target_type: str | None = None
414
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
415
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
416
+ object_type: str = "link"
417
+ created_at: str = field(default_factory=utc_now_iso)
418
+ updated_at: str = field(default_factory=utc_now_iso)
419
+
420
+ def to_dict(self) -> dict[str, object]:
421
+ return {
422
+ "id": self.id,
423
+ "task_id": self.task_id,
424
+ "path": self.path,
425
+ "kind": self.kind,
426
+ "label": self.label,
427
+ "required_for_validation": self.required_for_validation,
428
+ "target_type": self.target_type,
429
+ "file_version": self.file_version,
430
+ "schema_version": self.schema_version,
431
+ "object_type": self.object_type,
432
+ "created_at": self.created_at,
433
+ "updated_at": self.updated_at,
434
+ }
435
+
436
+ @classmethod
437
+ def from_dict(cls, data: object) -> FileLink:
438
+ if not isinstance(data, dict):
439
+ raise LaunchError("Invalid file link: expected mapping.")
440
+ _require_sidecar_contract(data, expected_object_type="link")
441
+ return cls(
442
+ id=_optional_string(data.get("id")),
443
+ task_id=_optional_string(data.get("task_id")),
444
+ path=_string_value(data, "path"),
445
+ kind=normalize_file_link_kind(_optional_string(data.get("kind")) or "code"),
446
+ label=_optional_string(data.get("label")),
447
+ required_for_validation=bool(data.get("required_for_validation", False)),
448
+ target_type=_optional_string(data.get("target_type")),
449
+ file_version=_optional_string(data.get("file_version"))
450
+ or TASKLEDGER_V2_FILE_VERSION,
451
+ schema_version=_int_or_default(
452
+ data.get("schema_version"), TASKLEDGER_SCHEMA_VERSION
453
+ ),
454
+ object_type=_optional_string(data.get("object_type")) or "link",
455
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
456
+ updated_at=_optional_string(data.get("updated_at")) or utc_now_iso(),
457
+ )
458
+
459
+
460
+ @dataclass(slots=True, frozen=True)
461
+ class TaskTodo:
462
+ id: str
463
+ text: str
464
+ done: bool = False
465
+ created_at: str = field(default_factory=utc_now_iso)
466
+ updated_at: str = field(default_factory=utc_now_iso)
467
+ source: str | None = None
468
+ mandatory: bool = False
469
+ # Extended fields for richer todo tracking
470
+ status: str = "open" # Will be validated to TodoStatus
471
+ active_at: str | None = None
472
+ blocked_reason: str | None = None
473
+ done_at: str | None = None
474
+ skipped_at: str | None = None
475
+ completed_by: ActorRef | None = None
476
+ completed_in_harness: HarnessRef | None = None
477
+ skipped_by: ActorRef | None = None
478
+ evidence: tuple[str, ...] = field(default_factory=tuple)
479
+ artifact_refs: tuple[str, ...] = field(default_factory=tuple)
480
+ change_refs: tuple[str, ...] = field(default_factory=tuple)
481
+ command_refs: tuple[str, ...] = field(default_factory=tuple)
482
+ source_plan_id: str | None = None
483
+ source_question_ids: tuple[str, ...] = field(default_factory=tuple)
484
+ validation_hint: str | None = None
485
+
486
+ def to_dict(self) -> dict[str, object]:
487
+ return {
488
+ "id": self.id,
489
+ "text": self.text,
490
+ "done": self.done,
491
+ "created_at": self.created_at,
492
+ "updated_at": self.updated_at,
493
+ "source": self.source,
494
+ "mandatory": self.mandatory,
495
+ "status": self.status,
496
+ "active_at": self.active_at,
497
+ "blocked_reason": self.blocked_reason,
498
+ "done_at": self.done_at,
499
+ "skipped_at": self.skipped_at,
500
+ "completed_by": self.completed_by.to_dict() if self.completed_by else None,
501
+ "completed_in_harness": (
502
+ self.completed_in_harness.to_dict()
503
+ if self.completed_in_harness
504
+ else None
505
+ ),
506
+ "skipped_by": self.skipped_by.to_dict() if self.skipped_by else None,
507
+ "evidence": list(self.evidence),
508
+ "artifact_refs": list(self.artifact_refs),
509
+ "change_refs": self.change_refs,
510
+ "command_refs": self.command_refs,
511
+ "source_plan_id": self.source_plan_id,
512
+ "source_question_ids": list(self.source_question_ids),
513
+ "validation_hint": self.validation_hint,
514
+ }
515
+
516
+ @classmethod
517
+ def from_dict(cls, data: object) -> TaskTodo:
518
+ if not isinstance(data, dict):
519
+ raise LaunchError("Invalid todo: expected mapping.")
520
+
521
+ # Enforce version compatibility for v2 records
522
+ _require_sidecar_contract(data, expected_object_type="todo")
523
+
524
+ # Handle backward compatibility: infer status from done field if not present
525
+ status_raw = _optional_string(data.get("status"))
526
+ if status_raw is None:
527
+ status = "done" if bool(data.get("done", False)) else "open"
528
+ else:
529
+ from taskledger.domain.states import normalize_todo_status
530
+
531
+ status = normalize_todo_status(status_raw)
532
+
533
+ # Parse completed_by and skipped_by
534
+ completed_by_data = data.get("completed_by")
535
+ completed_by = (
536
+ ActorRef.from_dict(completed_by_data) if completed_by_data else None
537
+ )
538
+ completed_in_harness_data = data.get("completed_in_harness")
539
+ completed_in_harness = (
540
+ HarnessRef.from_dict(completed_in_harness_data)
541
+ if completed_in_harness_data
542
+ else None
543
+ )
544
+ skipped_by_data = data.get("skipped_by")
545
+ skipped_by = ActorRef.from_dict(skipped_by_data) if skipped_by_data else None
546
+
547
+ # Parse evidence and refs
548
+ evidence = _string_tuple(data.get("evidence"))
549
+ artifact_refs = _string_tuple(data.get("artifact_refs")) or _string_tuple(
550
+ data.get("artifacts")
551
+ )
552
+ change_refs = _string_tuple(data.get("change_refs")) or _string_tuple(
553
+ data.get("changes")
554
+ )
555
+ command_refs = _string_tuple(data.get("command_refs"))
556
+
557
+ return cls(
558
+ id=_string_value(data, "id"),
559
+ text=_string_value(data, "text"),
560
+ done=bool(data.get("done", False)),
561
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
562
+ updated_at=_optional_string(data.get("updated_at")) or utc_now_iso(),
563
+ source=_optional_string(data.get("source")),
564
+ mandatory=bool(data.get("mandatory", False)),
565
+ status=status,
566
+ active_at=_optional_string(data.get("active_at")),
567
+ blocked_reason=_optional_string(data.get("blocked_reason")),
568
+ done_at=_optional_string(data.get("done_at")),
569
+ skipped_at=_optional_string(data.get("skipped_at")),
570
+ completed_by=completed_by,
571
+ completed_in_harness=completed_in_harness,
572
+ skipped_by=skipped_by,
573
+ evidence=evidence,
574
+ artifact_refs=artifact_refs,
575
+ change_refs=change_refs,
576
+ command_refs=command_refs,
577
+ source_plan_id=_optional_string(data.get("source_plan_id")),
578
+ source_question_ids=_string_tuple(data.get("source_question_ids")),
579
+ validation_hint=_optional_string(data.get("validation_hint")),
580
+ )
581
+
582
+
583
+ @dataclass(slots=True, frozen=True)
584
+ class AcceptanceCriterion:
585
+ id: str
586
+ text: str
587
+ mandatory: bool = True
588
+
589
+ def to_dict(self) -> dict[str, object]:
590
+ return {
591
+ "id": self.id,
592
+ "text": self.text,
593
+ "mandatory": self.mandatory,
594
+ }
595
+
596
+ @classmethod
597
+ def from_dict(cls, data: object) -> AcceptanceCriterion:
598
+ if not isinstance(data, dict):
599
+ raise LaunchError("Invalid acceptance criterion: expected mapping.")
600
+ return cls(
601
+ id=_string_value(data, "id"),
602
+ text=_string_value(data, "text"),
603
+ mandatory=bool(data.get("mandatory", True)),
604
+ )
605
+
606
+
607
+ @dataclass(slots=True, frozen=True)
608
+ class CriterionWaiver:
609
+ actor: ActorRef = field(default_factory=ActorRef)
610
+ reason: str = ""
611
+ created_at: str = field(default_factory=utc_now_iso)
612
+
613
+ def to_dict(self) -> dict[str, object]:
614
+ return {
615
+ "actor": self.actor.to_dict(),
616
+ "reason": self.reason,
617
+ "created_at": self.created_at,
618
+ }
619
+
620
+ @classmethod
621
+ def from_dict(cls, data: object) -> CriterionWaiver | None:
622
+ if data is None:
623
+ return None
624
+ if not isinstance(data, dict):
625
+ raise LaunchError("Invalid criterion waiver: expected mapping.")
626
+ return cls(
627
+ actor=ActorRef.from_dict(data.get("actor")),
628
+ reason=_string_value(data, "reason"),
629
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
630
+ )
631
+
632
+
633
+ @dataclass(slots=True, frozen=True)
634
+ class DependencyWaiver:
635
+ actor: ActorRef = field(default_factory=ActorRef)
636
+ reason: str = ""
637
+ created_at: str = field(default_factory=utc_now_iso)
638
+
639
+ def to_dict(self) -> dict[str, object]:
640
+ return {
641
+ "actor": self.actor.to_dict(),
642
+ "reason": self.reason,
643
+ "created_at": self.created_at,
644
+ }
645
+
646
+ @classmethod
647
+ def from_dict(cls, data: object) -> DependencyWaiver | None:
648
+ if data is None:
649
+ return None
650
+ if not isinstance(data, dict):
651
+ raise LaunchError("Invalid dependency waiver: expected mapping.")
652
+ return cls(
653
+ actor=ActorRef.from_dict(data.get("actor")),
654
+ reason=_string_value(data, "reason"),
655
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
656
+ )
657
+
658
+
659
+ @dataclass(slots=True, frozen=True)
660
+ class DependencyRequirement:
661
+ task_id: str
662
+ required_status: str = "done"
663
+ waiver: DependencyWaiver | None = None
664
+ id: str | None = None
665
+ required_task_id: str | None = None
666
+ parent_task_id: str | None = None
667
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
668
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
669
+ object_type: str = "requirement"
670
+ created_at: str = field(default_factory=utc_now_iso)
671
+ updated_at: str = field(default_factory=utc_now_iso)
672
+
673
+ def to_dict(self) -> dict[str, object]:
674
+ return {
675
+ "id": self.id,
676
+ "task_id": self.required_task_id or self.task_id,
677
+ "required_task_id": self.required_task_id or self.task_id,
678
+ "parent_task_id": self.parent_task_id,
679
+ "required_status": self.required_status,
680
+ "waiver": self.waiver.to_dict() if self.waiver is not None else None,
681
+ "file_version": self.file_version,
682
+ "schema_version": self.schema_version,
683
+ "object_type": self.object_type,
684
+ "created_at": self.created_at,
685
+ "updated_at": self.updated_at,
686
+ }
687
+
688
+ @classmethod
689
+ def from_dict(cls, data: object) -> DependencyRequirement:
690
+ if not isinstance(data, dict):
691
+ raise LaunchError("Invalid dependency requirement: expected mapping.")
692
+ _require_sidecar_contract(data, expected_object_type="requirement")
693
+ task_id = _optional_string(data.get("required_task_id")) or _string_value(
694
+ data, "task_id"
695
+ )
696
+ return cls(
697
+ id=_optional_string(data.get("id")),
698
+ task_id=task_id,
699
+ required_task_id=_optional_string(data.get("required_task_id")) or task_id,
700
+ parent_task_id=_optional_string(data.get("parent_task_id")),
701
+ required_status=_optional_string(data.get("required_status")) or "done",
702
+ waiver=DependencyWaiver.from_dict(data.get("waiver")),
703
+ file_version=_optional_string(data.get("file_version"))
704
+ or TASKLEDGER_V2_FILE_VERSION,
705
+ schema_version=_int_or_default(
706
+ data.get("schema_version"), TASKLEDGER_SCHEMA_VERSION
707
+ ),
708
+ object_type=_optional_string(data.get("object_type")) or "requirement",
709
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
710
+ updated_at=_optional_string(data.get("updated_at")) or utc_now_iso(),
711
+ )
712
+
713
+
714
+ @dataclass(slots=True, frozen=True)
715
+ class ValidationCheck:
716
+ name: str
717
+ id: str | None = None
718
+ criterion_id: str | None = None
719
+ status: ValidationCheckStatus = "pass"
720
+ details: str | None = None
721
+ evidence: tuple[str, ...] = ()
722
+ waiver: CriterionWaiver | None = None
723
+
724
+ def to_dict(self) -> dict[str, object]:
725
+ payload: dict[str, object] = {
726
+ "id": self.id,
727
+ "criterion_id": self.criterion_id,
728
+ "name": self.name,
729
+ "status": self.status,
730
+ "details": self.details,
731
+ "evidence": list(self.evidence),
732
+ "waiver": self.waiver.to_dict() if self.waiver is not None else None,
733
+ }
734
+ return payload
735
+
736
+ @classmethod
737
+ def from_dict(cls, data: object) -> ValidationCheck:
738
+ if not isinstance(data, dict):
739
+ raise LaunchError("Invalid validation check: expected mapping.")
740
+ identifier = _optional_string(data.get("id")) or _optional_string(
741
+ data.get("criterion_id")
742
+ )
743
+ status = normalize_validation_check_status(_string_value(data, "status"))
744
+ criterion_id = _optional_string(data.get("criterion_id")) or (
745
+ identifier if status != "not_run" else None
746
+ )
747
+ if status != "not_run" and criterion_id is None:
748
+ raise LaunchError(
749
+ "Validation checks must reference a criterion_id "
750
+ "unless status is not_run."
751
+ )
752
+ return cls(
753
+ id=identifier,
754
+ criterion_id=criterion_id,
755
+ name=_string_value(data, "name"),
756
+ status=status,
757
+ details=_optional_string(data.get("details")),
758
+ evidence=_string_tuple(data.get("evidence")),
759
+ waiver=CriterionWaiver.from_dict(data.get("waiver")),
760
+ )
761
+
762
+
763
+ @dataclass(slots=True, frozen=True)
764
+ class TodoCollection:
765
+ task_id: str
766
+ todos: tuple[TaskTodo, ...] = ()
767
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
768
+ object_type: str = "todos"
769
+
770
+ def to_dict(self) -> dict[str, object]:
771
+ return {
772
+ "schema_version": self.schema_version,
773
+ "object_type": self.object_type,
774
+ "task_id": self.task_id,
775
+ "todos": [item.to_dict() for item in self.todos],
776
+ }
777
+
778
+ @classmethod
779
+ def from_dict(cls, data: dict[str, object]) -> TodoCollection:
780
+ _require_contract(data, expected_object_type="todos")
781
+ return cls(
782
+ task_id=_string_value(data, "task_id"),
783
+ todos=tuple(
784
+ TaskTodo.from_dict(item) for item in _dict_list(data.get("todos"))
785
+ ),
786
+ schema_version=_int_value(data, "schema_version"),
787
+ object_type=_string_value(data, "object_type"),
788
+ )
789
+
790
+
791
+ @dataclass(slots=True, frozen=True)
792
+ class LinkCollection:
793
+ task_id: str
794
+ links: tuple[FileLink, ...] = ()
795
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
796
+ object_type: str = "links"
797
+
798
+ def to_dict(self) -> dict[str, object]:
799
+ return {
800
+ "schema_version": self.schema_version,
801
+ "object_type": self.object_type,
802
+ "task_id": self.task_id,
803
+ "links": [item.to_dict() for item in self.links],
804
+ }
805
+
806
+ @classmethod
807
+ def from_dict(cls, data: dict[str, object]) -> LinkCollection:
808
+ _require_contract(data, expected_object_type="links")
809
+ return cls(
810
+ task_id=_string_value(data, "task_id"),
811
+ links=tuple(
812
+ FileLink.from_dict(item) for item in _dict_list(data.get("links"))
813
+ ),
814
+ schema_version=_int_value(data, "schema_version"),
815
+ object_type=_string_value(data, "object_type"),
816
+ )
817
+
818
+
819
+ @dataclass(slots=True, frozen=True)
820
+ class RequirementCollection:
821
+ task_id: str
822
+ requirements: tuple[DependencyRequirement, ...] = ()
823
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
824
+ object_type: str = "requirements"
825
+
826
+ def to_dict(self) -> dict[str, object]:
827
+ return {
828
+ "schema_version": self.schema_version,
829
+ "object_type": self.object_type,
830
+ "task_id": self.task_id,
831
+ "requirements": [item.to_dict() for item in self.requirements],
832
+ }
833
+
834
+ @classmethod
835
+ def from_dict(cls, data: dict[str, object]) -> RequirementCollection:
836
+ _require_contract(data, expected_object_type="requirements")
837
+ return cls(
838
+ task_id=_string_value(data, "task_id"),
839
+ requirements=tuple(
840
+ DependencyRequirement.from_dict(item)
841
+ for item in _dict_list(data.get("requirements"))
842
+ ),
843
+ schema_version=_int_value(data, "schema_version"),
844
+ object_type=_string_value(data, "object_type"),
845
+ )
846
+
847
+
848
+ @dataclass(slots=True, frozen=True)
849
+ class TaskRecord:
850
+ id: str
851
+ slug: str
852
+ title: str
853
+ body: str
854
+ status_stage: TaskStatusStage = "draft"
855
+ created_at: str = field(default_factory=utc_now_iso)
856
+ updated_at: str = field(default_factory=utc_now_iso)
857
+ description_summary: str | None = None
858
+ priority: str | None = None
859
+ labels: tuple[str, ...] = ()
860
+ owner: str | None = None
861
+ introduction_ref: str | None = None
862
+ requirements: tuple[str, ...] = ()
863
+ file_links: tuple[FileLink, ...] = ()
864
+ todos: tuple[TaskTodo, ...] = ()
865
+ latest_plan_version: int | None = None
866
+ accepted_plan_version: int | None = None
867
+ latest_planning_run: str | None = None
868
+ latest_implementation_run: str | None = None
869
+ latest_validation_run: str | None = None
870
+ code_change_log_refs: tuple[str, ...] = ()
871
+ notes: tuple[str, ...] = ()
872
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
873
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
874
+ object_type: str = "task"
875
+
876
+ def to_dict(self) -> dict[str, object]:
877
+ return {
878
+ "schema_version": self.schema_version,
879
+ "object_type": self.object_type,
880
+ "file_version": self.file_version,
881
+ "id": self.id,
882
+ "slug": self.slug,
883
+ "title": self.title,
884
+ "status": self.status_stage,
885
+ "status_stage": self.status_stage,
886
+ "created_at": self.created_at,
887
+ "updated_at": self.updated_at,
888
+ "description_summary": self.description_summary,
889
+ "priority": self.priority,
890
+ "labels": list(self.labels),
891
+ "owner": self.owner,
892
+ "intro_refs": list(self.intro_refs),
893
+ "introduction_ref": self.introduction_ref,
894
+ "requirements": list(self.requirements),
895
+ "file_links": [item.to_dict() for item in self.file_links],
896
+ "todos": [item.to_dict() for item in self.todos],
897
+ "latest_plan": self.latest_plan,
898
+ "latest_plan_version": self.latest_plan_version,
899
+ "accepted_plan": self.accepted_plan,
900
+ "accepted_plan_version": self.accepted_plan_version,
901
+ "latest_planning_run": self.latest_planning_run,
902
+ "latest_implementation_run": self.latest_implementation_run,
903
+ "latest_validation_run": self.latest_validation_run,
904
+ "code_change_log_refs": list(self.code_change_log_refs),
905
+ "notes": list(self.notes),
906
+ "body": self.body,
907
+ }
908
+
909
+ @classmethod
910
+ def from_dict(cls, data: dict[str, object]) -> TaskRecord:
911
+ _require_contract(data, expected_object_type="task")
912
+ return cls(
913
+ id=_string_value(data, "id"),
914
+ slug=_string_value(data, "slug"),
915
+ title=_string_value(data, "title"),
916
+ body=_optional_string(data.get("body")) or "",
917
+ status_stage=normalize_task_status_stage(
918
+ _optional_string(data.get("status"))
919
+ or _string_value(data, "status_stage")
920
+ ),
921
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
922
+ updated_at=_optional_string(data.get("updated_at")) or utc_now_iso(),
923
+ description_summary=_optional_string(data.get("description_summary")),
924
+ priority=_optional_string(data.get("priority")),
925
+ labels=_string_tuple(data.get("labels")),
926
+ owner=_optional_string(data.get("owner")),
927
+ introduction_ref=_optional_string(data.get("introduction_ref"))
928
+ or _first_string(data.get("intro_refs")),
929
+ requirements=_string_tuple(data.get("requirements")),
930
+ file_links=tuple(
931
+ FileLink.from_dict(item) for item in _dict_list(data.get("file_links"))
932
+ ),
933
+ todos=tuple(
934
+ TaskTodo.from_dict(item) for item in _dict_list(data.get("todos"))
935
+ ),
936
+ latest_plan_version=_optional_int(data.get("latest_plan_version"))
937
+ or _plan_version_value(data.get("latest_plan")),
938
+ accepted_plan_version=_optional_int(data.get("accepted_plan_version"))
939
+ or _plan_version_value(data.get("accepted_plan")),
940
+ latest_planning_run=_optional_string(data.get("latest_planning_run")),
941
+ latest_implementation_run=_optional_string(
942
+ data.get("latest_implementation_run")
943
+ ),
944
+ latest_validation_run=_optional_string(data.get("latest_validation_run")),
945
+ code_change_log_refs=_string_tuple(data.get("code_change_log_refs")),
946
+ notes=_string_tuple(data.get("notes")),
947
+ file_version=_optional_string(data.get("file_version"))
948
+ or TASKLEDGER_V2_FILE_VERSION,
949
+ schema_version=_int_value(data, "schema_version"),
950
+ object_type=_string_value(data, "object_type"),
951
+ )
952
+
953
+ @property
954
+ def status(self) -> TaskStatusStage:
955
+ return self.status_stage
956
+
957
+ @property
958
+ def intro_refs(self) -> tuple[str, ...]:
959
+ return (self.introduction_ref,) if self.introduction_ref else ()
960
+
961
+ @property
962
+ def latest_plan(self) -> str | None:
963
+ if self.latest_plan_version is None:
964
+ return None
965
+ return _plan_id(self.latest_plan_version)
966
+
967
+ @property
968
+ def accepted_plan(self) -> str | None:
969
+ if self.accepted_plan_version is None:
970
+ return None
971
+ return _plan_id(self.accepted_plan_version)
972
+
973
+
974
+ @dataclass(slots=True, frozen=True)
975
+ class IntroductionRecord:
976
+ id: str
977
+ slug: str
978
+ title: str
979
+ body: str
980
+ created_at: str = field(default_factory=utc_now_iso)
981
+ updated_at: str = field(default_factory=utc_now_iso)
982
+ labels: tuple[str, ...] = ()
983
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
984
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
985
+ object_type: str = "intro"
986
+
987
+ def to_dict(self) -> dict[str, object]:
988
+ return {
989
+ "schema_version": self.schema_version,
990
+ "object_type": self.object_type,
991
+ "file_version": self.file_version,
992
+ "id": self.id,
993
+ "slug": self.slug,
994
+ "title": self.title,
995
+ "created_at": self.created_at,
996
+ "updated_at": self.updated_at,
997
+ "labels": list(self.labels),
998
+ "body": self.body,
999
+ }
1000
+
1001
+ @classmethod
1002
+ def from_dict(cls, data: dict[str, object]) -> IntroductionRecord:
1003
+ _require_contract(data, expected_object_type="intro")
1004
+ return cls(
1005
+ id=_string_value(data, "id"),
1006
+ slug=_string_value(data, "slug"),
1007
+ title=_string_value(data, "title"),
1008
+ body=_optional_string(data.get("body")) or "",
1009
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
1010
+ updated_at=_optional_string(data.get("updated_at")) or utc_now_iso(),
1011
+ labels=_string_tuple(data.get("labels")),
1012
+ file_version=_optional_string(data.get("file_version"))
1013
+ or TASKLEDGER_V2_FILE_VERSION,
1014
+ schema_version=_int_value(data, "schema_version"),
1015
+ object_type=_string_value(data, "object_type"),
1016
+ )
1017
+
1018
+
1019
+ @dataclass(slots=True, frozen=True)
1020
+ class PlanRecord:
1021
+ task_id: str
1022
+ plan_version: int
1023
+ body: str
1024
+ status: PlanStatus = "proposed"
1025
+ created_at: str = field(default_factory=utc_now_iso)
1026
+ created_by: ActorRef = field(default_factory=ActorRef)
1027
+ supersedes: int | None = None
1028
+ question_refs: tuple[str, ...] = ()
1029
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
1030
+ criteria: tuple[AcceptanceCriterion, ...] = ()
1031
+ todos: tuple[TaskTodo, ...] = ()
1032
+ generation_reason: str | None = None
1033
+ based_on_question_ids: tuple[str, ...] = ()
1034
+ based_on_answer_hash: str | None = None
1035
+ approved_at: str | None = None
1036
+ approved_by: ActorRef | None = None
1037
+ approval_note: str | None = None
1038
+ goal: str | None = None
1039
+ files: tuple[str, ...] = ()
1040
+ test_commands: tuple[str, ...] = ()
1041
+ expected_outputs: tuple[str, ...] = ()
1042
+ todos_waived_reason: str | None = None
1043
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
1044
+ object_type: str = "plan"
1045
+
1046
+ def to_dict(self) -> dict[str, object]:
1047
+ return {
1048
+ "schema_version": self.schema_version,
1049
+ "object_type": self.object_type,
1050
+ "file_version": self.file_version,
1051
+ "task_id": self.task_id,
1052
+ "plan_id": self.plan_id,
1053
+ "version": self.plan_version,
1054
+ "plan_version": self.plan_version,
1055
+ "status": self.status,
1056
+ "created_at": self.created_at,
1057
+ "created_by": self.created_by.to_dict(),
1058
+ "supersedes": self.supersedes,
1059
+ "question_refs": list(self.question_refs),
1060
+ "criteria": [item.to_dict() for item in self.criteria],
1061
+ "todos": [item.to_dict() for item in self.todos],
1062
+ "generation_reason": self.generation_reason,
1063
+ "based_on_question_ids": list(self.based_on_question_ids),
1064
+ "based_on_answer_hash": self.based_on_answer_hash,
1065
+ "supersedes_plan_id": (
1066
+ _plan_id(self.supersedes) if self.supersedes is not None else None
1067
+ ),
1068
+ "approved_at": self.approved_at,
1069
+ "approved_by": (
1070
+ self.approved_by.to_dict() if self.approved_by is not None else None
1071
+ ),
1072
+ "approval_note": self.approval_note,
1073
+ "body": self.body,
1074
+ "goal": self.goal,
1075
+ "files": list(self.files),
1076
+ "test_commands": list(self.test_commands),
1077
+ "expected_outputs": list(self.expected_outputs),
1078
+ "todos_waived_reason": self.todos_waived_reason,
1079
+ }
1080
+
1081
+ @classmethod
1082
+ def from_dict(cls, data: dict[str, object]) -> PlanRecord:
1083
+ _require_contract(data, expected_object_type="plan")
1084
+ plan_version = (
1085
+ _optional_int(data.get("plan_version"))
1086
+ or _optional_int(data.get("version"))
1087
+ or _plan_version_from_id(_optional_string(data.get("plan_id")))
1088
+ )
1089
+ if plan_version is None:
1090
+ raise LaunchError("Missing or invalid 'plan_version' value.")
1091
+ return cls(
1092
+ task_id=_string_value(data, "task_id"),
1093
+ plan_version=plan_version,
1094
+ body=_optional_string(data.get("body")) or "",
1095
+ status=normalize_plan_status(_string_value(data, "status")),
1096
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
1097
+ created_by=ActorRef.from_dict(data.get("created_by")),
1098
+ supersedes=_optional_int(data.get("supersedes")),
1099
+ question_refs=_string_tuple(data.get("question_refs")),
1100
+ file_version=_optional_string(data.get("file_version"))
1101
+ or TASKLEDGER_V2_FILE_VERSION,
1102
+ criteria=tuple(
1103
+ AcceptanceCriterion.from_dict(item)
1104
+ for item in _dict_list(data.get("criteria"))
1105
+ ),
1106
+ todos=tuple(
1107
+ TaskTodo.from_dict(item) for item in _dict_list(data.get("todos"))
1108
+ ),
1109
+ generation_reason=_optional_string(data.get("generation_reason")),
1110
+ based_on_question_ids=(
1111
+ _string_tuple(data.get("based_on_question_ids"))
1112
+ or _string_tuple(data.get("question_refs"))
1113
+ ),
1114
+ based_on_answer_hash=_optional_string(data.get("based_on_answer_hash")),
1115
+ approved_at=_optional_string(data.get("approved_at")),
1116
+ approved_by=ActorRef.from_dict(data.get("approved_by"))
1117
+ if data.get("approved_by") is not None
1118
+ else None,
1119
+ approval_note=_optional_string(data.get("approval_note")),
1120
+ goal=_optional_string(data.get("goal")),
1121
+ files=_string_tuple(data.get("files")),
1122
+ test_commands=_string_tuple(data.get("test_commands")),
1123
+ expected_outputs=_string_tuple(data.get("expected_outputs")),
1124
+ todos_waived_reason=_optional_string(data.get("todos_waived_reason")),
1125
+ schema_version=_int_value(data, "schema_version"),
1126
+ object_type=_string_value(data, "object_type"),
1127
+ )
1128
+
1129
+ @property
1130
+ def plan_id(self) -> str:
1131
+ return _plan_id(self.plan_version)
1132
+
1133
+
1134
+ @dataclass(slots=True, frozen=True)
1135
+ class QuestionRecord:
1136
+ id: str
1137
+ task_id: str
1138
+ question: str
1139
+ plan_version: int | None = None
1140
+ status: QuestionStatus = "open"
1141
+ created_at: str = field(default_factory=utc_now_iso)
1142
+ answered_at: str | None = None
1143
+ answered_by: str | None = None
1144
+ answered_by_actor: ActorRef | None = None
1145
+ asked_by_actor: ActorRef | None = None
1146
+ asked_in_harness: HarnessRef | None = None
1147
+ required_for_plan: bool = False
1148
+ answer_source: str | None = None
1149
+ answer: str | None = None
1150
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
1151
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
1152
+ object_type: str = "question"
1153
+
1154
+ def to_dict(self) -> dict[str, object]:
1155
+ return {
1156
+ "schema_version": self.schema_version,
1157
+ "object_type": self.object_type,
1158
+ "file_version": self.file_version,
1159
+ "id": self.id,
1160
+ "task_id": self.task_id,
1161
+ "plan_version": self.plan_version,
1162
+ "status": self.status,
1163
+ "created_at": self.created_at,
1164
+ "answered_at": self.answered_at,
1165
+ "answered_by": self.answered_by,
1166
+ "answered_by_actor": (
1167
+ self.answered_by_actor.to_dict()
1168
+ if self.answered_by_actor is not None
1169
+ else None
1170
+ ),
1171
+ "asked_by_actor": (
1172
+ self.asked_by_actor.to_dict()
1173
+ if self.asked_by_actor is not None
1174
+ else None
1175
+ ),
1176
+ "asked_in_harness": (
1177
+ self.asked_in_harness.to_dict()
1178
+ if self.asked_in_harness is not None
1179
+ else None
1180
+ ),
1181
+ "required_for_plan": self.required_for_plan,
1182
+ "answer_source": self.answer_source,
1183
+ "question": self.question,
1184
+ "answer": self.answer,
1185
+ }
1186
+
1187
+ @classmethod
1188
+ def from_dict(cls, data: dict[str, object]) -> QuestionRecord:
1189
+ _require_contract(data, expected_object_type="question")
1190
+ return cls(
1191
+ id=_string_value(data, "id"),
1192
+ task_id=_string_value(data, "task_id"),
1193
+ question=_string_value(data, "question"),
1194
+ plan_version=_optional_int(data.get("plan_version")),
1195
+ status=normalize_question_status(_string_value(data, "status")),
1196
+ created_at=_optional_string(data.get("created_at")) or utc_now_iso(),
1197
+ answered_at=_optional_string(data.get("answered_at")),
1198
+ answered_by=_optional_string(data.get("answered_by")),
1199
+ answered_by_actor=ActorRef.from_dict(data.get("answered_by_actor"))
1200
+ if data.get("answered_by_actor") is not None
1201
+ else None,
1202
+ asked_by_actor=ActorRef.from_dict(data.get("asked_by_actor"))
1203
+ if data.get("asked_by_actor") is not None
1204
+ else None,
1205
+ asked_in_harness=HarnessRef.from_dict(data.get("asked_in_harness"))
1206
+ if data.get("asked_in_harness") is not None
1207
+ else None,
1208
+ required_for_plan=bool(data.get("required_for_plan", False)),
1209
+ answer_source=_optional_string(data.get("answer_source")),
1210
+ answer=_optional_string(data.get("answer")),
1211
+ file_version=_optional_string(data.get("file_version"))
1212
+ or TASKLEDGER_V2_FILE_VERSION,
1213
+ schema_version=_int_value(data, "schema_version"),
1214
+ object_type=_string_value(data, "object_type"),
1215
+ )
1216
+
1217
+
1218
+ @dataclass(slots=True, frozen=True)
1219
+ class TaskRunRecord:
1220
+ run_id: str
1221
+ task_id: str
1222
+ run_type: RunType
1223
+ status: RunStatus = "running"
1224
+ started_at: str = field(default_factory=utc_now_iso)
1225
+ finished_at: str | None = None
1226
+ actor: ActorRef = field(default_factory=ActorRef)
1227
+ harness: HarnessRef | None = None
1228
+ based_on_plan_version: int | None = None
1229
+ based_on_implementation_run: str | None = None
1230
+ resumes_run_id: str | None = None
1231
+ summary: str | None = None
1232
+ worklog: tuple[str, ...] = ()
1233
+ deviations_from_plan: tuple[str, ...] = ()
1234
+ change_refs: tuple[str, ...] = ()
1235
+ todo_updates: tuple[str, ...] = ()
1236
+ artifact_refs: tuple[str, ...] = ()
1237
+ checks: tuple[ValidationCheck, ...] = ()
1238
+ evidence: tuple[str, ...] = ()
1239
+ recommendation: str | None = None
1240
+ result: ValidationResult | None = None
1241
+ handoff_refs: tuple[str, ...] = ()
1242
+ actor_history: tuple[ActorRef, ...] = ()
1243
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
1244
+ based_on_plan: str | None = None
1245
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
1246
+ object_type: str = "run"
1247
+
1248
+ def to_dict(self) -> dict[str, object]:
1249
+ return {
1250
+ "schema_version": self.schema_version,
1251
+ "object_type": self.object_type,
1252
+ "file_version": self.file_version,
1253
+ "run_id": self.run_id,
1254
+ "task_id": self.task_id,
1255
+ "run_type": self.run_type,
1256
+ "status": self.status,
1257
+ "started_at": self.started_at,
1258
+ "finished_at": self.finished_at,
1259
+ "actor": self.actor.to_dict(),
1260
+ "harness": self.harness.to_dict() if self.harness is not None else None,
1261
+ "based_on_plan": self.based_on_plan or self.plan_ref,
1262
+ "based_on_plan_version": self.based_on_plan_version,
1263
+ "based_on_implementation_run": self.based_on_implementation_run,
1264
+ "resumes_run_id": self.resumes_run_id,
1265
+ "summary": self.summary,
1266
+ "worklog": list(self.worklog),
1267
+ "deviations_from_plan": list(self.deviations_from_plan),
1268
+ "change_refs": list(self.change_refs),
1269
+ "todo_updates": list(self.todo_updates),
1270
+ "artifact_refs": list(self.artifact_refs),
1271
+ "checks": [item.to_dict() for item in self.checks],
1272
+ "evidence": list(self.evidence),
1273
+ "recommendation": self.recommendation,
1274
+ "result": self.result,
1275
+ "handoff_refs": list(self.handoff_refs),
1276
+ "actor_history": [item.to_dict() for item in self.actor_history],
1277
+ }
1278
+
1279
+ @classmethod
1280
+ def from_dict(cls, data: dict[str, object]) -> TaskRunRecord:
1281
+ _require_contract(data, expected_object_type="run")
1282
+ result = _optional_string(data.get("result"))
1283
+ return cls(
1284
+ run_id=_string_value(data, "run_id"),
1285
+ task_id=_string_value(data, "task_id"),
1286
+ run_type=normalize_run_type(_string_value(data, "run_type")),
1287
+ status=normalize_run_status(_string_value(data, "status")),
1288
+ started_at=_optional_string(data.get("started_at")) or utc_now_iso(),
1289
+ finished_at=_optional_string(data.get("finished_at")),
1290
+ actor=ActorRef.from_dict(data.get("actor")),
1291
+ harness=HarnessRef.from_dict(data.get("harness"))
1292
+ if data.get("harness") is not None
1293
+ else None,
1294
+ based_on_plan_version=_optional_int(data.get("based_on_plan_version"))
1295
+ or _plan_version_value(data.get("based_on_plan")),
1296
+ based_on_implementation_run=_optional_string(
1297
+ data.get("based_on_implementation_run")
1298
+ ),
1299
+ resumes_run_id=_optional_string(data.get("resumes_run_id")),
1300
+ summary=_optional_string(data.get("summary")),
1301
+ worklog=_string_tuple(data.get("worklog")),
1302
+ deviations_from_plan=_string_tuple(data.get("deviations_from_plan")),
1303
+ change_refs=_string_tuple(data.get("change_refs")),
1304
+ todo_updates=_string_tuple(data.get("todo_updates")),
1305
+ artifact_refs=_string_tuple(data.get("artifact_refs")),
1306
+ checks=tuple(
1307
+ ValidationCheck.from_dict(item)
1308
+ for item in _dict_list(data.get("checks"))
1309
+ ),
1310
+ evidence=_string_tuple(data.get("evidence")),
1311
+ recommendation=_optional_string(data.get("recommendation")),
1312
+ result=normalize_validation_result(result) if result is not None else None,
1313
+ handoff_refs=_string_tuple(data.get("handoff_refs")),
1314
+ actor_history=tuple(
1315
+ ActorRef.from_dict(item)
1316
+ for item in _dict_list(data.get("actor_history"))
1317
+ ),
1318
+ file_version=_optional_string(data.get("file_version"))
1319
+ or TASKLEDGER_V2_FILE_VERSION,
1320
+ based_on_plan=_optional_string(data.get("based_on_plan")),
1321
+ schema_version=_int_value(data, "schema_version"),
1322
+ object_type=_string_value(data, "object_type"),
1323
+ )
1324
+
1325
+ @property
1326
+ def plan_ref(self) -> str | None:
1327
+ if self.based_on_plan_version is None:
1328
+ return None
1329
+ return _plan_id(self.based_on_plan_version)
1330
+
1331
+
1332
+ @dataclass(slots=True, frozen=True)
1333
+ class CodeChangeRecord:
1334
+ change_id: str
1335
+ task_id: str
1336
+ implementation_run: str
1337
+ timestamp: str
1338
+ kind: str
1339
+ path: str
1340
+ summary: str
1341
+ git_commit: str | None = None
1342
+ git_diff_stat: str | None = None
1343
+ command: str | None = None
1344
+ before_hash: str | None = None
1345
+ after_hash: str | None = None
1346
+ exit_code: int | None = None
1347
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
1348
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
1349
+ object_type: str = "change"
1350
+
1351
+ def to_dict(self) -> dict[str, object]:
1352
+ return {
1353
+ "schema_version": self.schema_version,
1354
+ "object_type": self.object_type,
1355
+ "file_version": self.file_version,
1356
+ "change_id": self.change_id,
1357
+ "task_id": self.task_id,
1358
+ "implementation_run": self.implementation_run,
1359
+ "timestamp": self.timestamp,
1360
+ "kind": self.kind,
1361
+ "path": self.path,
1362
+ "summary": self.summary,
1363
+ "git_commit": self.git_commit,
1364
+ "git_diff_stat": self.git_diff_stat,
1365
+ "command": self.command,
1366
+ "before_hash": self.before_hash,
1367
+ "after_hash": self.after_hash,
1368
+ "exit_code": self.exit_code,
1369
+ }
1370
+
1371
+ @classmethod
1372
+ def from_dict(cls, data: dict[str, object]) -> CodeChangeRecord:
1373
+ _require_contract(data, expected_object_type="change")
1374
+ return cls(
1375
+ change_id=_string_value(data, "change_id"),
1376
+ task_id=_string_value(data, "task_id"),
1377
+ implementation_run=_string_value(data, "implementation_run"),
1378
+ timestamp=_optional_string(data.get("timestamp")) or utc_now_iso(),
1379
+ kind=_string_value(data, "kind"),
1380
+ path=_string_value(data, "path"),
1381
+ summary=_string_value(data, "summary"),
1382
+ git_commit=_optional_string(data.get("git_commit")),
1383
+ git_diff_stat=_optional_string(data.get("git_diff_stat")),
1384
+ command=_optional_string(data.get("command")),
1385
+ before_hash=_optional_string(data.get("before_hash")),
1386
+ after_hash=_optional_string(data.get("after_hash")),
1387
+ exit_code=_optional_int(data.get("exit_code")),
1388
+ file_version=_optional_string(data.get("file_version"))
1389
+ or TASKLEDGER_V2_FILE_VERSION,
1390
+ schema_version=_int_value(data, "schema_version"),
1391
+ object_type=_string_value(data, "object_type"),
1392
+ )
1393
+
1394
+
1395
+ @dataclass(slots=True, frozen=True)
1396
+ class TaskLock:
1397
+ lock_id: str
1398
+ task_id: str
1399
+ stage: ActiveTaskStatusStage
1400
+ run_id: str
1401
+ created_at: str
1402
+ expires_at: str | None
1403
+ reason: str
1404
+ holder: ActorRef
1405
+ lease_seconds: int = 7200
1406
+ last_heartbeat_at: str | None = None
1407
+ broken_at: str | None = None
1408
+ broken_by: ActorRef | None = None
1409
+ broken_reason: str | None = None
1410
+ actor: ActorRef | None = None
1411
+ harness: HarnessRef | None = None
1412
+ transfer_history: tuple[tuple[str, str, str], ...] = ()
1413
+ transfer_date: str | None = None
1414
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
1415
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
1416
+ object_type: str = "lock"
1417
+
1418
+ def to_dict(self) -> dict[str, object]:
1419
+ return {
1420
+ "schema_version": self.schema_version,
1421
+ "object_type": self.object_type,
1422
+ "file_version": self.file_version,
1423
+ "lock_id": self.lock_id,
1424
+ "task_id": self.task_id,
1425
+ "stage": self.stage,
1426
+ "run_type": self.run_type,
1427
+ "run_id": self.run_id,
1428
+ "created_at": self.created_at,
1429
+ "expires_at": self.expires_at,
1430
+ "lease_seconds": self.lease_seconds,
1431
+ "last_heartbeat_at": self.last_heartbeat_at,
1432
+ "reason": self.reason,
1433
+ "holder": self.holder.to_dict(),
1434
+ "broken_at": self.broken_at,
1435
+ "broken_by": (
1436
+ self.broken_by.to_dict() if self.broken_by is not None else None
1437
+ ),
1438
+ "broken_reason": self.broken_reason,
1439
+ "actor": self.actor.to_dict() if self.actor is not None else None,
1440
+ "harness": self.harness.to_dict() if self.harness is not None else None,
1441
+ "transfer_history": [list(entry) for entry in self.transfer_history],
1442
+ "transfer_date": self.transfer_date,
1443
+ }
1444
+
1445
+ @classmethod
1446
+ def from_dict(cls, data: dict[str, object]) -> TaskLock:
1447
+ _require_contract(data, expected_object_type="lock")
1448
+ stage = _lock_stage_from_data(data)
1449
+ if stage not in {"planning", "implementing", "validating"}:
1450
+ raise LaunchError(f"Unsupported lock stage: {stage}")
1451
+
1452
+ # Deserialize transfer_history
1453
+ transfer_history_data = data.get("transfer_history", [])
1454
+ transfer_history: tuple[tuple[str, str, str], ...] = ()
1455
+ if isinstance(transfer_history_data, list):
1456
+ for entry in transfer_history_data:
1457
+ if isinstance(entry, list | tuple) and len(entry) == 3:
1458
+ transfer_history = transfer_history + (tuple(entry),)
1459
+
1460
+ return cls(
1461
+ lock_id=_string_value(data, "lock_id"),
1462
+ task_id=_string_value(data, "task_id"),
1463
+ stage=cast(ActiveTaskStatusStage, stage),
1464
+ run_id=_string_value(data, "run_id"),
1465
+ created_at=_string_value(data, "created_at"),
1466
+ expires_at=_optional_string(data.get("expires_at")),
1467
+ reason=_string_value(data, "reason"),
1468
+ holder=ActorRef.from_dict(data.get("holder")),
1469
+ lease_seconds=_optional_int(data.get("lease_seconds")) or 7200,
1470
+ last_heartbeat_at=_optional_string(data.get("last_heartbeat_at")),
1471
+ broken_at=_optional_string(data.get("broken_at")),
1472
+ broken_by=ActorRef.from_dict(data.get("broken_by"))
1473
+ if data.get("broken_by") is not None
1474
+ else None,
1475
+ broken_reason=_optional_string(data.get("broken_reason")),
1476
+ actor=ActorRef.from_dict(data.get("actor"))
1477
+ if data.get("actor") is not None
1478
+ else None,
1479
+ harness=HarnessRef.from_dict(data.get("harness"))
1480
+ if data.get("harness") is not None
1481
+ else None,
1482
+ transfer_history=transfer_history,
1483
+ transfer_date=_optional_string(data.get("transfer_date")),
1484
+ file_version=_optional_string(data.get("file_version"))
1485
+ or TASKLEDGER_V2_FILE_VERSION,
1486
+ schema_version=_int_value(data, "schema_version"),
1487
+ object_type=_string_value(data, "object_type"),
1488
+ )
1489
+
1490
+ @property
1491
+ def run_type(self) -> RunType:
1492
+ return cast(
1493
+ RunType,
1494
+ {
1495
+ "planning": "planning",
1496
+ "implementing": "implementation",
1497
+ "validating": "validation",
1498
+ }[self.stage],
1499
+ )
1500
+
1501
+
1502
+ @dataclass(slots=True, frozen=True)
1503
+ class TaskEvent:
1504
+ ts: str
1505
+ event: str
1506
+ task_id: str
1507
+ actor: ActorRef
1508
+ harness: HarnessRef | None = None
1509
+ event_id: str = field(
1510
+ default_factory=lambda: (
1511
+ "evt-"
1512
+ + utc_now_iso().replace(":", "").replace("-", "").replace("+00:00", "Z")
1513
+ )
1514
+ )
1515
+ data: dict[str, object] = field(default_factory=dict)
1516
+ file_version: str = TASKLEDGER_V2_FILE_VERSION
1517
+ schema_version: int = TASKLEDGER_SCHEMA_VERSION
1518
+ object_type: str = "event"
1519
+
1520
+ def to_dict(self) -> dict[str, object]:
1521
+ return {
1522
+ "schema_version": self.schema_version,
1523
+ "object_type": self.object_type,
1524
+ "file_version": self.file_version,
1525
+ "event_id": self.event_id,
1526
+ "ts": self.ts,
1527
+ "event": self.event,
1528
+ "task_id": self.task_id,
1529
+ "actor": self.actor.to_dict(),
1530
+ "harness": self.harness.to_dict() if self.harness is not None else None,
1531
+ "data": dict(self.data),
1532
+ }
1533
+
1534
+ @classmethod
1535
+ def from_dict(cls, data: dict[str, object]) -> TaskEvent:
1536
+ _require_contract(data, expected_object_type="event")
1537
+ payload = data.get("data")
1538
+ return cls(
1539
+ event_id=_string_value(data, "event_id"),
1540
+ ts=_string_value(data, "ts"),
1541
+ event=_string_value(data, "event"),
1542
+ task_id=_string_value(data, "task_id"),
1543
+ actor=ActorRef.from_dict(data.get("actor")),
1544
+ harness=HarnessRef.from_dict(data.get("harness"))
1545
+ if data.get("harness") is not None
1546
+ else None,
1547
+ data=payload if isinstance(payload, dict) else {},
1548
+ file_version=_optional_string(data.get("file_version"))
1549
+ or TASKLEDGER_V2_FILE_VERSION,
1550
+ schema_version=_int_value(data, "schema_version"),
1551
+ object_type=_string_value(data, "object_type"),
1552
+ )
1553
+
1554
+
1555
+ def _dict_list(value: object) -> list[dict[str, object]]:
1556
+ if not isinstance(value, list):
1557
+ return []
1558
+ return [item for item in value if isinstance(item, dict)]
1559
+
1560
+
1561
+ def _optional_int(value: object) -> int | None:
1562
+ return value if isinstance(value, int) else None
1563
+
1564
+
1565
+ def _optional_string(value: object) -> str | None:
1566
+ return value if isinstance(value, str) else None
1567
+
1568
+
1569
+ def _optional_list_string(value: object) -> list[str] | None:
1570
+ if not isinstance(value, list):
1571
+ return None
1572
+ return [item for item in value if isinstance(item, str)] or None
1573
+
1574
+
1575
+ def _string_tuple(value: object) -> tuple[str, ...]:
1576
+ if not isinstance(value, list):
1577
+ return ()
1578
+ return tuple(item for item in value if isinstance(item, str))
1579
+
1580
+
1581
+ def _string_value(data: dict[str, object], key: str) -> str:
1582
+ value = data.get(key)
1583
+ if not isinstance(value, str) or not value:
1584
+ raise LaunchError(f"Missing or invalid '{key}' value.")
1585
+ return value
1586
+
1587
+
1588
+ def _int_value(data: dict[str, object], key: str) -> int:
1589
+ value = data.get(key)
1590
+ if not isinstance(value, int):
1591
+ raise LaunchError(f"Missing or invalid '{key}' value.")
1592
+ return value
1593
+
1594
+
1595
+ def _first_string(value: object) -> str | None:
1596
+ if not isinstance(value, list):
1597
+ return None
1598
+ for item in value:
1599
+ if isinstance(item, str):
1600
+ return item
1601
+ return None
1602
+
1603
+
1604
+ def _plan_id(version: int) -> str:
1605
+ return f"plan-v{version}"
1606
+
1607
+
1608
+ def _plan_version_from_id(value: str | None) -> int | None:
1609
+ if value is None or not value.startswith("plan-v"):
1610
+ return None
1611
+ suffix = value.removeprefix("plan-v")
1612
+ return int(suffix) if suffix.isdigit() else None
1613
+
1614
+
1615
+ def _plan_version_value(value: object) -> int | None:
1616
+ if isinstance(value, int):
1617
+ return value
1618
+ if isinstance(value, str):
1619
+ return _plan_version_from_id(value)
1620
+ return None
1621
+
1622
+
1623
+ def _lock_stage_from_data(data: dict[str, object]) -> TaskStatusStage:
1624
+ stage = _optional_string(data.get("stage"))
1625
+ if stage is None:
1626
+ run_type = _optional_string(data.get("run_type"))
1627
+ stage = {
1628
+ "planning": "planning",
1629
+ "implementation": "implementing",
1630
+ "validation": "validating",
1631
+ }.get(run_type or "")
1632
+ if stage is None:
1633
+ raise LaunchError("Missing or invalid 'stage' value.")
1634
+ return normalize_task_status_stage(stage)
1635
+
1636
+
1637
+ def _generated_event_id() -> str:
1638
+ timestamp = utc_now_iso().replace(":", "").replace("-", "").replace("+00:00", "Z")
1639
+ return f"evt-{timestamp}"
1640
+
1641
+
1642
+ def _require_contract(data: dict[str, object], *, expected_object_type: str) -> None:
1643
+ version = data.get("schema_version")
1644
+ if not isinstance(version, int) or version > TASKLEDGER_SCHEMA_VERSION:
1645
+ raise LaunchError(
1646
+ "Unsupported schema version: "
1647
+ f"expected <= {TASKLEDGER_SCHEMA_VERSION}, got {version!r}."
1648
+ )
1649
+ object_type = _optional_string(data.get("object_type"))
1650
+ if object_type != expected_object_type:
1651
+ raise LaunchError(
1652
+ "Missing or invalid 'object_type': "
1653
+ f"expected {expected_object_type!r}, got {object_type!r}."
1654
+ )
1655
+ if "file_version" in data:
1656
+ _require_v2_file_version(data)
1657
+
1658
+
1659
+ def _int_or_default(value: object, default: int) -> int:
1660
+ return value if isinstance(value, int) else default
1661
+
1662
+
1663
+ def _require_v2_file_version(data: dict[str, object]) -> None:
1664
+ version = _optional_string(data.get("file_version"))
1665
+ if version != TASKLEDGER_V2_FILE_VERSION:
1666
+ raise LaunchError(
1667
+ "Unsupported file version: "
1668
+ f"expected {TASKLEDGER_V2_FILE_VERSION}, got {version!r}."
1669
+ )
1670
+
1671
+
1672
+ def _require_sidecar_contract(
1673
+ data: dict[str, object], *, expected_object_type: str
1674
+ ) -> None:
1675
+ """Enforce version compatibility for sidecar models read from v2 files.
1676
+
1677
+ When schema_version or object_type are present, validate them.
1678
+ When absent (legacy YAML sidecar reads), skip enforcement.
1679
+ """
1680
+ version = data.get("schema_version")
1681
+ if isinstance(version, int) and version > TASKLEDGER_SCHEMA_VERSION:
1682
+ raise LaunchError(
1683
+ f"Record schema too new: {version} "
1684
+ f"(current max: {TASKLEDGER_SCHEMA_VERSION}). "
1685
+ "Please upgrade taskledger."
1686
+ )
1687
+ obj_type = _optional_string(data.get("object_type"))
1688
+ if obj_type is not None and obj_type != expected_object_type:
1689
+ raise LaunchError(
1690
+ f"Invalid object_type: expected {expected_object_type!r}, got {obj_type!r}."
1691
+ )
1692
+ file_ver = _optional_string(data.get("file_version"))
1693
+ if file_ver is not None and file_ver != TASKLEDGER_V2_FILE_VERSION:
1694
+ raise LaunchError(
1695
+ "Unsupported file version: "
1696
+ f"expected {TASKLEDGER_V2_FILE_VERSION}, got {file_ver!r}."
1697
+ )