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,542 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass, field, replace
5
+
6
+ from taskledger.domain.models import TaskLock, TaskRecord, TaskRunRecord
7
+ from taskledger.domain.states import (
8
+ EXIT_CODE_APPROVAL_REQUIRED,
9
+ EXIT_CODE_INVALID_TRANSITION,
10
+ EXIT_CODE_LOCK_CONFLICT,
11
+ )
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Decision:
16
+ allowed: bool
17
+ code: str
18
+ message: str
19
+ blocking_refs: tuple[str, ...] = ()
20
+ details: dict[str, object] = field(default_factory=dict)
21
+ exit_code: int = 0
22
+
23
+ @property
24
+ def ok(self) -> bool:
25
+ return self.allowed
26
+
27
+ @property
28
+ def reason(self) -> str:
29
+ return self.message
30
+
31
+
32
+ PolicyDecision = Decision
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PolicyContext:
37
+ task: TaskRecord
38
+ lock: TaskLock | None
39
+ run: TaskRunRecord | None
40
+ active_stage: str | None
41
+
42
+
43
+ def derive_active_stage(
44
+ lock: TaskLock | None,
45
+ runs: Iterable[TaskRunRecord],
46
+ ) -> str | None:
47
+ if lock is None:
48
+ return None
49
+ for run in runs:
50
+ if (
51
+ run.run_id == lock.run_id
52
+ and run.run_type == lock.run_type
53
+ and run.status == "running"
54
+ ):
55
+ return run.run_type
56
+ return None
57
+
58
+
59
+ def build_policy_context(
60
+ task: TaskRecord,
61
+ lock: TaskLock | None,
62
+ *,
63
+ run: TaskRunRecord | None = None,
64
+ ) -> PolicyContext:
65
+ active_stage = (
66
+ (derive_active_stage(lock, (run,)) if run is not None else lock.run_type)
67
+ if lock is not None
68
+ else None
69
+ )
70
+ return PolicyContext(task=task, lock=lock, run=run, active_stage=active_stage)
71
+
72
+
73
+ def _policy_decision(ok: bool, reason: str, exit_code: int) -> Decision:
74
+ return Decision(
75
+ allowed=ok,
76
+ code="OK" if ok else "POLICY_DENIED",
77
+ message=reason,
78
+ exit_code=exit_code,
79
+ )
80
+
81
+
82
+ def _with_denial_code(decision: Decision, code: str) -> Decision:
83
+ if decision.allowed:
84
+ return decision
85
+ return replace(decision, code=code)
86
+
87
+
88
+ def can_start_planning(task: TaskRecord, ledger: object) -> Decision:
89
+ lock = getattr(ledger, "lock", None)
90
+ allowed = task.status_stage in {"draft", "plan_review"} and lock is None
91
+ return Decision(
92
+ allowed=allowed,
93
+ code="OK" if allowed else "TASK_NOT_PLANNABLE",
94
+ message=(
95
+ "Planning can start."
96
+ if allowed
97
+ else "Planning requires draft or plan_review state without an active lock."
98
+ ),
99
+ exit_code=0 if allowed else EXIT_CODE_INVALID_TRANSITION,
100
+ )
101
+
102
+
103
+ def can_propose_plan(task: TaskRecord, run: TaskRunRecord, ledger: object) -> Decision:
104
+ return _with_denial_code(
105
+ plan_propose_decision(task, getattr(ledger, "lock", None), run=run),
106
+ "RUN_LOCK_MISMATCH",
107
+ )
108
+
109
+
110
+ def can_approve_plan(task: TaskRecord, plan: object, ledger: object) -> Decision:
111
+ return _with_denial_code(
112
+ plan_approve_decision(task, getattr(ledger, "lock", None)),
113
+ "TASK_NOT_IN_REVIEW",
114
+ )
115
+
116
+
117
+ def can_start_implementation(task: TaskRecord, ledger: object) -> Decision:
118
+ lock = getattr(ledger, "lock", None)
119
+ accepted_plan = getattr(ledger, "accepted_plan", None)
120
+ allowed = (
121
+ task.status_stage in {"approved", "failed_validation"}
122
+ and accepted_plan is not None
123
+ and lock is None
124
+ )
125
+ return Decision(
126
+ allowed=allowed,
127
+ code="OK" if allowed else "TASK_NOT_APPROVED",
128
+ message=(
129
+ "Implementation can start."
130
+ if allowed
131
+ else (
132
+ "Implementation requires approved state, an accepted plan, "
133
+ "and no active lock."
134
+ )
135
+ ),
136
+ exit_code=0 if allowed else EXIT_CODE_APPROVAL_REQUIRED,
137
+ )
138
+
139
+
140
+ def can_finish_implementation(
141
+ task: TaskRecord,
142
+ run: TaskRunRecord,
143
+ ledger: object,
144
+ ) -> Decision:
145
+ return _with_denial_code(
146
+ implementation_mutation_decision(
147
+ task,
148
+ getattr(ledger, "lock", None),
149
+ run=run,
150
+ action="finish implementation",
151
+ ),
152
+ "RUN_LOCK_MISMATCH",
153
+ )
154
+
155
+
156
+ def can_start_validation(task: TaskRecord, ledger: object) -> Decision:
157
+ lock = getattr(ledger, "lock", None)
158
+ allowed = task.status_stage == "implemented" and lock is None
159
+ return Decision(
160
+ allowed=allowed,
161
+ code="OK" if allowed else "TASK_NOT_IMPLEMENTED",
162
+ message=(
163
+ "Validation can start."
164
+ if allowed
165
+ else "Validation requires implemented state and no active lock."
166
+ ),
167
+ exit_code=0 if allowed else EXIT_CODE_INVALID_TRANSITION,
168
+ )
169
+
170
+
171
+ def can_finish_validation(
172
+ task: TaskRecord,
173
+ run: TaskRunRecord,
174
+ ledger: object,
175
+ ) -> Decision:
176
+ return _with_denial_code(
177
+ validation_check_decision(task, getattr(ledger, "lock", None), run=run),
178
+ "RUN_LOCK_MISMATCH",
179
+ )
180
+
181
+
182
+ def can_mark_todo_done(
183
+ task: TaskRecord,
184
+ todo: object,
185
+ actor: object,
186
+ ledger: object,
187
+ ) -> Decision:
188
+ actor_role = getattr(actor, "actor_type", "user")
189
+ return _with_denial_code(
190
+ todo_toggle_decision(
191
+ task,
192
+ getattr(ledger, "lock", None),
193
+ actor_role="user" if actor_role == "user" else "implementer",
194
+ ),
195
+ "TODO_NOT_MUTABLE",
196
+ )
197
+
198
+
199
+ def metadata_edit_decision(task: TaskRecord, lock: TaskLock | None) -> Decision:
200
+ ctx = build_policy_context(task, lock)
201
+ if ctx.task.status_stage not in {"draft", "plan_review", "approved"}:
202
+ return _policy_decision(
203
+ False,
204
+ "Task metadata can only be edited in draft, plan_review, or approved.",
205
+ EXIT_CODE_INVALID_TRANSITION,
206
+ )
207
+ if ctx.active_stage is not None:
208
+ return _policy_decision(
209
+ False,
210
+ (
211
+ f"Task {ctx.task.id} is locked for {ctx.active_stage}. "
212
+ "Break the lock explicitly before editing metadata."
213
+ ),
214
+ EXIT_CODE_LOCK_CONFLICT,
215
+ )
216
+ return _policy_decision(True, "Task metadata can be edited.", 0)
217
+
218
+
219
+ def todo_add_decision(
220
+ task: TaskRecord,
221
+ lock: TaskLock | None,
222
+ *,
223
+ actor_role: str,
224
+ ) -> Decision:
225
+ ctx = build_policy_context(task, lock)
226
+ if ctx.active_stage == "planning":
227
+ return _active_stage_lock_decision(
228
+ ctx,
229
+ expected_stage="planning",
230
+ action="add todos during planning",
231
+ )
232
+ if ctx.active_stage == "implementation" and ctx.task.status_stage in {
233
+ "approved",
234
+ "implementing",
235
+ "failed_validation",
236
+ }:
237
+ return _active_stage_lock_decision(
238
+ ctx,
239
+ expected_stage="implementing",
240
+ action="add todos during implementation",
241
+ )
242
+ if ctx.active_stage is not None or ctx.task.status_stage not in {
243
+ "draft",
244
+ "plan_review",
245
+ "approved",
246
+ }:
247
+ return _policy_decision(
248
+ False,
249
+ "Todos can only be added before implementation starts.",
250
+ EXIT_CODE_INVALID_TRANSITION,
251
+ )
252
+ return _policy_decision(True, f"{actor_role} can add a todo.", 0)
253
+
254
+
255
+ def todo_toggle_decision(
256
+ task: TaskRecord,
257
+ lock: TaskLock | None,
258
+ *,
259
+ actor_role: str,
260
+ ) -> Decision:
261
+ ctx = build_policy_context(task, lock)
262
+ if ctx.active_stage == "implementation":
263
+ return _active_stage_lock_decision(
264
+ ctx,
265
+ expected_stage="implementing",
266
+ action="toggle todos during implementation",
267
+ )
268
+ if ctx.active_stage == "validation" or ctx.task.status_stage in {
269
+ "cancelled",
270
+ "done",
271
+ }:
272
+ return _policy_decision(
273
+ False,
274
+ f"Todos cannot be toggled while the task is {ctx.task.status_stage}.",
275
+ EXIT_CODE_INVALID_TRANSITION,
276
+ )
277
+ if actor_role != "user":
278
+ return _policy_decision(
279
+ False,
280
+ "Only the user may toggle todos outside implementation.",
281
+ EXIT_CODE_INVALID_TRANSITION,
282
+ )
283
+ return _policy_decision(True, f"{actor_role} can toggle todos.", 0)
284
+
285
+
286
+ def question_add_decision(
287
+ task: TaskRecord,
288
+ lock: TaskLock | None,
289
+ *,
290
+ actor_role: str,
291
+ ) -> Decision:
292
+ ctx = build_policy_context(task, lock)
293
+ if ctx.active_stage not in {None, "planning"} or ctx.task.status_stage not in {
294
+ "draft",
295
+ "plan_review",
296
+ }:
297
+ return _policy_decision(
298
+ False,
299
+ "Questions can only be added during planning or plan review.",
300
+ EXIT_CODE_INVALID_TRANSITION,
301
+ )
302
+ if ctx.active_stage == "planning" and actor_role != "user":
303
+ return _active_stage_lock_decision(
304
+ ctx,
305
+ expected_stage="planning",
306
+ action="add questions during planning",
307
+ )
308
+ return _policy_decision(True, f"{actor_role} can add a question.", 0)
309
+
310
+
311
+ def question_mutation_decision(
312
+ task: TaskRecord,
313
+ lock: TaskLock | None,
314
+ *,
315
+ actor_role: str,
316
+ ) -> Decision:
317
+ ctx = build_policy_context(task, lock)
318
+ if ctx.active_stage not in {None, "planning"} or ctx.task.status_stage not in {
319
+ "draft",
320
+ "plan_review",
321
+ }:
322
+ return _policy_decision(
323
+ False,
324
+ "Questions can only be updated during planning or plan review.",
325
+ EXIT_CODE_INVALID_TRANSITION,
326
+ )
327
+ if ctx.active_stage == "planning" and actor_role != "user":
328
+ return _active_stage_lock_decision(
329
+ ctx,
330
+ expected_stage="planning",
331
+ action="update questions during planning",
332
+ )
333
+ return _policy_decision(True, f"{actor_role} can update the question.", 0)
334
+
335
+
336
+ def plan_propose_decision(
337
+ task: TaskRecord,
338
+ lock: TaskLock | None,
339
+ *,
340
+ run: TaskRunRecord | None,
341
+ ) -> Decision:
342
+ ctx = build_policy_context(task, lock, run=run)
343
+ if (
344
+ ctx.task.status_stage not in {"draft", "plan_review"}
345
+ or ctx.active_stage != "planning"
346
+ ):
347
+ return _policy_decision(
348
+ False,
349
+ "Plan proposals require active planning.",
350
+ EXIT_CODE_INVALID_TRANSITION,
351
+ )
352
+ if ctx.run is None or ctx.run.run_type != "planning" or ctx.run.status != "running":
353
+ return _policy_decision(
354
+ False,
355
+ "Plan proposals require an active planning run.",
356
+ EXIT_CODE_INVALID_TRANSITION,
357
+ )
358
+ return _active_stage_lock_decision(
359
+ ctx,
360
+ expected_stage="planning",
361
+ action="propose a plan",
362
+ expected_run_id=ctx.run.run_id,
363
+ )
364
+
365
+
366
+ def plan_command_decision(
367
+ task: TaskRecord,
368
+ lock: TaskLock | None,
369
+ *,
370
+ run: TaskRunRecord | None,
371
+ ) -> Decision:
372
+ ctx = build_policy_context(task, lock, run=run)
373
+ if (
374
+ ctx.task.status_stage not in {"draft", "plan_review"}
375
+ or ctx.active_stage != "planning"
376
+ ):
377
+ return _policy_decision(
378
+ False,
379
+ "Plan commands require active planning.",
380
+ EXIT_CODE_INVALID_TRANSITION,
381
+ )
382
+ if ctx.run is None or ctx.run.run_type != "planning" or ctx.run.status != "running":
383
+ return _policy_decision(
384
+ False,
385
+ "Plan commands require an active planning run.",
386
+ EXIT_CODE_INVALID_TRANSITION,
387
+ )
388
+ return _active_stage_lock_decision(
389
+ ctx,
390
+ expected_stage="planning",
391
+ action="run a planning command",
392
+ expected_run_id=ctx.run.run_id,
393
+ )
394
+
395
+
396
+ def plan_approve_decision(task: TaskRecord, lock: TaskLock | None) -> Decision:
397
+ ctx = build_policy_context(task, lock)
398
+ if ctx.task.status_stage != "plan_review":
399
+ return _policy_decision(
400
+ False,
401
+ "Plan approval requires plan_review state.",
402
+ EXIT_CODE_APPROVAL_REQUIRED,
403
+ )
404
+ if ctx.active_stage is not None:
405
+ return _policy_decision(
406
+ False,
407
+ (
408
+ f"Task {ctx.task.id} still has a {ctx.active_stage} lock. "
409
+ "Break it before plan review actions."
410
+ ),
411
+ EXIT_CODE_LOCK_CONFLICT,
412
+ )
413
+ return _policy_decision(True, "Plan can be approved.", 0)
414
+
415
+
416
+ def plan_revise_decision(task: TaskRecord, lock: TaskLock | None) -> Decision:
417
+ ctx = build_policy_context(task, lock)
418
+ if ctx.task.status_stage != "plan_review":
419
+ return _policy_decision(
420
+ False,
421
+ "Plan revision requires plan_review state.",
422
+ EXIT_CODE_INVALID_TRANSITION,
423
+ )
424
+ if ctx.active_stage is not None:
425
+ return _policy_decision(
426
+ False,
427
+ (
428
+ f"Task {ctx.task.id} still has a {ctx.active_stage} lock. "
429
+ "Break it before revising the plan."
430
+ ),
431
+ EXIT_CODE_LOCK_CONFLICT,
432
+ )
433
+ return _policy_decision(True, "Plan can be revised.", 0)
434
+
435
+
436
+ def implementation_mutation_decision(
437
+ task: TaskRecord,
438
+ lock: TaskLock | None,
439
+ *,
440
+ run: TaskRunRecord | None,
441
+ action: str,
442
+ ) -> Decision:
443
+ ctx = build_policy_context(task, lock, run=run)
444
+ if (
445
+ ctx.task.status_stage not in {"approved", "failed_validation", "implementing"}
446
+ or ctx.active_stage != "implementation"
447
+ ):
448
+ return _policy_decision(
449
+ False,
450
+ f"{action} requires active implementation.",
451
+ EXIT_CODE_INVALID_TRANSITION,
452
+ )
453
+ if (
454
+ ctx.run is None
455
+ or ctx.run.run_type != "implementation"
456
+ or ctx.run.status != "running"
457
+ ):
458
+ return _policy_decision(
459
+ False,
460
+ f"{action} requires an active implementation run.",
461
+ EXIT_CODE_INVALID_TRANSITION,
462
+ )
463
+ return _active_stage_lock_decision(
464
+ ctx,
465
+ expected_stage="implementing",
466
+ action=action,
467
+ expected_run_id=ctx.run.run_id,
468
+ )
469
+
470
+
471
+ def validation_check_decision(
472
+ task: TaskRecord,
473
+ lock: TaskLock | None,
474
+ *,
475
+ run: TaskRunRecord | None,
476
+ ) -> Decision:
477
+ ctx = build_policy_context(task, lock, run=run)
478
+ if ctx.task.status_stage != "implemented" or ctx.active_stage != "validation":
479
+ return _policy_decision(
480
+ False,
481
+ "Validation checks require active validation.",
482
+ EXIT_CODE_INVALID_TRANSITION,
483
+ )
484
+ if (
485
+ ctx.run is None
486
+ or ctx.run.run_type != "validation"
487
+ or ctx.run.status != "running"
488
+ ):
489
+ return _policy_decision(
490
+ False,
491
+ "Validation checks require an active validation run.",
492
+ EXIT_CODE_INVALID_TRANSITION,
493
+ )
494
+ return _active_stage_lock_decision(
495
+ ctx,
496
+ expected_stage="validating",
497
+ action="record validation checks",
498
+ expected_run_id=ctx.run.run_id,
499
+ )
500
+
501
+
502
+ def _active_stage_lock_decision(
503
+ ctx: PolicyContext,
504
+ *,
505
+ expected_stage: str,
506
+ action: str,
507
+ expected_run_id: str | None = None,
508
+ ) -> Decision:
509
+ if ctx.lock is None:
510
+ return _policy_decision(
511
+ False,
512
+ f"Task {ctx.task.id} needs an active {expected_stage} lock to {action}.",
513
+ EXIT_CODE_LOCK_CONFLICT,
514
+ )
515
+ expected_run_type = {
516
+ "planning": "planning",
517
+ "implementing": "implementation",
518
+ "validating": "validation",
519
+ }[expected_stage]
520
+ if ctx.lock.run_type != expected_run_type:
521
+ return _policy_decision(
522
+ False,
523
+ f"Task {ctx.task.id} is locked for {ctx.lock.stage}, not {expected_stage}.",
524
+ EXIT_CODE_LOCK_CONFLICT,
525
+ )
526
+ if expected_run_id is not None and ctx.lock.run_id != expected_run_id:
527
+ return _policy_decision(
528
+ False,
529
+ (
530
+ f"Task {ctx.task.id} has a {expected_stage} lock for "
531
+ f"{ctx.lock.run_id}, "
532
+ f"not {expected_run_id}."
533
+ ),
534
+ EXIT_CODE_LOCK_CONFLICT,
535
+ )
536
+ return _policy_decision(True, f"Task can {action}.", 0)
537
+
538
+
539
+ def require_known_actor_role(actor_role: str) -> str:
540
+ if actor_role not in {"planner", "implementer", "user"}:
541
+ raise ValueError(f"Unsupported actor role: {actor_role}")
542
+ return actor_role