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,96 @@
1
+ """Lock transfer operations for handoff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from taskledger.domain.models import ActorRef, HarnessRef, TaskLock
8
+ from taskledger.errors import LaunchError
9
+ from taskledger.storage.task_store import resolve_lock, save_lock
10
+ from taskledger.timeutils import utc_now_iso
11
+
12
+
13
+ def transfer_lock(
14
+ workspace_root: Path,
15
+ task_id: str,
16
+ lock_id: str,
17
+ to_actor: ActorRef,
18
+ to_harness: HarnessRef,
19
+ ) -> TaskLock:
20
+ """Transfer a lock from current holder to new actor."""
21
+ lock = resolve_lock(workspace_root, task_id)
22
+
23
+ if lock is None:
24
+ raise LaunchError(f"Lock not found for task: {task_id}")
25
+
26
+ if lock.lock_id != lock_id:
27
+ raise LaunchError(f"Lock ID mismatch: expected {lock_id}, got {lock.lock_id}")
28
+
29
+ # Record transfer in history
30
+ from_actor_str = f"{lock.holder.actor_type}:{lock.holder.actor_name}"
31
+ to_actor_str = f"{to_actor.actor_type}:{to_actor.actor_name}"
32
+
33
+ new_entry = (lock_id, from_actor_str, to_actor_str)
34
+ new_history = lock.transfer_history + (new_entry,)
35
+
36
+ updated = TaskLock(
37
+ lock_id=lock.lock_id,
38
+ task_id=lock.task_id,
39
+ stage=lock.stage,
40
+ run_id=lock.run_id,
41
+ created_at=lock.created_at,
42
+ expires_at=lock.expires_at,
43
+ reason=lock.reason,
44
+ holder=to_actor,
45
+ lease_seconds=lock.lease_seconds,
46
+ last_heartbeat_at=lock.last_heartbeat_at,
47
+ broken_at=lock.broken_at,
48
+ broken_by=lock.broken_by,
49
+ broken_reason=lock.broken_reason,
50
+ actor=to_actor,
51
+ harness=to_harness,
52
+ transfer_history=new_history,
53
+ transfer_date=utc_now_iso(),
54
+ )
55
+
56
+ save_lock(workspace_root, task_id, updated)
57
+ return updated
58
+
59
+
60
+ def release_lock(
61
+ workspace_root: Path,
62
+ task_id: str,
63
+ lock_id: str,
64
+ ) -> TaskLock:
65
+ """Release a lock, preparing it for transfer or removal."""
66
+ lock = resolve_lock(workspace_root, task_id)
67
+
68
+ if lock is None:
69
+ raise LaunchError(f"Lock not found for task: {task_id}")
70
+
71
+ if lock.lock_id != lock_id:
72
+ raise LaunchError(f"Lock ID mismatch: expected {lock_id}, got {lock.lock_id}")
73
+
74
+ # Record the release time in transfer_date
75
+ updated = TaskLock(
76
+ lock_id=lock.lock_id,
77
+ task_id=lock.task_id,
78
+ stage=lock.stage,
79
+ run_id=lock.run_id,
80
+ created_at=lock.created_at,
81
+ expires_at=lock.expires_at,
82
+ reason=lock.reason,
83
+ holder=lock.holder,
84
+ lease_seconds=lock.lease_seconds,
85
+ last_heartbeat_at=lock.last_heartbeat_at,
86
+ broken_at=lock.broken_at,
87
+ broken_by=lock.broken_by,
88
+ broken_reason=lock.broken_reason,
89
+ actor=lock.actor,
90
+ harness=lock.harness,
91
+ transfer_history=lock.transfer_history,
92
+ transfer_date=utc_now_iso(),
93
+ )
94
+
95
+ save_lock(workspace_root, task_id, updated)
96
+ return updated
@@ -0,0 +1,397 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal, TypedDict
7
+
8
+ from taskledger.domain.models import PlanRecord, TaskTodo
9
+ from taskledger.errors import LaunchError
10
+ from taskledger.storage.task_store import list_plans, resolve_plan, resolve_task
11
+
12
+ Severity = Literal["error", "warning"]
13
+
14
+ _GENERIC_TODO_PHRASES = frozenset(
15
+ {
16
+ "fix tests",
17
+ "clean up",
18
+ "update docs",
19
+ "make it work",
20
+ "handle edge cases",
21
+ "add tests",
22
+ "refactor",
23
+ }
24
+ )
25
+
26
+ _PLACEHOLDER_PATTERNS: list[re.Pattern[str]] = [
27
+ re.compile(r"\bTBD\b", re.IGNORECASE),
28
+ re.compile(r"\bTODO\b"),
29
+ re.compile(r"\blater\b", re.IGNORECASE),
30
+ re.compile(r"\bappropriate\b", re.IGNORECASE),
31
+ re.compile(r"\bsimilar to above\b", re.IGNORECASE),
32
+ re.compile(r"\betc\.?\b"),
33
+ re.compile(r"\bfix tests\b", re.IGNORECASE),
34
+ re.compile(r"\bclean up\b", re.IGNORECASE),
35
+ ]
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class PlanLintIssue:
40
+ severity: Severity
41
+ code: str
42
+ location: str
43
+ message: str
44
+ hint: str | None = None
45
+
46
+ def to_dict(self) -> dict[str, object]:
47
+ result: dict[str, object] = {
48
+ "severity": self.severity,
49
+ "code": self.code,
50
+ "location": self.location,
51
+ "message": self.message,
52
+ }
53
+ if self.hint is not None:
54
+ result["hint"] = self.hint
55
+ return result
56
+
57
+
58
+ class PlanLintPayload(TypedDict):
59
+ kind: str
60
+ task_id: str
61
+ plan_id: str
62
+ plan_version: int
63
+ strict: bool
64
+ passed: bool
65
+ summary: dict[str, int]
66
+ issues: list[dict[str, object]]
67
+
68
+
69
+ def lint_plan(
70
+ workspace_root: Path,
71
+ task_ref: str,
72
+ *,
73
+ version: int | None = None,
74
+ strict: bool = False,
75
+ ) -> PlanLintPayload:
76
+ task = resolve_task(workspace_root, task_ref)
77
+ if version is not None:
78
+ plan = resolve_plan(workspace_root, task.id, version=version)
79
+ else:
80
+ plans = list_plans(workspace_root, task.id)
81
+ if not plans:
82
+ raise LaunchError(
83
+ f"No plans found for task {task.id}.",
84
+ )
85
+ plan = plans[-1]
86
+
87
+ issues = _run_lint_rules(plan, strict)
88
+
89
+ error_count = sum(1 for i in issues if i.severity == "error")
90
+ warning_count = sum(1 for i in issues if i.severity == "warning")
91
+ passed = error_count == 0
92
+
93
+ return PlanLintPayload(
94
+ kind="plan_lint",
95
+ task_id=task.id,
96
+ plan_id=plan.plan_id,
97
+ plan_version=plan.plan_version,
98
+ strict=strict,
99
+ passed=passed,
100
+ summary={"errors": error_count, "warnings": warning_count},
101
+ issues=[issue.to_dict() for issue in issues],
102
+ )
103
+
104
+
105
+ def _run_lint_rules(plan: PlanRecord, strict: bool) -> list[PlanLintIssue]:
106
+ issues: list[PlanLintIssue] = []
107
+
108
+ # 1. missing_goal
109
+ goal = _goal_text(plan)
110
+ if not goal:
111
+ issues.append(
112
+ PlanLintIssue(
113
+ severity="error",
114
+ code="missing_goal",
115
+ location="plan.goal",
116
+ message="Plan must define a concrete goal.",
117
+ hint="Add `goal: ...` to plan front matter or a `## Goal` section.",
118
+ )
119
+ )
120
+
121
+ # 2. missing_acceptance_criteria
122
+ if not plan.criteria:
123
+ issues.append(
124
+ PlanLintIssue(
125
+ severity="error",
126
+ code="missing_acceptance_criteria",
127
+ location="plan.criteria",
128
+ message="Plan must have at least one acceptance criterion.",
129
+ hint="Add `acceptance_criteria:` to plan front matter.",
130
+ )
131
+ )
132
+
133
+ # 3. missing_todos
134
+ waiver = (plan.todos_waived_reason or "").strip()
135
+ if not plan.todos and not waiver:
136
+ issues.append(
137
+ PlanLintIssue(
138
+ severity="error",
139
+ code="missing_todos",
140
+ location="plan.todos",
141
+ message="Plan must have at least one todo or a todos_waived_reason.",
142
+ hint="Add `todos:` to plan front matter or set `todos_waived_reason`.",
143
+ )
144
+ )
145
+
146
+ # 4. todo_not_concrete
147
+ for index, todo in enumerate(plan.todos):
148
+ if not _todo_is_concrete(todo):
149
+ issues.append(
150
+ PlanLintIssue(
151
+ severity="error",
152
+ code="todo_not_concrete",
153
+ location=f"plan.todos[{index}]",
154
+ message=f'Todo is too vague: "{todo.text}".',
155
+ hint="Name a file, function, command, or specific action.",
156
+ )
157
+ )
158
+
159
+ # 5. placeholder checks
160
+ issues.extend(_placeholder_issues("plan.goal", goal, strict))
161
+ issues.extend(_placeholder_issues("plan.body", plan.body, strict))
162
+ for index, criterion in enumerate(plan.criteria):
163
+ issues.extend(
164
+ _placeholder_issues(f"plan.criteria[{index}]", criterion.text, strict)
165
+ )
166
+ for index, todo in enumerate(plan.todos):
167
+ issues.extend(_placeholder_issues(f"plan.todos[{index}]", todo.text, strict))
168
+ if todo.validation_hint:
169
+ issues.extend(
170
+ _placeholder_issues(
171
+ f"plan.todos[{index}].validation_hint", todo.validation_hint, strict
172
+ )
173
+ )
174
+
175
+ # Extended rules (warnings, errors in strict mode)
176
+ # 6. missing_files
177
+ if not _has_file_reference(plan):
178
+ sev: Severity = "error" if strict else "warning"
179
+ issues.append(
180
+ PlanLintIssue(
181
+ severity=sev,
182
+ code="missing_files",
183
+ location="plan.files",
184
+ message="Plan should name files expected to change or inspect.",
185
+ hint="Add `files:` to plan front matter.",
186
+ )
187
+ )
188
+
189
+ # 7. missing_test_commands
190
+ if not _has_test_command(plan):
191
+ sev = "error" if strict else "warning"
192
+ issues.append(
193
+ PlanLintIssue(
194
+ severity=sev,
195
+ code="missing_test_commands",
196
+ location="plan.test_commands",
197
+ message="Plan should include commands for verification.",
198
+ hint="Add `test_commands:` to plan front matter.",
199
+ )
200
+ )
201
+
202
+ # 8. missing_expected_outputs
203
+ if not _has_expected_output(plan):
204
+ sev = "error" if strict else "warning"
205
+ issues.append(
206
+ PlanLintIssue(
207
+ severity=sev,
208
+ code="missing_expected_outputs",
209
+ location="plan.expected_outputs",
210
+ message="Plan should describe expected outputs for verification.",
211
+ hint="Add `expected_outputs:` to plan front matter.",
212
+ )
213
+ )
214
+
215
+ # 9. missing_todo_validation_hint
216
+ if (
217
+ plan.todos
218
+ and not _has_test_command(plan)
219
+ and not _todos_have_validation_hints(plan)
220
+ ):
221
+ sev = "error" if strict else "warning"
222
+ issues.append(
223
+ PlanLintIssue(
224
+ severity=sev,
225
+ code="missing_todo_validation_hint",
226
+ location="plan.todos",
227
+ message=(
228
+ "Plan todos should include validation_hint or the plan should "
229
+ "define test_commands."
230
+ ),
231
+ hint=(
232
+ "Add validation_hint to each todo or add plan-level test_commands."
233
+ ),
234
+ )
235
+ )
236
+
237
+ return issues
238
+
239
+
240
+ def _goal_text(plan: PlanRecord) -> str | None:
241
+ if plan.goal and plan.goal.strip():
242
+ return plan.goal.strip()
243
+ heading = _body_section(plan.body, "Goal")
244
+ if heading and heading.strip():
245
+ return heading.strip()
246
+ for line in plan.body.splitlines():
247
+ stripped = line.strip()
248
+ if stripped.lower().startswith("goal:"):
249
+ rest = stripped[len("goal:") :].strip()
250
+ if rest:
251
+ return rest
252
+ return None
253
+
254
+
255
+ def _body_section(body: str, heading: str) -> str | None:
256
+ pattern = re.compile(r"^(#{1,3})\s+" + re.escape(heading) + r"\s*$", re.IGNORECASE)
257
+ lines = body.splitlines()
258
+ start: int | None = None
259
+ depth = 0
260
+ for i, line in enumerate(lines):
261
+ m = pattern.match(line)
262
+ if m:
263
+ start = i + 1
264
+ depth = len(m.group(1))
265
+ continue
266
+ if start is not None:
267
+ m2 = re.match(r"^(#{1," + str(depth) + r"})\s+\S", line)
268
+ if m2:
269
+ section_lines = lines[start:i]
270
+ return "\n".join(section_lines).strip() or None
271
+ if start is not None:
272
+ section_lines = lines[start:]
273
+ return "\n".join(section_lines).strip() or None
274
+ return None
275
+
276
+
277
+ def _has_placeholder(text: str) -> list[str]:
278
+ if not text:
279
+ return []
280
+ found: list[str] = []
281
+ for pat in _PLACEHOLDER_PATTERNS:
282
+ m = pat.search(text)
283
+ if m:
284
+ found.append(m.group(0))
285
+ return found
286
+
287
+
288
+ def _placeholder_issues(
289
+ label: str, text: str | None, strict: bool
290
+ ) -> list[PlanLintIssue]:
291
+ if not text:
292
+ return []
293
+ phrases = _has_placeholder(text)
294
+ if not phrases:
295
+ return []
296
+ sev: Severity = "error" if strict else "warning"
297
+ return [
298
+ PlanLintIssue(
299
+ severity=sev,
300
+ code="placeholder",
301
+ location=label,
302
+ message=f'Placeholder phrase found: "{phrases[0]}".',
303
+ hint="Replace placeholder with specific content.",
304
+ )
305
+ ]
306
+
307
+
308
+ def _todo_is_concrete(todo: TaskTodo) -> bool:
309
+ text = todo.text.strip()
310
+ if not text:
311
+ return False
312
+
313
+ # Fewer than 3 meaningful words
314
+ words = [w for w in text.split() if len(w) > 1]
315
+ if len(words) < 3:
316
+ return False
317
+
318
+ # Too short
319
+ stripped = text.replace(" ", "")
320
+ if len(stripped) < 12:
321
+ return False
322
+
323
+ # Generic phrase match
324
+ if text.lower().strip() in _GENERIC_TODO_PHRASES:
325
+ return False
326
+
327
+ # Contains a file path, module path, function, class, command, or test
328
+ concrete_indicators = [
329
+ re.compile(r"[.\w/\\]+\.\w{1,4}"), # file paths like foo.py, bar.rs
330
+ re.compile(r"`[^`]+`"), # backticked commands/symbols
331
+ re.compile(r"\w+\.\w+\("), # function calls like foo.bar()
332
+ re.compile(r"\btests?/\w"), # test directory references
333
+ re.compile(r"\bpytest\b"),
334
+ re.compile(r"\bruff\b"),
335
+ re.compile(r"\bmypy\b"),
336
+ re.compile(r"\bclass\s+\w"),
337
+ re.compile(r"\bdef\s+\w"),
338
+ ]
339
+ for pat in concrete_indicators:
340
+ if pat.search(text):
341
+ return True
342
+
343
+ # Has a validation hint
344
+ if todo.validation_hint and todo.validation_hint.strip():
345
+ return True
346
+
347
+ # Has clear action and target (heuristic: verb + object with specificity)
348
+ # Check for path separators or dotted names
349
+ if "/" in text or "\\" in text or "." in text:
350
+ return True
351
+
352
+ return False
353
+
354
+
355
+ def _has_file_reference(plan: PlanRecord) -> bool:
356
+ if plan.files:
357
+ return True
358
+ # Check body for file-like paths
359
+ for line in plan.body.splitlines():
360
+ if re.search(r"[.\w/\\]+\.\w{1,4}", line):
361
+ return True
362
+ return False
363
+
364
+
365
+ def _has_test_command(plan: PlanRecord) -> bool:
366
+ if plan.test_commands:
367
+ return True
368
+ # Check body for code blocks with test commands
369
+ test_tools = {"pytest", "ruff", "mypy", "python -m", "tox", "pre-commit"}
370
+ for line in plan.body.splitlines():
371
+ stripped = line.strip()
372
+ for tool in test_tools:
373
+ if tool in stripped:
374
+ return True
375
+ return False
376
+
377
+
378
+ def _has_expected_output(plan: PlanRecord) -> bool:
379
+ if plan.expected_outputs:
380
+ return True
381
+ section = _body_section(plan.body, "Expected output")
382
+ if section:
383
+ return True
384
+ section = _body_section(plan.body, "Expected outputs")
385
+ if section:
386
+ return True
387
+ # Check if any todos have validation hints
388
+ for todo in plan.todos:
389
+ if todo.validation_hint and todo.validation_hint.strip():
390
+ return True
391
+ return False
392
+
393
+
394
+ def _todos_have_validation_hints(plan: PlanRecord) -> bool:
395
+ return any(
396
+ todo.validation_hint and todo.validation_hint.strip() for todo in plan.todos
397
+ )