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,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"]
|