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.
Files changed (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. 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
+ ]