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,39 @@
1
+ """The state-aware coding loop.
2
+
3
+ ``engine`` is the entry point: create a task, then ``refresh_state`` /
4
+ ``get_state`` drive the loop. Collectors aggregate evidence (Git, Entire, tests,
5
+ optional Graph); ``requirements`` normalizes and evaluates requirements. Nothing
6
+ here decides *how* code is written - that stays with Antigravity.
7
+ """
8
+
9
+ from devmemory.services.taskloop.engine import (
10
+ TaskNotFoundError,
11
+ create_task,
12
+ get_checkpoint,
13
+ get_state,
14
+ get_task,
15
+ latest_task,
16
+ list_snapshots,
17
+ list_tasks,
18
+ mark_complete,
19
+ refresh_state,
20
+ report_issue,
21
+ resolve_issue,
22
+ set_requirement_status,
23
+ )
24
+
25
+ __all__ = [
26
+ "TaskNotFoundError",
27
+ "create_task",
28
+ "get_checkpoint",
29
+ "get_state",
30
+ "get_task",
31
+ "latest_task",
32
+ "list_snapshots",
33
+ "list_tasks",
34
+ "mark_complete",
35
+ "refresh_state",
36
+ "report_issue",
37
+ "resolve_issue",
38
+ "set_requirement_status",
39
+ ]
@@ -0,0 +1,263 @@
1
+ """Evidence collectors for one state refresh.
2
+
3
+ Each collector wraps an existing DevMemory adapter and returns a normalized
4
+ state sub-model. Every one fails *gracefully* (§21): on any error it returns a
5
+ model with ``available=False`` / a ``NOT_RUN``/``ERROR`` status and a ``reason`` -
6
+ it never raises for "the tool just isn't here", and it never fabricates a count
7
+ or a checkpoint id.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from devmemory.adapters.tests import TestAdapter
17
+ from devmemory.domain.enums import TestRunStatus
18
+ from devmemory.domain.models import TestOutcome
19
+ from devmemory.domain.taskloop import StateCheckpoint, StateGit, StateImpact, StateTests
20
+ from devmemory.logging import get_logger
21
+ from devmemory.services.context import ProjectContext
22
+
23
+ _log = get_logger(__name__)
24
+
25
+ # Build-tool / test-runner droppings. The engine runs the test command on every
26
+ # refresh, so these churn constantly - they are never meaningful task work and
27
+ # must not keep a task out of READY (whether untracked or accidentally tracked).
28
+ _JUNK_PATH = re.compile(
29
+ r"(^|/)(__pycache__|\.pytest_cache|\.ruff_cache|\.mypy_cache|node_modules"
30
+ r"|\.tox|\.nox|htmlcov|\.coverage)(/|$)|\.pyc$|\.pyo$|(^|/)\.DS_Store$"
31
+ )
32
+
33
+
34
+ def _meaningful(paths: list[str]) -> list[str]:
35
+ return [p for p in paths if not _JUNK_PATH.search(p)]
36
+
37
+
38
+ # --- git --------------------------------------------------------------------
39
+
40
+
41
+ def collect_git(ctx: ProjectContext, *, base_commit: str | None) -> StateGit:
42
+ """Branch, HEAD, cumulative diff since the task's base commit, tree cleanliness.
43
+
44
+ Git is the source of truth for committed code. If git is unavailable the
45
+ engine treats the task as BLOCKED (§21).
46
+ """
47
+ git = ctx.git
48
+ try:
49
+ if not git.is_repository():
50
+ return StateGit(available=False, reason="not a git repository")
51
+
52
+ wt = git.working_tree_state()
53
+ head = git.head_sha()
54
+ subject = git.commit("HEAD").subject if head else None
55
+
56
+ # "clean" for the READY gate ignores test-runner droppings (the engine
57
+ # itself creates __pycache__/.pytest_cache every refresh).
58
+ dirty = _meaningful(wt.staged) + _meaningful(wt.unstaged) + _meaningful(wt.untracked)
59
+ tree_clean = not dirty
60
+
61
+ files_changed = lines_added = lines_deleted = 0
62
+ if head:
63
+ # Cumulative task progress: base_commit..working-tree. Falls back to
64
+ # the last commit's own diff when we have no base.
65
+ diff_base = base_commit or git.parent_sha("HEAD")
66
+ try:
67
+ stat = git.diff_stat(diff_base, "HEAD")
68
+ files_changed = stat.files_changed
69
+ lines_added = stat.additions
70
+ lines_deleted = stat.deletions
71
+ except Exception as exc:
72
+ _log.warning("taskloop.git.diffstat_failed", error=str(exc))
73
+
74
+ _log.info("taskloop.collector.git", branch=wt.branch, clean=tree_clean)
75
+ return StateGit(
76
+ branch=wt.branch or "(detached)",
77
+ commit_sha=head,
78
+ commit_subject=subject,
79
+ files_changed=files_changed,
80
+ lines_added=lines_added,
81
+ lines_deleted=lines_deleted,
82
+ working_tree_clean=tree_clean,
83
+ available=True,
84
+ )
85
+ except Exception as exc:
86
+ _log.warning("taskloop.collector.git_failed", error=str(exc))
87
+ return StateGit(available=False, reason=f"git error: {exc}")
88
+
89
+
90
+ # --- entire ----------------------------------------------------------------
91
+
92
+
93
+ def collect_checkpoint(ctx: ProjectContext, *, head_sha: str | None) -> StateCheckpoint:
94
+ """Latest relevant checkpoint + the one tied to HEAD, plus a session id.
95
+
96
+ Never fabricates a checkpoint id (§21). If Entire is absent/disabled the
97
+ result is an empty :class:`StateCheckpoint` and the rest of the state still
98
+ works (§21).
99
+ """
100
+ entire = ctx.entire
101
+ out = StateCheckpoint()
102
+ try:
103
+ if not entire.is_installed():
104
+ return out
105
+
106
+ # The checkpoint associated with the current commit (trailer / heuristic).
107
+ if head_sha:
108
+ ref = entire.resolve_for_commit(head_sha)
109
+ if ref is not None:
110
+ out.last_committed_id = ref.checkpoint_id
111
+ out.commit_sha = ref.commit_sha
112
+ out.association = ref.association_method.value
113
+ for sess in ref.sessions:
114
+ if sess.session_id:
115
+ out.session_id = sess.session_id
116
+ break
117
+
118
+ # The newest checkpoint on the branch (may be ahead of HEAD, uncommitted).
119
+ checkpoints = entire.list_checkpoints(limit=1)
120
+ if checkpoints:
121
+ cid = checkpoints[0].get("id") or checkpoints[0].get("checkpoint_id")
122
+ if isinstance(cid, str):
123
+ out.current_id = cid
124
+ out.current_id = out.current_id or out.last_committed_id
125
+
126
+ if out.session_id is None:
127
+ for row in entire.list_sessions(limit=5):
128
+ sid = row.get("session_id")
129
+ if isinstance(sid, str) and row.get("status") == "active":
130
+ out.session_id = sid
131
+ break
132
+
133
+ _log.info(
134
+ "taskloop.collector.entire",
135
+ current=out.current_id,
136
+ committed=out.last_committed_id,
137
+ )
138
+ return out
139
+ except Exception as exc:
140
+ _log.warning("taskloop.collector.entire_failed", error=str(exc))
141
+ return out # empty, not fabricated
142
+
143
+
144
+ # --- tests ---------------------------------------------------------------
145
+
146
+
147
+ def collect_tests(
148
+ ctx: ProjectContext,
149
+ *,
150
+ command: str | None,
151
+ output_dir: Path,
152
+ ) -> StateTests:
153
+ """Run the configured test command and normalize the result.
154
+
155
+ Supports pytest, ``npm test`` / ``npm run test`` (jest/mocha via the generic
156
+ parser), and go. Parsing failures yield ``FAILED_TO_PARSE`` rather than
157
+ invented counts (§9). A command that cannot start yields ``ERROR`` (§21).
158
+ """
159
+ if not command:
160
+ return StateTests(status=TestRunStatus.NOT_RUN)
161
+
162
+ cfg = ctx.config.tests
163
+ adapter = TestAdapter(ctx.paths.repo_root)
164
+ try:
165
+ outcome = adapter.run(
166
+ command,
167
+ parser=cfg.parser,
168
+ junit_xml=cfg.junit_xml,
169
+ timeout=cfg.timeout_seconds,
170
+ )
171
+ except Exception as exc:
172
+ _log.warning("taskloop.collector.tests_error", command=command, error=str(exc))
173
+ return StateTests(command=command, status=TestRunStatus.ERROR)
174
+
175
+ output_path: str | None = None
176
+ if outcome.output:
177
+ output_dir.mkdir(parents=True, exist_ok=True)
178
+ fname = f"tests-{int(time.time())}.log"
179
+ (output_dir / fname).write_text(outcome.output, encoding="utf-8")
180
+ output_path = str((output_dir / fname).relative_to(ctx.paths.repo_root))
181
+
182
+ status = _classify_tests(outcome)
183
+ _log.info(
184
+ "taskloop.collector.tests",
185
+ command=command,
186
+ status=status.value,
187
+ passed=outcome.passed,
188
+ failed=outcome.failed,
189
+ )
190
+ return StateTests(
191
+ command=command,
192
+ passed=outcome.passed,
193
+ failed=outcome.failed,
194
+ skipped=outcome.skipped,
195
+ status=status,
196
+ exit_code=outcome.exit_code,
197
+ output_path=output_path,
198
+ )
199
+
200
+
201
+ def _classify_tests(outcome: TestOutcome) -> TestRunStatus:
202
+ total = outcome.passed + outcome.failed + outcome.skipped
203
+ # pytest exit 5 = "no tests were collected". The TestAdapter synthesizes
204
+ # errors=1 for any non-zero exit, so check the code before the count.
205
+ if outcome.exit_code == 5 and outcome.failed == 0:
206
+ return TestRunStatus.FAILED_TO_PARSE
207
+ if outcome.failed > 0 or outcome.errors > 0:
208
+ return TestRunStatus.FAILED
209
+ if total == 0:
210
+ # Command ran, nothing parsed. A clean exit means an unknown format;
211
+ # a non-zero exit is a real failure.
212
+ if outcome.exit_code in (0, None):
213
+ return TestRunStatus.FAILED_TO_PARSE
214
+ return TestRunStatus.FAILED
215
+ if outcome.exit_code in (0, None):
216
+ return TestRunStatus.PASSED
217
+ return TestRunStatus.FAILED
218
+
219
+
220
+ # --- graph (optional) --------------------------------------------------
221
+
222
+
223
+ def collect_impact(ctx: ProjectContext, *, head_sha: str | None) -> StateImpact:
224
+ """A small, useful change-impact count from the Entire ``graph`` plugin.
225
+
226
+ Not a visualization, not a graph DB (§8). If the plugin is unavailable the
227
+ result is ``available=False`` with a reason and the state still works.
228
+ """
229
+ graph = ctx.graph
230
+ try:
231
+ if not graph.is_available:
232
+ probe = graph.probe()
233
+ return StateImpact(available=False, reason=probe.detail or "graph plugin not installed")
234
+ if not head_sha:
235
+ return StateImpact(available=False, reason="no commit to analyze")
236
+
237
+ impact = graph.commit_impact(head_sha)
238
+ if impact is None:
239
+ return StateImpact(available=False, reason="graph returned no impact for this commit")
240
+
241
+ affected_files = len({e.path for e in impact.entities})
242
+ affected_tests = sum(
243
+ 1
244
+ for e in impact.entities
245
+ if "test" in e.path.lower() or e.path.lower().endswith("_test.go")
246
+ )
247
+ details = [
248
+ f"{e.change_type} {e.kind} {e.name} ({e.dependents_count} dependents)"
249
+ for e in impact.hotspots[:8]
250
+ ]
251
+ _log.info("taskloop.collector.graph", affected_files=affected_files)
252
+ return StateImpact(
253
+ affected_files=affected_files,
254
+ affected_tests=affected_tests,
255
+ available=True,
256
+ details=details,
257
+ )
258
+ except Exception as exc:
259
+ _log.warning("taskloop.collector.graph_failed", error=str(exc))
260
+ return StateImpact(available=False, reason=f"graph error: {exc}")
261
+
262
+
263
+ __all__ = ["collect_checkpoint", "collect_git", "collect_impact", "collect_tests"]