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,358 @@
1
+ """Requirement normalization and evaluation (§5).
2
+
3
+ At task creation the human goal is normalized into a small explicit list. On each
4
+ refresh, requirements that are not already COMPLETE are re-evaluated against the
5
+ evidence - Git diff, changed files, the checkpoint intent, and test results.
6
+
7
+ An LLM is used *only where it helps* and its output is always structured and
8
+ auditable ({requirement_id, status, reason, evidence}). When no LLM key is
9
+ configured a deterministic rule-based evaluator runs instead - never a guess from
10
+ Git statistics alone.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from datetime import UTC, datetime
18
+
19
+ from devmemory.analysis.llm import call_llm
20
+ from devmemory.domain.enums import RequirementStatus, TestRunStatus
21
+ from devmemory.domain.taskloop import (
22
+ Requirement,
23
+ RequirementVerdict,
24
+ StateGit,
25
+ StateTests,
26
+ )
27
+ from devmemory.logging import get_logger
28
+ from devmemory.services.context import ProjectContext
29
+
30
+ _log = get_logger(__name__)
31
+
32
+ _MAX_DIFF_BYTES = 6000
33
+ _TESTS_HINT = re.compile(r"\btest", re.IGNORECASE)
34
+
35
+ _NORMALIZE_SYSTEM = (
36
+ "You split a software task into a SHORT list of explicit, independently "
37
+ "checkable requirements (aim for 3-6). Each must be verifiable from the code "
38
+ "and tests. Always include a final requirement that the relevant tests exist "
39
+ "and pass. Reply with ONLY a JSON array: "
40
+ '[{"id":"R1","description":"..."}, ...]. No prose.'
41
+ )
42
+
43
+ _EVAL_SYSTEM = (
44
+ "You evaluate whether each requirement is satisfied by the EVIDENCE provided "
45
+ "(git diff, changed files, checkpoint intent, test results). Judge only from "
46
+ "evidence - do not assume work that is not shown. Never infer completion from "
47
+ "line counts alone. Reply with ONLY a JSON array: "
48
+ '[{"requirement_id":"R3","status":"COMPLETE|PARTIAL|INCOMPLETE|UNKNOWN",'
49
+ '"reason":"one sentence","evidence":["..."]}]. No prose.'
50
+ )
51
+
52
+
53
+ # --- normalization --------------------------------------------------------
54
+
55
+
56
+ def normalize_requirements(
57
+ goal: str, ctx: ProjectContext, *, brief: str | None = None
58
+ ) -> list[Requirement]:
59
+ """Turn the human goal into R1..Rn. LLM when a key is configured, else rules.
60
+
61
+ ``brief`` is the project's single source of truth (see
62
+ :mod:`devmemory.services.brief`); when present it is given to the LLM as
63
+ context so requirements reflect the project's constraints and conventions.
64
+ """
65
+ providers = ctx.config.analysis.providers
66
+ prompt = f"TASK:\n{goal.strip()}"
67
+ if brief:
68
+ prompt = f"PROJECT BRIEF (source of truth):\n{brief.strip()}\n\n{prompt}"
69
+ text = call_llm(
70
+ prompt,
71
+ system=_NORMALIZE_SYSTEM,
72
+ providers=providers,
73
+ model=ctx.config.analysis.model,
74
+ )
75
+ parsed = _parse_json_array(text) if text else None
76
+ if parsed:
77
+ reqs: list[Requirement] = []
78
+ for i, item in enumerate(parsed, start=1):
79
+ if not isinstance(item, dict):
80
+ continue
81
+ desc = str(item.get("description") or "").strip()
82
+ if not desc:
83
+ continue
84
+ reqs.append(Requirement(id=f"R{i}", description=desc, evaluated_by=""))
85
+ if reqs:
86
+ _log.info("taskloop.requirements.normalized", count=len(reqs), by="llm")
87
+ return reqs
88
+
89
+ reqs = _rule_based_split(goal)
90
+ _log.info("taskloop.requirements.normalized", count=len(reqs), by="rules")
91
+ return reqs
92
+
93
+
94
+ def _rule_based_split(goal: str) -> list[Requirement]:
95
+ """Conservative split on clause boundaries (', and ' / ';' / newline / '. ' /
96
+ numbered list markers). Always ends with a 'tests exist and pass' requirement."""
97
+ raw = re.split(r"(?:,\s+and\s+)|(?:\s+and\s+then\s+)|[;\n]|(?:\.\s+)|(?:^|\s)\d+[.)]\s+", goal)
98
+ clauses = [c.strip(" .\t-") for c in raw if len(c.strip(" .\t-")) > 6]
99
+ if not clauses:
100
+ clauses = [goal.strip()]
101
+ reqs = [
102
+ Requirement(id=f"R{i}", description=c[:1].upper() + c[1:]) for i, c in enumerate(clauses, 1)
103
+ ]
104
+ if not any(_TESTS_HINT.search(r.description) for r in reqs):
105
+ reqs.append(
106
+ Requirement(id=f"R{len(reqs) + 1}", description="Relevant tests exist and pass")
107
+ )
108
+ return reqs
109
+
110
+
111
+ # --- evaluation ----------------------------------------------------------
112
+
113
+
114
+ def evaluate_requirements(
115
+ requirements: list[Requirement],
116
+ *,
117
+ ctx: ProjectContext,
118
+ git: StateGit,
119
+ tests: StateTests,
120
+ checkpoint_intent: str | None,
121
+ base_commit: str | None,
122
+ ) -> list[RequirementVerdict]:
123
+ """Re-evaluate every not-yet-COMPLETE requirement against current evidence.
124
+
125
+ COMPLETE requirements are returned unchanged (a satisfied requirement stays
126
+ satisfied unless the agent explicitly reopens it).
127
+ """
128
+ pending = [r for r in requirements if r.status is not RequirementStatus.COMPLETE]
129
+ done = [
130
+ RequirementVerdict(
131
+ requirement_id=r.id, status=r.status, reason=r.reason, evidence=r.evidence
132
+ )
133
+ for r in requirements
134
+ if r.status is RequirementStatus.COMPLETE
135
+ ]
136
+ if not pending:
137
+ return done
138
+
139
+ changed = _changed_paths(ctx, base_commit)
140
+ diff_excerpt = _diff_excerpt(ctx, base_commit)
141
+
142
+ verdicts = _evaluate_llm(pending, git, tests, checkpoint_intent, changed, diff_excerpt, ctx)
143
+ if verdicts is None:
144
+ verdicts = [
145
+ _evaluate_one_rule(r, tests=tests, changed=changed, diff=diff_excerpt) for r in pending
146
+ ]
147
+ return done + verdicts
148
+
149
+
150
+ def _evaluate_llm(
151
+ pending: list[Requirement],
152
+ git: StateGit,
153
+ tests: StateTests,
154
+ checkpoint_intent: str | None,
155
+ changed: list[str],
156
+ diff_excerpt: str,
157
+ ctx: ProjectContext,
158
+ ) -> list[RequirementVerdict] | None:
159
+ payload = {
160
+ "requirements": [{"id": r.id, "description": r.description} for r in pending],
161
+ "evidence": {
162
+ "changed_files": changed,
163
+ "checkpoint_intent": checkpoint_intent,
164
+ "tests": {
165
+ "status": tests.status.value,
166
+ "passed": tests.passed,
167
+ "failed": tests.failed,
168
+ },
169
+ "git": {
170
+ "files_changed": git.files_changed,
171
+ "lines_added": git.lines_added,
172
+ "working_tree_clean": git.working_tree_clean,
173
+ },
174
+ "diff_excerpt": diff_excerpt,
175
+ },
176
+ }
177
+ text = call_llm(
178
+ json.dumps(payload, indent=2),
179
+ system=_EVAL_SYSTEM,
180
+ providers=ctx.config.analysis.providers,
181
+ model=ctx.config.analysis.model,
182
+ )
183
+ parsed = _parse_json_array(text) if text else None
184
+ if not parsed:
185
+ return None
186
+
187
+ by_id = {r.id for r in pending}
188
+ out: list[RequirementVerdict] = []
189
+ for item in parsed:
190
+ if not isinstance(item, dict):
191
+ continue
192
+ rid = str(item.get("requirement_id") or "")
193
+ if rid not in by_id:
194
+ continue
195
+ try:
196
+ status = RequirementStatus(str(item.get("status", "UNKNOWN")).upper())
197
+ except ValueError:
198
+ status = RequirementStatus.UNKNOWN
199
+ evidence = item.get("evidence")
200
+ out.append(
201
+ RequirementVerdict(
202
+ requirement_id=rid,
203
+ status=status,
204
+ reason=str(item.get("reason") or "").strip()[:400] or "evaluated by LLM",
205
+ evidence=[str(e) for e in evidence][:8] if isinstance(evidence, list) else [],
206
+ )
207
+ )
208
+ seen = {v.requirement_id for v in out}
209
+ for r in pending:
210
+ if r.id not in seen:
211
+ out.append(_evaluate_one_rule(r, tests=tests, changed=changed, diff=diff_excerpt))
212
+ _log.info("taskloop.requirements.evaluated", by="llm", pending=len(pending))
213
+ return out
214
+
215
+
216
+ def _evaluate_one_rule(
217
+ req: Requirement,
218
+ *,
219
+ tests: StateTests,
220
+ changed: list[str],
221
+ diff: str,
222
+ ) -> RequirementVerdict:
223
+ """Deterministic fallback: keyword overlap with changed paths + diff, and a
224
+ real check for the 'tests pass' requirement. Conservative - it will say
225
+ INCOMPLETE/PARTIAL rather than claim completion it cannot see."""
226
+ desc = req.description.lower()
227
+
228
+ if _TESTS_HINT.search(desc) and ("pass" in desc or "exist" in desc or "green" in desc):
229
+ if tests.status is TestRunStatus.PASSED and tests.passed > 0:
230
+ return RequirementVerdict(
231
+ requirement_id=req.id,
232
+ status=RequirementStatus.COMPLETE,
233
+ reason=f"{tests.passed} tests pass, 0 failed",
234
+ evidence=[f"tests: {tests.command}"],
235
+ )
236
+ if tests.status in (TestRunStatus.NOT_RUN,):
237
+ return RequirementVerdict(
238
+ requirement_id=req.id,
239
+ status=RequirementStatus.INCOMPLETE,
240
+ reason="no test command configured / tests not run",
241
+ )
242
+ return RequirementVerdict(
243
+ requirement_id=req.id,
244
+ status=RequirementStatus.INCOMPLETE,
245
+ reason=f"tests {tests.status.value.lower()} ({tests.failed} failed)",
246
+ )
247
+
248
+ keywords = {w for w in re.findall(r"[a-z_]{4,}", desc) if w not in _STOPWORDS}
249
+ hay = " ".join(changed).lower() + "\n" + diff.lower()
250
+ hits = sorted(k for k in keywords if k in hay)
251
+ if not keywords:
252
+ status, reason = (
253
+ RequirementStatus.UNKNOWN,
254
+ "requirement not keyword-checkable; inspect repo",
255
+ )
256
+ elif len(hits) >= max(2, len(keywords) // 2):
257
+ status = RequirementStatus.PARTIAL
258
+ reason = f"evidence in changed code for: {', '.join(hits)} (verify behavior + tests)"
259
+ elif hits:
260
+ status = RequirementStatus.PARTIAL
261
+ reason = f"weak evidence ({', '.join(hits)}); likely more work needed"
262
+ else:
263
+ status = RequirementStatus.INCOMPLETE
264
+ reason = "no implementation evidence in the diff for this requirement"
265
+ return RequirementVerdict(
266
+ requirement_id=req.id,
267
+ status=status,
268
+ reason=reason,
269
+ evidence=[f"changed: {p}" for p in changed[:5]],
270
+ )
271
+
272
+
273
+ _STOPWORDS = {
274
+ "that",
275
+ "this",
276
+ "with",
277
+ "have",
278
+ "from",
279
+ "when",
280
+ "then",
281
+ "must",
282
+ "should",
283
+ "will",
284
+ "into",
285
+ "your",
286
+ "user",
287
+ "users",
288
+ "able",
289
+ "exists",
290
+ "exist",
291
+ "support",
292
+ "handle",
293
+ "using",
294
+ "provide",
295
+ }
296
+
297
+
298
+ # --- evidence helpers --------------------------------------------------
299
+
300
+
301
+ def _changed_paths(ctx: ProjectContext, base_commit: str | None) -> list[str]:
302
+ try:
303
+ base = base_commit or ctx.git.parent_sha("HEAD")
304
+ return [f.path for f in ctx.git.changed_files(base, "HEAD")]
305
+ except Exception as exc:
306
+ _log.warning("taskloop.requirements.changed_paths_failed", error=str(exc))
307
+ return []
308
+
309
+
310
+ def _diff_excerpt(ctx: ProjectContext, base_commit: str | None) -> str:
311
+ try:
312
+ base = base_commit or ctx.git.parent_sha("HEAD")
313
+ text = ctx.git.diff_text(base, "HEAD")
314
+ except Exception:
315
+ return ""
316
+ return text[:_MAX_DIFF_BYTES]
317
+
318
+
319
+ def _parse_json_array(text: str | None) -> list[object] | None:
320
+ if not text:
321
+ return None
322
+ match = re.search(r"\[.*\]", text, re.DOTALL)
323
+ if not match:
324
+ return None
325
+ try:
326
+ data = json.loads(match.group(0))
327
+ except json.JSONDecodeError:
328
+ return None
329
+ return data if isinstance(data, list) else None
330
+
331
+
332
+ def apply_verdicts(
333
+ requirements: list[Requirement], verdicts: list[RequirementVerdict], *, by: str
334
+ ) -> list[Requirement]:
335
+ """Merge verdicts back onto the requirement list (pure - no I/O)."""
336
+ now = datetime.now(UTC)
337
+ by_id = {v.requirement_id: v for v in verdicts}
338
+ out: list[Requirement] = []
339
+ for req in requirements:
340
+ v = by_id.get(req.id)
341
+ if v is None:
342
+ out.append(req)
343
+ continue
344
+ out.append(
345
+ req.model_copy(
346
+ update={
347
+ "status": v.status,
348
+ "reason": v.reason,
349
+ "evidence": v.evidence,
350
+ "evaluated_at": now,
351
+ "evaluated_by": by,
352
+ }
353
+ )
354
+ )
355
+ return out
356
+
357
+
358
+ __all__ = ["apply_verdicts", "evaluate_requirements", "normalize_requirements"]
@@ -0,0 +1,152 @@
1
+ """The development trace: intent -> agent -> checkpoint -> commit -> files ->
2
+ tests -> metrics -> status -> analysis, as an ordered list of nodes the UI draws
3
+ as a connected chain.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from devmemory.domain.models import DevelopmentVersion
11
+ from devmemory.services.context import ProjectContext
12
+ from devmemory.services.versions import get_version
13
+
14
+
15
+ class TraceNode(BaseModel):
16
+ key: str
17
+ label: str
18
+ value: str
19
+ detail: str | None = None
20
+ source: str
21
+ """intent | agent | entire | git | files | tests | metrics | status | analysis"""
22
+ status: str | None = None
23
+
24
+
25
+ class DevelopmentTrace(BaseModel):
26
+ version_id: str
27
+ nodes: list[TraceNode]
28
+
29
+
30
+ def development_trace(ctx: ProjectContext, ref: str) -> DevelopmentTrace:
31
+ return build_trace(get_version(ctx, ref))
32
+
33
+
34
+ def build_trace(v: DevelopmentVersion) -> DevelopmentTrace:
35
+ nodes: list[TraceNode] = []
36
+
37
+ nodes.append(
38
+ TraceNode(
39
+ key="intent",
40
+ label="Intent",
41
+ value=v.intent or "(none recorded)",
42
+ source="intent",
43
+ )
44
+ )
45
+
46
+ if v.agent:
47
+ nodes.append(
48
+ TraceNode(
49
+ key="agent",
50
+ label="AI agent",
51
+ value=v.agent,
52
+ detail=v.model,
53
+ source="agent",
54
+ )
55
+ )
56
+
57
+ cp = v.primary_checkpoint
58
+ if cp is not None:
59
+ detail = cp.association_method.value
60
+ if cp.is_uncertain:
61
+ detail += f" · confidence {cp.association_confidence:.2f}"
62
+ nodes.append(
63
+ TraceNode(
64
+ key="checkpoint",
65
+ label="Entire checkpoint",
66
+ value=cp.checkpoint_id,
67
+ detail=detail,
68
+ source="entire",
69
+ status="uncertain" if cp.is_uncertain else "linked",
70
+ )
71
+ )
72
+ else:
73
+ nodes.append(
74
+ TraceNode(
75
+ key="checkpoint",
76
+ label="Entire checkpoint",
77
+ value="not available",
78
+ source="entire",
79
+ status="missing",
80
+ )
81
+ )
82
+
83
+ nodes.append(
84
+ TraceNode(
85
+ key="commit",
86
+ label="Git commit",
87
+ value=v.git_commit[:12],
88
+ detail=f"parent {(v.parent_commit or '-')[:12]}",
89
+ source="git",
90
+ )
91
+ )
92
+
93
+ nodes.append(
94
+ TraceNode(
95
+ key="files",
96
+ label="Files changed",
97
+ value=f"{v.files_changed} file{'s' if v.files_changed != 1 else ''}",
98
+ detail=f"+{v.lines_added} / -{v.lines_removed}",
99
+ source="files",
100
+ )
101
+ )
102
+
103
+ if v.tests and v.tests.ran:
104
+ nodes.append(
105
+ TraceNode(
106
+ key="tests",
107
+ label="Tests",
108
+ value=f"{v.tests.passed} passed / {v.tests.failed} failed",
109
+ detail=v.tests.command,
110
+ source="tests",
111
+ status="pass" if v.tests.all_passed else "fail",
112
+ )
113
+ )
114
+
115
+ for m in v.metrics:
116
+ status = "up" if m.is_improvement else "down" if m.is_worse else None
117
+ arrow = f"{m.before} → {m.after}" if m.before is not None else str(m.after)
118
+ nodes.append(
119
+ TraceNode(
120
+ key=f"metric:{m.name}",
121
+ label=m.name,
122
+ value=arrow + (f" {m.unit}" if m.unit else ""),
123
+ source="metrics",
124
+ status=status,
125
+ )
126
+ )
127
+
128
+ nodes.append(
129
+ TraceNode(
130
+ key="status",
131
+ label="Result",
132
+ value=v.status.value,
133
+ source="status",
134
+ status=v.status.value.lower(),
135
+ )
136
+ )
137
+
138
+ if v.analysis and v.analysis.summary:
139
+ nodes.append(
140
+ TraceNode(
141
+ key="analysis",
142
+ label="Analysis",
143
+ value=v.analysis.summary,
144
+ detail=v.analysis.recommendation,
145
+ source="analysis",
146
+ )
147
+ )
148
+
149
+ return DevelopmentTrace(version_id=v.version_id, nodes=nodes)
150
+
151
+
152
+ __all__ = ["DevelopmentTrace", "TraceNode", "build_trace", "development_trace"]