devmemory-cli 0.1.0.dev0__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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""The state engine (§3).
|
|
2
|
+
|
|
3
|
+
Aggregates evidence from Git, Entire, tests, and optional Graph into one
|
|
4
|
+
:class:`NormalizedState`, computes an evidence-based :class:`TaskStatus`, and
|
|
5
|
+
stores a snapshot. It does **not** modify application code and does **not**
|
|
6
|
+
decide how code should be written.
|
|
7
|
+
|
|
8
|
+
Public surface (mirrors the MCP tools):
|
|
9
|
+
create_task · get_state · refresh_state · get_checkpoint
|
|
10
|
+
report_issue · resolve_issue · mark_complete
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
|
|
17
|
+
from devmemory.domain.enums import IssueStatus, RequirementStatus, TaskStatus, TestRunStatus
|
|
18
|
+
from devmemory.domain.errors import DevMemoryError
|
|
19
|
+
from devmemory.domain.taskloop import (
|
|
20
|
+
Issue,
|
|
21
|
+
NormalizedState,
|
|
22
|
+
Requirement,
|
|
23
|
+
StateGit,
|
|
24
|
+
StateIssue,
|
|
25
|
+
StateRequirement,
|
|
26
|
+
StateSnapshot,
|
|
27
|
+
StateTask,
|
|
28
|
+
StateTests,
|
|
29
|
+
Task,
|
|
30
|
+
TaskTestRun,
|
|
31
|
+
)
|
|
32
|
+
from devmemory.logging import get_logger
|
|
33
|
+
from devmemory.services.brief import brief_context
|
|
34
|
+
from devmemory.services.context import ProjectContext
|
|
35
|
+
from devmemory.services.taskloop import collectors
|
|
36
|
+
from devmemory.services.taskloop.requirements import (
|
|
37
|
+
apply_verdicts,
|
|
38
|
+
evaluate_requirements,
|
|
39
|
+
normalize_requirements,
|
|
40
|
+
)
|
|
41
|
+
from devmemory.storage.tasks import TaskRepository
|
|
42
|
+
|
|
43
|
+
_log = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TaskNotFoundError(DevMemoryError):
|
|
47
|
+
exit_code = 3
|
|
48
|
+
|
|
49
|
+
def __init__(self, task_id: str) -> None:
|
|
50
|
+
super().__init__(f"No task {task_id!r}. Create one with `devmemory task new`.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- task lifecycle --------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_task(
|
|
57
|
+
ctx: ProjectContext,
|
|
58
|
+
*,
|
|
59
|
+
goal: str,
|
|
60
|
+
task_id: str | None = None,
|
|
61
|
+
test_command: str | None = None,
|
|
62
|
+
push_policy: str = "manual",
|
|
63
|
+
) -> Task:
|
|
64
|
+
"""Normalize the goal into requirements and persist a new task (§5).
|
|
65
|
+
|
|
66
|
+
The task's ``base_commit`` is pinned to current HEAD so later refreshes can
|
|
67
|
+
measure cumulative progress.
|
|
68
|
+
"""
|
|
69
|
+
repo = TaskRepository(ctx.db)
|
|
70
|
+
tid = task_id or repo.next_task_id()
|
|
71
|
+
if repo.get(tid) is not None:
|
|
72
|
+
raise DevMemoryError(f"Task {tid!r} already exists.")
|
|
73
|
+
|
|
74
|
+
head = ctx.git.head_sha() if ctx.git.is_repository() else None
|
|
75
|
+
branch = ctx.git.current_branch() or "main"
|
|
76
|
+
cmd = test_command or ctx.config.tests.command
|
|
77
|
+
|
|
78
|
+
requirements = normalize_requirements(goal, ctx, brief=brief_context(ctx))
|
|
79
|
+
task = Task(
|
|
80
|
+
id=tid,
|
|
81
|
+
goal=goal,
|
|
82
|
+
status=TaskStatus.IN_PROGRESS,
|
|
83
|
+
branch=branch,
|
|
84
|
+
base_commit=head,
|
|
85
|
+
test_command=cmd,
|
|
86
|
+
push_policy=push_policy,
|
|
87
|
+
requirements=requirements,
|
|
88
|
+
)
|
|
89
|
+
created = repo.create(task)
|
|
90
|
+
_log.info("taskloop.task.created", task_id=tid, requirements=len(requirements), base=head)
|
|
91
|
+
return created
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_state(ctx: ProjectContext, task_id: str) -> NormalizedState:
|
|
95
|
+
"""Return the most recent stored snapshot, or a first snapshot if none exists."""
|
|
96
|
+
repo = TaskRepository(ctx.db)
|
|
97
|
+
if repo.get(task_id) is None:
|
|
98
|
+
_raise_not_found(task_id)
|
|
99
|
+
snap = repo.latest_snapshot(task_id)
|
|
100
|
+
if snap is not None:
|
|
101
|
+
return snap.state
|
|
102
|
+
return refresh_state(ctx, task_id)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def refresh_state(ctx: ProjectContext, task_id: str) -> NormalizedState:
|
|
106
|
+
"""Run every collector, re-evaluate requirements, recompute status, store a
|
|
107
|
+
snapshot, and return it (§11 ``refresh_state``)."""
|
|
108
|
+
started = datetime.now(UTC)
|
|
109
|
+
repo = TaskRepository(ctx.db)
|
|
110
|
+
task = repo.get(task_id) or _raise_not_found(task_id)
|
|
111
|
+
_log.info("taskloop.refresh.started", task_id=task_id)
|
|
112
|
+
|
|
113
|
+
# 1-3. collectors (each degrades gracefully)
|
|
114
|
+
git = collectors.collect_git(ctx, base_commit=task.base_commit)
|
|
115
|
+
checkpoint = collectors.collect_checkpoint(ctx, head_sha=git.commit_sha)
|
|
116
|
+
tests = collectors.collect_tests(
|
|
117
|
+
ctx, command=task.test_command, output_dir=ctx.paths.cache_dir / "taskloop" / task_id
|
|
118
|
+
)
|
|
119
|
+
impact = collectors.collect_impact(ctx, head_sha=git.commit_sha)
|
|
120
|
+
|
|
121
|
+
# persist the test run
|
|
122
|
+
if tests.status is not TestRunStatus.NOT_RUN:
|
|
123
|
+
repo.add_test_run(
|
|
124
|
+
TaskTestRun(
|
|
125
|
+
task_id=task_id,
|
|
126
|
+
command=tests.command or "",
|
|
127
|
+
status=tests.status,
|
|
128
|
+
passed=tests.passed,
|
|
129
|
+
failed=tests.failed,
|
|
130
|
+
skipped=tests.skipped,
|
|
131
|
+
exit_code=tests.exit_code,
|
|
132
|
+
output_path=tests.output_path,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# link the current commit to the task
|
|
137
|
+
if git.commit_sha:
|
|
138
|
+
repo.link_commit(task_id, git.commit_sha)
|
|
139
|
+
|
|
140
|
+
# 5. re-evaluate requirements (skip if the working tree is dirty - uncommitted
|
|
141
|
+
# work is not evidence yet; §13 commits before refresh)
|
|
142
|
+
requirements = task.requirements
|
|
143
|
+
if git.available and git.working_tree_clean and git.commit_sha:
|
|
144
|
+
checkpoint_ref = None
|
|
145
|
+
if checkpoint.last_committed_id:
|
|
146
|
+
ref = ctx.entire.get_checkpoint(checkpoint.last_committed_id)
|
|
147
|
+
checkpoint_ref = ref.intent if ref else None
|
|
148
|
+
verdicts = evaluate_requirements(
|
|
149
|
+
requirements,
|
|
150
|
+
ctx=ctx,
|
|
151
|
+
git=git,
|
|
152
|
+
tests=tests,
|
|
153
|
+
checkpoint_intent=checkpoint_ref,
|
|
154
|
+
base_commit=task.base_commit,
|
|
155
|
+
)
|
|
156
|
+
requirements = apply_verdicts(requirements, verdicts, by="llm-or-rules")
|
|
157
|
+
for req in requirements:
|
|
158
|
+
repo.update_requirement(task_id, req)
|
|
159
|
+
|
|
160
|
+
# 6. refresh engine-owned unresolved items (test failures)
|
|
161
|
+
repo.resolve_issues_by_kind(task_id, "test")
|
|
162
|
+
if tests.status in (TestRunStatus.FAILED, TestRunStatus.ERROR):
|
|
163
|
+
if tests.status is TestRunStatus.ERROR:
|
|
164
|
+
detail = "Test command could not run"
|
|
165
|
+
elif tests.failed == 0 and tests.passed == 0:
|
|
166
|
+
detail = f"Tests did not complete (exit {tests.exit_code})"
|
|
167
|
+
else:
|
|
168
|
+
detail = f"{tests.failed} test(s) failing, {tests.passed} passing"
|
|
169
|
+
repo.add_issue(
|
|
170
|
+
Issue(
|
|
171
|
+
task_id=task_id,
|
|
172
|
+
kind="test",
|
|
173
|
+
blocking=False,
|
|
174
|
+
description=detail + (f" - see {tests.output_path}" if tests.output_path else ""),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
open_issues = repo.open_issues(task_id)
|
|
178
|
+
|
|
179
|
+
# 7. compute overall status
|
|
180
|
+
overall, findings, focus = _compute_status(
|
|
181
|
+
task=task, git=git, tests=tests, requirements=requirements, open_issues=open_issues
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
state = NormalizedState(
|
|
185
|
+
task=StateTask(id=task.id, goal=task.goal, status=overall),
|
|
186
|
+
requirements=[
|
|
187
|
+
StateRequirement(id=r.id, description=r.description, status=r.status, reason=r.reason)
|
|
188
|
+
for r in requirements
|
|
189
|
+
],
|
|
190
|
+
checkpoint=checkpoint,
|
|
191
|
+
git=git,
|
|
192
|
+
tests=tests,
|
|
193
|
+
impact=impact,
|
|
194
|
+
unresolved=[
|
|
195
|
+
StateIssue(id=i.id, description=i.description, kind=i.kind, blocking=i.blocking)
|
|
196
|
+
for i in open_issues
|
|
197
|
+
],
|
|
198
|
+
findings=findings,
|
|
199
|
+
recommended_focus=focus,
|
|
200
|
+
overall_status=overall,
|
|
201
|
+
refreshed_at=started,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# 8. store the snapshot; keep the task row's status in sync
|
|
205
|
+
snap = repo.append_snapshot(state)
|
|
206
|
+
state.snapshot_id = snap.id
|
|
207
|
+
repo.set_status(task_id, overall)
|
|
208
|
+
|
|
209
|
+
_log.info(
|
|
210
|
+
"taskloop.refresh.done",
|
|
211
|
+
task_id=task_id,
|
|
212
|
+
status=overall.value,
|
|
213
|
+
snapshot=snap.id,
|
|
214
|
+
duration_s=round((datetime.now(UTC) - started).total_seconds(), 2),
|
|
215
|
+
)
|
|
216
|
+
return state
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_checkpoint(ctx: ProjectContext, checkpoint_id: str) -> dict[str, object]:
|
|
220
|
+
"""Compact, useful metadata for one checkpoint via Entire (§11 ``get_checkpoint``).
|
|
221
|
+
|
|
222
|
+
Never a full transcript by default.
|
|
223
|
+
"""
|
|
224
|
+
ref = ctx.entire.get_checkpoint(checkpoint_id)
|
|
225
|
+
if ref is None:
|
|
226
|
+
return {"checkpoint_id": checkpoint_id, "available": False, "reason": "not found in Entire"}
|
|
227
|
+
return {
|
|
228
|
+
"checkpoint_id": ref.checkpoint_id,
|
|
229
|
+
"available": True,
|
|
230
|
+
"intent": ref.intent,
|
|
231
|
+
"agent": ref.agent,
|
|
232
|
+
"model": ref.model,
|
|
233
|
+
"commit_sha": ref.commit_sha,
|
|
234
|
+
"created_at": ref.created_at.isoformat() if ref.created_at else None,
|
|
235
|
+
"association": ref.association_method.value,
|
|
236
|
+
"sessions": [s.session_id for s in ref.sessions if s.session_id],
|
|
237
|
+
"tokens_total": ref.tokens.total or None,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def report_issue(
|
|
242
|
+
ctx: ProjectContext, *, task_id: str, description: str, blocking: bool = False
|
|
243
|
+
) -> Issue:
|
|
244
|
+
"""Persist an unresolved item raised by the agent (§11 ``report_issue``)."""
|
|
245
|
+
repo = TaskRepository(ctx.db)
|
|
246
|
+
if repo.get(task_id) is None:
|
|
247
|
+
_raise_not_found(task_id)
|
|
248
|
+
issue = repo.add_issue(
|
|
249
|
+
Issue(task_id=task_id, description=description.strip(), kind="agent", blocking=blocking)
|
|
250
|
+
)
|
|
251
|
+
_log.info("taskloop.issue.reported", task_id=task_id, issue_id=issue.id, blocking=blocking)
|
|
252
|
+
return issue
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def resolve_issue(ctx: ProjectContext, *, issue_id: int) -> bool:
|
|
256
|
+
ok = TaskRepository(ctx.db).resolve_issue(issue_id)
|
|
257
|
+
_log.info("taskloop.issue.resolved", issue_id=issue_id, ok=ok)
|
|
258
|
+
return ok
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def set_requirement_status(
|
|
262
|
+
ctx: ProjectContext,
|
|
263
|
+
*,
|
|
264
|
+
task_id: str,
|
|
265
|
+
requirement_id: str,
|
|
266
|
+
status: RequirementStatus,
|
|
267
|
+
note: str = "",
|
|
268
|
+
) -> NormalizedState:
|
|
269
|
+
"""Manual requirement verdict (``evaluated_by='manual'``) - the agent asserts
|
|
270
|
+
it implemented something; the engine still independently verifies the
|
|
271
|
+
checkable parts (tests pass, tree clean) before it will report READY.
|
|
272
|
+
|
|
273
|
+
A manually-COMPLETE requirement is re-checked on the next refresh only if the
|
|
274
|
+
agent lowers it again - satisfied stays satisfied.
|
|
275
|
+
"""
|
|
276
|
+
repo = TaskRepository(ctx.db)
|
|
277
|
+
task = repo.get(task_id) or _raise_not_found(task_id)
|
|
278
|
+
match = next((r for r in task.requirements if r.id == requirement_id), None)
|
|
279
|
+
if match is None:
|
|
280
|
+
raise DevMemoryError(f"Task {task_id} has no requirement {requirement_id!r}.")
|
|
281
|
+
repo.update_requirement(
|
|
282
|
+
task_id,
|
|
283
|
+
match.model_copy(
|
|
284
|
+
update={
|
|
285
|
+
"status": status,
|
|
286
|
+
"reason": note.strip() or f"set to {status.value} by the agent",
|
|
287
|
+
"evidence": match.evidence,
|
|
288
|
+
"evaluated_at": datetime.now(UTC),
|
|
289
|
+
"evaluated_by": "manual",
|
|
290
|
+
}
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
_log.info(
|
|
294
|
+
"taskloop.requirement.set", task_id=task_id, requirement=requirement_id, status=status.value
|
|
295
|
+
)
|
|
296
|
+
return refresh_state(ctx, task_id)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def mark_complete(ctx: ProjectContext, task_id: str) -> NormalizedState:
|
|
300
|
+
"""Trigger a completion evaluation - never blindly mark READY (§11).
|
|
301
|
+
|
|
302
|
+
Runs a full refresh (which re-evaluates all evidence) and returns the state.
|
|
303
|
+
The caller sees READY only if the evidence supports it.
|
|
304
|
+
"""
|
|
305
|
+
repo = TaskRepository(ctx.db)
|
|
306
|
+
if repo.get(task_id) is None:
|
|
307
|
+
_raise_not_found(task_id)
|
|
308
|
+
_log.info("taskloop.mark_complete.requested", task_id=task_id)
|
|
309
|
+
state = refresh_state(ctx, task_id)
|
|
310
|
+
if state.overall_status is not TaskStatus.READY:
|
|
311
|
+
_log.info(
|
|
312
|
+
"taskloop.mark_complete.denied",
|
|
313
|
+
task_id=task_id,
|
|
314
|
+
status=state.overall_status.value,
|
|
315
|
+
)
|
|
316
|
+
return state
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --- status computation --------------------------------------------------
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _compute_status(
|
|
323
|
+
*,
|
|
324
|
+
task: Task,
|
|
325
|
+
git: StateGit,
|
|
326
|
+
tests: StateTests,
|
|
327
|
+
requirements: list[Requirement],
|
|
328
|
+
open_issues: list[Issue],
|
|
329
|
+
) -> tuple[TaskStatus, list[str], list[str]]:
|
|
330
|
+
"""Evidence-based status per §4. Returns (status, findings, recommended_focus)."""
|
|
331
|
+
reqs = requirements
|
|
332
|
+
findings: list[str] = []
|
|
333
|
+
focus: list[str] = []
|
|
334
|
+
|
|
335
|
+
# BLOCKED: git is the source of truth; without it nothing is verifiable.
|
|
336
|
+
if not git.available:
|
|
337
|
+
findings.append(f"Git unavailable: {git.reason}")
|
|
338
|
+
return TaskStatus.BLOCKED, findings, ["Restore git access before continuing"]
|
|
339
|
+
|
|
340
|
+
blockers = [i for i in open_issues if i.blocking and i.status is IssueStatus.OPEN]
|
|
341
|
+
if blockers:
|
|
342
|
+
for i in blockers:
|
|
343
|
+
findings.append(f"Blocking issue #{i.id}: {i.description}")
|
|
344
|
+
return TaskStatus.BLOCKED, findings, ["Resolve the blocking issue or escalate to a human"]
|
|
345
|
+
|
|
346
|
+
incomplete = [r for r in reqs if r.status is not RequirementStatus.COMPLETE]
|
|
347
|
+
unknown = [r for r in reqs if r.status is RequirementStatus.UNKNOWN]
|
|
348
|
+
tests_ok = tests.status is TestRunStatus.PASSED or (
|
|
349
|
+
tests.status is TestRunStatus.NOT_RUN and task.test_command is None
|
|
350
|
+
)
|
|
351
|
+
non_blocking_issues = [i for i in open_issues if not i.blocking]
|
|
352
|
+
|
|
353
|
+
# findings
|
|
354
|
+
if not git.working_tree_clean:
|
|
355
|
+
findings.append("Working tree is dirty - commit before the state can be fully evaluated")
|
|
356
|
+
focus.append("Commit the current changes")
|
|
357
|
+
if tests.status is TestRunStatus.FAILED and (tests.failed or tests.passed):
|
|
358
|
+
findings.append(f"{tests.failed} test(s) failing")
|
|
359
|
+
focus.append("Fix failing tests")
|
|
360
|
+
elif tests.status is TestRunStatus.FAILED:
|
|
361
|
+
findings.append(f"Test command did not complete (exit {tests.exit_code})")
|
|
362
|
+
focus.append("Repair the test command / environment")
|
|
363
|
+
elif tests.status is TestRunStatus.ERROR:
|
|
364
|
+
findings.append("Test command could not run")
|
|
365
|
+
focus.append("Repair the test command / environment")
|
|
366
|
+
elif tests.status is TestRunStatus.FAILED_TO_PARSE:
|
|
367
|
+
findings.append("No parseable test results (no tests collected, or unknown format)")
|
|
368
|
+
focus.append("Add tests, or set tests.parser / tests.command")
|
|
369
|
+
elif tests.status is TestRunStatus.NOT_RUN and task.test_command:
|
|
370
|
+
findings.append("Tests configured but not yet run")
|
|
371
|
+
for r in incomplete:
|
|
372
|
+
findings.append(f"{r.id} {r.status.value}: {r.reason or r.description}")
|
|
373
|
+
focus.append(f"{r.id}: {r.description}")
|
|
374
|
+
for i in non_blocking_issues:
|
|
375
|
+
findings.append(f"Open issue #{i.id}: {i.description}")
|
|
376
|
+
|
|
377
|
+
# READY: requirements complete, tests pass, no open issues, tree clean.
|
|
378
|
+
if reqs and not incomplete and tests_ok and not non_blocking_issues and git.working_tree_clean:
|
|
379
|
+
return TaskStatus.READY, findings or ["All requirements satisfied; tests pass"], []
|
|
380
|
+
|
|
381
|
+
# IN_PROGRESS: nothing conclusive yet (no commits, everything still unknown).
|
|
382
|
+
nothing_evaluated = not reqs or (
|
|
383
|
+
len(unknown) == len(reqs) and tests.status is TestRunStatus.NOT_RUN
|
|
384
|
+
)
|
|
385
|
+
if nothing_evaluated and git.working_tree_clean and not non_blocking_issues:
|
|
386
|
+
return TaskStatus.IN_PROGRESS, findings, focus or ["Begin implementing the requirements"]
|
|
387
|
+
|
|
388
|
+
# otherwise there is concrete unfinished work
|
|
389
|
+
return TaskStatus.NEEDS_WORK, findings, focus[:6]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _raise_not_found(task_id: str) -> Task:
|
|
393
|
+
raise TaskNotFoundError(task_id)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def latest_task(ctx: ProjectContext) -> Task | None:
|
|
397
|
+
return TaskRepository(ctx.db).latest()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def list_tasks(ctx: ProjectContext) -> list[Task]:
|
|
401
|
+
return TaskRepository(ctx.db).list_tasks()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_task(ctx: ProjectContext, task_id: str) -> Task:
|
|
405
|
+
return TaskRepository(ctx.db).get(task_id) or _raise_not_found(task_id)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def list_snapshots(ctx: ProjectContext, task_id: str, *, limit: int = 20) -> list[StateSnapshot]:
|
|
409
|
+
return TaskRepository(ctx.db).snapshots(task_id, limit=limit)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
__all__ = [
|
|
413
|
+
"TaskNotFoundError",
|
|
414
|
+
"create_task",
|
|
415
|
+
"get_checkpoint",
|
|
416
|
+
"get_state",
|
|
417
|
+
"get_task",
|
|
418
|
+
"latest_task",
|
|
419
|
+
"list_snapshots",
|
|
420
|
+
"list_tasks",
|
|
421
|
+
"mark_complete",
|
|
422
|
+
"refresh_state",
|
|
423
|
+
"report_issue",
|
|
424
|
+
"resolve_issue",
|
|
425
|
+
"set_requirement_status",
|
|
426
|
+
]
|