taskledger 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskledger/__init__.py +5 -0
- taskledger/__main__.py +6 -0
- taskledger/_version.py +24 -0
- taskledger/api/__init__.py +13 -0
- taskledger/api/handoff.py +247 -0
- taskledger/api/introductions.py +9 -0
- taskledger/api/locks.py +4 -0
- taskledger/api/plans.py +31 -0
- taskledger/api/project.py +185 -0
- taskledger/api/questions.py +19 -0
- taskledger/api/search.py +87 -0
- taskledger/api/task_runs.py +38 -0
- taskledger/api/tasks.py +61 -0
- taskledger/cli.py +600 -0
- taskledger/cli_actor.py +196 -0
- taskledger/cli_common.py +617 -0
- taskledger/cli_implement.py +409 -0
- taskledger/cli_migrate.py +328 -0
- taskledger/cli_misc.py +984 -0
- taskledger/cli_plan.py +478 -0
- taskledger/cli_question.py +350 -0
- taskledger/cli_task.py +257 -0
- taskledger/cli_validate.py +285 -0
- taskledger/command_inventory.py +125 -0
- taskledger/domain/__init__.py +2 -0
- taskledger/domain/models.py +1697 -0
- taskledger/domain/policies.py +542 -0
- taskledger/domain/states.py +320 -0
- taskledger/errors.py +165 -0
- taskledger/exchange.py +343 -0
- taskledger/ids.py +19 -0
- taskledger/py.typed +0 -0
- taskledger/search.py +349 -0
- taskledger/services/__init__.py +1 -0
- taskledger/services/actors.py +245 -0
- taskledger/services/dashboard.py +306 -0
- taskledger/services/doctor.py +435 -0
- taskledger/services/handoff.py +1029 -0
- taskledger/services/handoff_lifecycle.py +154 -0
- taskledger/services/navigation.py +930 -0
- taskledger/services/phase5_lock_transfer.py +96 -0
- taskledger/services/plan_lint.py +397 -0
- taskledger/services/serve_read_model.py +852 -0
- taskledger/services/tasks.py +4224 -0
- taskledger/services/validation.py +221 -0
- taskledger/services/web_dashboard.py +1742 -0
- taskledger/storage/__init__.py +39 -0
- taskledger/storage/atomic.py +57 -0
- taskledger/storage/common.py +90 -0
- taskledger/storage/events.py +98 -0
- taskledger/storage/frontmatter.py +57 -0
- taskledger/storage/indexes.py +42 -0
- taskledger/storage/init.py +187 -0
- taskledger/storage/locks.py +83 -0
- taskledger/storage/meta.py +103 -0
- taskledger/storage/migrations.py +207 -0
- taskledger/storage/paths.py +166 -0
- taskledger/storage/project_config.py +393 -0
- taskledger/storage/repos.py +256 -0
- taskledger/storage/task_store.py +836 -0
- taskledger/timeutils.py +7 -0
- taskledger-0.1.0.dist-info/METADATA +411 -0
- taskledger-0.1.0.dist-info/RECORD +67 -0
- taskledger-0.1.0.dist-info/WHEEL +5 -0
- taskledger-0.1.0.dist-info/entry_points.txt +2 -0
- taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- taskledger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|