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
devmemory/environment.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Best-effort snapshot of the local toolchain.
|
|
2
|
+
|
|
3
|
+
Attached to development versions (Phase 3+) so history records what produced it.
|
|
4
|
+
Collects nothing personal - versions and platform strings only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from devmemory.domain.models import EnvironmentInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _first_line(executable: str, *args: str) -> str | None:
|
|
18
|
+
path = shutil.which(executable)
|
|
19
|
+
if path is None:
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
proc = subprocess.run( # noqa: S603 - fixed executable, arg list, no shell
|
|
23
|
+
[path, *args],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
timeout=5,
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
except (OSError, subprocess.SubprocessError):
|
|
30
|
+
return None
|
|
31
|
+
text = (proc.stdout or proc.stderr).strip()
|
|
32
|
+
return text.splitlines()[0].strip() if text else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _detect_package_manager(repo_path: Path) -> str | None:
|
|
36
|
+
markers = {
|
|
37
|
+
"uv.lock": "uv",
|
|
38
|
+
"poetry.lock": "poetry",
|
|
39
|
+
"Pipfile.lock": "pipenv",
|
|
40
|
+
"pdm.lock": "pdm",
|
|
41
|
+
"requirements.txt": "pip",
|
|
42
|
+
"pyproject.toml": "pip",
|
|
43
|
+
"package-lock.json": "npm",
|
|
44
|
+
"pnpm-lock.yaml": "pnpm",
|
|
45
|
+
"yarn.lock": "yarn",
|
|
46
|
+
"go.mod": "go",
|
|
47
|
+
"Cargo.toml": "cargo",
|
|
48
|
+
}
|
|
49
|
+
for marker, name in markers.items():
|
|
50
|
+
if (repo_path / marker).exists():
|
|
51
|
+
return name
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def collect_environment(repo_path: Path | str = ".") -> EnvironmentInfo:
|
|
56
|
+
repo_path = Path(repo_path)
|
|
57
|
+
return EnvironmentInfo(
|
|
58
|
+
python_version=platform.python_version(),
|
|
59
|
+
platform=f"{platform.system()} {platform.release()}",
|
|
60
|
+
machine=platform.machine(),
|
|
61
|
+
git_version=_first_line("git", "--version"),
|
|
62
|
+
entire_version=_first_line("entire", "version"),
|
|
63
|
+
package_manager=_detect_package_manager(repo_path),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["collect_environment"]
|
devmemory/logging.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Structured logging with secret redaction.
|
|
2
|
+
|
|
3
|
+
DevMemory must be diagnosable (which operation failed, which integration, was
|
|
4
|
+
project state changed, what next) without ever writing a credential to a log.
|
|
5
|
+
``configure_logging`` wires up structlog; a redaction processor scrubs both
|
|
6
|
+
known-sensitive keys and any value that matches a live environment secret.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import structlog
|
|
17
|
+
|
|
18
|
+
_REDACTED = "***redacted***"
|
|
19
|
+
|
|
20
|
+
_SENSITIVE_KEY_MARKERS = (
|
|
21
|
+
"token",
|
|
22
|
+
"secret",
|
|
23
|
+
"password",
|
|
24
|
+
"passwd",
|
|
25
|
+
"api_key",
|
|
26
|
+
"apikey",
|
|
27
|
+
"authorization",
|
|
28
|
+
"auth_header",
|
|
29
|
+
"access_key",
|
|
30
|
+
"private_key",
|
|
31
|
+
"client_secret",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_SENSITIVE_ENV_MARKERS = (
|
|
35
|
+
"TOKEN",
|
|
36
|
+
"SECRET",
|
|
37
|
+
"PASSWORD",
|
|
38
|
+
"API_KEY",
|
|
39
|
+
"APIKEY",
|
|
40
|
+
"ACCESS_KEY",
|
|
41
|
+
"PRIVATE_KEY",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_configured = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _live_secret_values() -> set[str]:
|
|
48
|
+
"""Non-trivial values of environment variables that look like secrets."""
|
|
49
|
+
values: set[str] = set()
|
|
50
|
+
for name, value in os.environ.items():
|
|
51
|
+
if not value or len(value) < 6:
|
|
52
|
+
continue
|
|
53
|
+
if any(marker in name.upper() for marker in _SENSITIVE_ENV_MARKERS):
|
|
54
|
+
values.add(value)
|
|
55
|
+
return values
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _redact_value(value: Any, secrets: set[str]) -> Any:
|
|
59
|
+
if isinstance(value, str):
|
|
60
|
+
if value in secrets:
|
|
61
|
+
return _REDACTED
|
|
62
|
+
redacted = value
|
|
63
|
+
for secret in secrets:
|
|
64
|
+
if secret and secret in redacted:
|
|
65
|
+
redacted = redacted.replace(secret, _REDACTED)
|
|
66
|
+
return redacted
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
return {k: _redact_mapping_entry(k, v, secrets) for k, v in value.items()}
|
|
69
|
+
if isinstance(value, (list, tuple)):
|
|
70
|
+
return type(value)(_redact_value(v, secrets) for v in value)
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _redact_mapping_entry(key: Any, value: Any, secrets: set[str]) -> Any:
|
|
75
|
+
if isinstance(key, str) and any(marker in key.lower() for marker in _SENSITIVE_KEY_MARKERS):
|
|
76
|
+
return _REDACTED
|
|
77
|
+
return _redact_value(value, secrets)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _redaction_processor(
|
|
81
|
+
_logger: object, _method: str, event_dict: structlog.types.EventDict
|
|
82
|
+
) -> structlog.types.EventDict:
|
|
83
|
+
secrets = _live_secret_values()
|
|
84
|
+
return {k: _redact_mapping_entry(k, v, secrets) for k, v in event_dict.items()}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def configure_logging(*, level: str = "INFO", json_logs: bool | None = None) -> None:
|
|
88
|
+
"""Initialise structlog. Safe to call more than once (later calls reconfigure).
|
|
89
|
+
|
|
90
|
+
``json_logs`` defaults to ``True`` when stderr is not a TTY (CI, pipes),
|
|
91
|
+
``False`` (rich console renderer) when it is.
|
|
92
|
+
"""
|
|
93
|
+
global _configured
|
|
94
|
+
|
|
95
|
+
if json_logs is None:
|
|
96
|
+
json_logs = not sys.stderr.isatty()
|
|
97
|
+
|
|
98
|
+
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
|
99
|
+
|
|
100
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
101
|
+
structlog.contextvars.merge_contextvars,
|
|
102
|
+
structlog.processors.add_log_level,
|
|
103
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
104
|
+
structlog.processors.StackInfoRenderer(),
|
|
105
|
+
_redaction_processor,
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
renderer: structlog.types.Processor = (
|
|
109
|
+
structlog.processors.JSONRenderer()
|
|
110
|
+
if json_logs
|
|
111
|
+
else structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty())
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
structlog.configure(
|
|
115
|
+
processors=[*shared_processors, renderer],
|
|
116
|
+
wrapper_class=structlog.make_filtering_bound_logger(numeric_level),
|
|
117
|
+
# Resolve sys.stderr lazily per log line - pytest's captured streams are
|
|
118
|
+
# swapped and closed between tests, so a cached stream reference breaks.
|
|
119
|
+
logger_factory=_lazy_stderr_factory,
|
|
120
|
+
cache_logger_on_first_use=False,
|
|
121
|
+
)
|
|
122
|
+
_configured = True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _lazy_stderr_factory(*_args: object) -> structlog.PrintLogger:
|
|
126
|
+
return structlog.PrintLogger(file=sys.stderr)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
|
130
|
+
"""Return a bound logger, configuring logging with defaults on first use."""
|
|
131
|
+
if not _configured:
|
|
132
|
+
configure_logging()
|
|
133
|
+
return structlog.get_logger(name) # type: ignore[no-any-return]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def redact_secrets(text: str) -> str:
|
|
137
|
+
"""Replace any live environment-secret value found in ``text``.
|
|
138
|
+
|
|
139
|
+
Used before text produced by an external LLM is stored or displayed.
|
|
140
|
+
"""
|
|
141
|
+
out = text
|
|
142
|
+
for secret in _live_secret_values():
|
|
143
|
+
if secret in out:
|
|
144
|
+
out = out.replace(secret, _REDACTED)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
__all__ = ["configure_logging", "get_logger", "redact_secrets"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""DevMemory's Model Context Protocol server.
|
|
2
|
+
|
|
3
|
+
``build_server`` returns a configured :class:`fastmcp.FastMCP` exposing the same
|
|
4
|
+
development memory the dashboard shows - so an AI coding agent can ask "what
|
|
5
|
+
happened here before?" before it makes a change. Requires the ``mcp`` extra.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from devmemory.mcp.server import build_server
|
|
11
|
+
|
|
12
|
+
__all__ = ["build_server"]
|
devmemory/mcp/server.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The DevMemory MCP server.
|
|
2
|
+
|
|
3
|
+
Read-only. Every tool returns collected facts (Git, Entire, tests, metrics) or a
|
|
4
|
+
rule-based risk read over them - never an LLM interpretation. The server holds
|
|
5
|
+
one thread-safe :class:`ProjectContext` for its lifetime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from devmemory.adapters.graph import GraphImpact
|
|
16
|
+
from devmemory.domain.enums import RequirementStatus
|
|
17
|
+
from devmemory.domain.taskloop import Issue, NormalizedState
|
|
18
|
+
from devmemory.services.agent_context import (
|
|
19
|
+
ChangeGuidance,
|
|
20
|
+
ProjectBrief,
|
|
21
|
+
VersionBrief,
|
|
22
|
+
VersionReport,
|
|
23
|
+
change_guidance,
|
|
24
|
+
project_brief,
|
|
25
|
+
recent_history,
|
|
26
|
+
version_brief,
|
|
27
|
+
version_report,
|
|
28
|
+
)
|
|
29
|
+
from devmemory.services.analytics import AnalyticsSummary, analytics_summary
|
|
30
|
+
from devmemory.services.context import ProjectContext
|
|
31
|
+
from devmemory.services.memory import MemoryQuery, PreviousAttempt, previous_attempts
|
|
32
|
+
from devmemory.services.taskloop import engine as taskloop
|
|
33
|
+
from devmemory.services.trace import DevelopmentTrace, development_trace
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from fastmcp import FastMCP
|
|
37
|
+
|
|
38
|
+
_INSTRUCTIONS = """\
|
|
39
|
+
DevMemory records every AI-assisted change as a Development Version: the Git diff,
|
|
40
|
+
the Entire checkpoint (intent + agent + model), the test and metric results, and
|
|
41
|
+
whether it regressed anything.
|
|
42
|
+
|
|
43
|
+
State-aware coding loop: call get_state(task_id) before starting or resuming
|
|
44
|
+
work, do the implementation with your own tools, commit, then refresh_state to
|
|
45
|
+
re-collect evidence and get the new status (IN_PROGRESS / NEEDS_WORK / READY /
|
|
46
|
+
BLOCKED). Continue while NEEDS_WORK; stop at READY; escalate at BLOCKED.
|
|
47
|
+
|
|
48
|
+
Call check_before_change BEFORE editing files - it reports whether this area has
|
|
49
|
+
failed here before. Use get_project_context for orientation, get_version_history
|
|
50
|
+
and get_previous_attempts to look back, and get_development_trace to see how one
|
|
51
|
+
version's intent led to its result.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_server(repo_path: Path | str | None = None) -> FastMCP:
|
|
56
|
+
from fastmcp import FastMCP
|
|
57
|
+
|
|
58
|
+
resolved = Path(repo_path) if repo_path is not None else None
|
|
59
|
+
holder: dict[str, ProjectContext] = {}
|
|
60
|
+
|
|
61
|
+
def ctx() -> ProjectContext:
|
|
62
|
+
if "ctx" not in holder:
|
|
63
|
+
holder["ctx"] = ProjectContext.load(resolved, thread_safe=True)
|
|
64
|
+
return holder["ctx"]
|
|
65
|
+
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def lifespan(_server: FastMCP) -> AsyncIterator[None]:
|
|
68
|
+
try:
|
|
69
|
+
yield
|
|
70
|
+
finally:
|
|
71
|
+
existing = holder.pop("ctx", None)
|
|
72
|
+
if existing is not None:
|
|
73
|
+
existing.close()
|
|
74
|
+
|
|
75
|
+
mcp: FastMCP = FastMCP("devmemory", instructions=_INSTRUCTIONS, lifespan=lifespan)
|
|
76
|
+
|
|
77
|
+
@mcp.tool
|
|
78
|
+
def get_project_context() -> ProjectBrief:
|
|
79
|
+
"""Orientation for this repo: branch/HEAD, whether HEAD is checkpointed,
|
|
80
|
+
version count and success rate, open features, the latest version, recent
|
|
81
|
+
adverse versions, and things to be careful about."""
|
|
82
|
+
return project_brief(ctx())
|
|
83
|
+
|
|
84
|
+
@mcp.tool
|
|
85
|
+
def get_version_history(limit: int = 20, feature: str | None = None) -> list[VersionBrief]:
|
|
86
|
+
"""Recent development versions, newest first. Optionally filter by feature."""
|
|
87
|
+
return recent_history(ctx(), limit=limit, feature=feature)
|
|
88
|
+
|
|
89
|
+
@mcp.tool
|
|
90
|
+
def get_version(ref: str) -> VersionReport:
|
|
91
|
+
"""One version in full: the flattened brief, its intent->result trace, and
|
|
92
|
+
its parent commit. ``ref`` accepts ``v7``, ``7``, or a commit prefix."""
|
|
93
|
+
return version_report(ctx(), ref)
|
|
94
|
+
|
|
95
|
+
@mcp.tool
|
|
96
|
+
def get_development_trace(ref: str) -> DevelopmentTrace:
|
|
97
|
+
"""The ordered chain for one version: intent -> agent -> checkpoint ->
|
|
98
|
+
commit -> files -> tests -> metrics -> status -> analysis."""
|
|
99
|
+
return development_trace(ctx(), ref)
|
|
100
|
+
|
|
101
|
+
@mcp.tool
|
|
102
|
+
def get_previous_attempts(
|
|
103
|
+
files: list[str] | None = None,
|
|
104
|
+
intent: str | None = None,
|
|
105
|
+
feature: str | None = None,
|
|
106
|
+
include_successes: bool = False,
|
|
107
|
+
limit: int = 10,
|
|
108
|
+
) -> list[PreviousAttempt]:
|
|
109
|
+
"""Past versions that touched the same files / feature / intent, ranked by
|
|
110
|
+
relevance. Adverse attempts only unless ``include_successes`` is set. Each
|
|
111
|
+
result explains why it matched and what to do about it."""
|
|
112
|
+
return previous_attempts(
|
|
113
|
+
ctx(),
|
|
114
|
+
MemoryQuery(
|
|
115
|
+
files=files or [],
|
|
116
|
+
intent=intent,
|
|
117
|
+
feature=feature,
|
|
118
|
+
include_successes=include_successes,
|
|
119
|
+
limit=limit,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@mcp.tool
|
|
124
|
+
def check_before_change(
|
|
125
|
+
files: list[str] | None = None,
|
|
126
|
+
intent: str = "",
|
|
127
|
+
feature: str | None = None,
|
|
128
|
+
) -> ChangeGuidance:
|
|
129
|
+
"""Pre-flight check: given the files you are about to edit (and optionally
|
|
130
|
+
your intent / the feature), returns a verdict - proceed | caution |
|
|
131
|
+
high-risk - with the specific prior failures to read first. Call this
|
|
132
|
+
before editing."""
|
|
133
|
+
return change_guidance(ctx(), files=files or [], intent=intent or None, feature=feature)
|
|
134
|
+
|
|
135
|
+
@mcp.tool
|
|
136
|
+
def search_versions(query: str, limit: int = 10) -> list[VersionBrief]:
|
|
137
|
+
"""Full-text search over version intents, features, and changed files."""
|
|
138
|
+
from devmemory.services.versions import search_versions as _search
|
|
139
|
+
|
|
140
|
+
return [version_brief(v) for v in _search(ctx(), query, limit=limit)]
|
|
141
|
+
|
|
142
|
+
@mcp.tool
|
|
143
|
+
def get_change_impact(ref: str) -> GraphImpact | None:
|
|
144
|
+
"""The entity-level blast radius for a version (Entire `graph` plugin):
|
|
145
|
+
added / removed / renamed / signature-changed / body-changed symbols with
|
|
146
|
+
dependent counts. ``None`` if the plugin is not installed."""
|
|
147
|
+
from devmemory.services.impact import version_impact
|
|
148
|
+
|
|
149
|
+
return version_impact(ctx(), ref)
|
|
150
|
+
|
|
151
|
+
@mcp.tool
|
|
152
|
+
def get_analytics() -> AnalyticsSummary:
|
|
153
|
+
"""Development intelligence across all versions: regression leaderboard,
|
|
154
|
+
feature attempts, file churn, agent effectiveness, trend, and
|
|
155
|
+
repeatedly-failed approaches."""
|
|
156
|
+
return analytics_summary(ctx())
|
|
157
|
+
|
|
158
|
+
# -- the state-aware coding loop -------------------------------------
|
|
159
|
+
|
|
160
|
+
@mcp.tool
|
|
161
|
+
def create_task(goal: str, test_command: str | None = None) -> NormalizedState:
|
|
162
|
+
"""Start a task: normalize ``goal`` into explicit requirements, pin the
|
|
163
|
+
base commit, and return the first state. Call this once per human task,
|
|
164
|
+
then drive the loop with get_state / refresh_state."""
|
|
165
|
+
task = taskloop.create_task(ctx(), goal=goal, test_command=test_command)
|
|
166
|
+
return taskloop.get_state(ctx(), task.id)
|
|
167
|
+
|
|
168
|
+
@mcp.tool
|
|
169
|
+
def get_state(task_id: str) -> NormalizedState:
|
|
170
|
+
"""The current normalized project state for a task: requirements, git,
|
|
171
|
+
checkpoint, tests, impact, unresolved items, recommended focus, and the
|
|
172
|
+
overall status. Read this before starting or resuming work. It orients
|
|
173
|
+
you - still inspect the real repository with your own tools."""
|
|
174
|
+
return taskloop.get_state(ctx(), task_id)
|
|
175
|
+
|
|
176
|
+
@mcp.tool
|
|
177
|
+
def refresh_state(task_id: str) -> NormalizedState:
|
|
178
|
+
"""Re-collect all evidence (git, Entire, tests, optional graph),
|
|
179
|
+
re-evaluate requirements, recompute the overall status, store a snapshot,
|
|
180
|
+
and return the complete new state. Call this after you commit meaningful
|
|
181
|
+
progress."""
|
|
182
|
+
return taskloop.refresh_state(ctx(), task_id)
|
|
183
|
+
|
|
184
|
+
@mcp.tool
|
|
185
|
+
def get_checkpoint(checkpoint_id: str) -> dict[str, object]:
|
|
186
|
+
"""Compact metadata for one Entire checkpoint: intent, agent, model,
|
|
187
|
+
associated commit, sessions, token total. Not a full transcript."""
|
|
188
|
+
return taskloop.get_checkpoint(ctx(), checkpoint_id)
|
|
189
|
+
|
|
190
|
+
@mcp.tool
|
|
191
|
+
def report_issue(task_id: str, description: str, blocking: bool = False) -> Issue:
|
|
192
|
+
"""Record an unresolved item for a task. Set ``blocking=True`` when you
|
|
193
|
+
cannot safely continue without a human decision - that forces the task to
|
|
194
|
+
BLOCKED on the next refresh."""
|
|
195
|
+
return taskloop.report_issue(
|
|
196
|
+
ctx(), task_id=task_id, description=description, blocking=blocking
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@mcp.tool
|
|
200
|
+
def set_requirement_status(
|
|
201
|
+
task_id: str, requirement_id: str, status: str, note: str = ""
|
|
202
|
+
) -> NormalizedState:
|
|
203
|
+
"""Record your own verdict for one requirement (status: COMPLETE |
|
|
204
|
+
PARTIAL | INCOMPLETE | UNKNOWN) after you have implemented and verified
|
|
205
|
+
it. The engine still independently checks tests + tree state before it
|
|
206
|
+
will report READY. Returns the refreshed state."""
|
|
207
|
+
return taskloop.set_requirement_status(
|
|
208
|
+
ctx(),
|
|
209
|
+
task_id=task_id,
|
|
210
|
+
requirement_id=requirement_id,
|
|
211
|
+
status=RequirementStatus(status.upper()),
|
|
212
|
+
note=note,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
@mcp.tool
|
|
216
|
+
def mark_complete(task_id: str) -> NormalizedState:
|
|
217
|
+
"""Request a completion evaluation. This runs a full refresh and returns
|
|
218
|
+
the state - it never blindly marks READY. You are done only if the
|
|
219
|
+
returned overall_status is READY."""
|
|
220
|
+
return taskloop.mark_complete(ctx(), task_id)
|
|
221
|
+
|
|
222
|
+
return mcp
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
__all__ = ["build_server"]
|
devmemory/paths.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Filesystem layout for a DevMemory-tracked project.
|
|
2
|
+
|
|
3
|
+
Everything DevMemory writes into a target repository lives under a single hidden
|
|
4
|
+
directory, ``.devmemory/``. This module is the one place that knows its shape.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
DEVMEMORY_DIRNAME = ".devmemory"
|
|
13
|
+
CONFIG_FILENAME = "config.json"
|
|
14
|
+
CONFIG_LOCAL_FILENAME = "config.local.json"
|
|
15
|
+
DB_FILENAME = "metadata.db"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ProjectPaths:
|
|
20
|
+
"""Resolved absolute paths for one project's DevMemory data."""
|
|
21
|
+
|
|
22
|
+
repo_root: Path
|
|
23
|
+
"""The project/repository root - the parent of ``.devmemory/``."""
|
|
24
|
+
|
|
25
|
+
root: Path
|
|
26
|
+
"""The ``.devmemory/`` directory itself."""
|
|
27
|
+
|
|
28
|
+
config: Path
|
|
29
|
+
"""Committed, non-secret configuration (``.devmemory/config.json``)."""
|
|
30
|
+
|
|
31
|
+
config_local: Path
|
|
32
|
+
"""Git-ignored local overrides (``.devmemory/config.local.json``)."""
|
|
33
|
+
|
|
34
|
+
db: Path
|
|
35
|
+
"""SQLite metadata database."""
|
|
36
|
+
|
|
37
|
+
versions_dir: Path
|
|
38
|
+
"""Human-readable per-version JSON mirrors."""
|
|
39
|
+
|
|
40
|
+
artifacts_dir: Path
|
|
41
|
+
"""Compressed project snapshots."""
|
|
42
|
+
|
|
43
|
+
outbox_dir: Path
|
|
44
|
+
"""Pending Databricks events awaiting sync."""
|
|
45
|
+
|
|
46
|
+
runs_dir: Path
|
|
47
|
+
"""Structured logs, one file per ``devmemory checkpoint`` run."""
|
|
48
|
+
|
|
49
|
+
cache_dir: Path
|
|
50
|
+
"""Derived data that can be safely deleted (diffs, graph results)."""
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def for_root(cls, repo_root: Path) -> ProjectPaths:
|
|
54
|
+
repo_root = repo_root.resolve()
|
|
55
|
+
root = repo_root / DEVMEMORY_DIRNAME
|
|
56
|
+
return cls(
|
|
57
|
+
repo_root=repo_root,
|
|
58
|
+
root=root,
|
|
59
|
+
config=root / CONFIG_FILENAME,
|
|
60
|
+
config_local=root / CONFIG_LOCAL_FILENAME,
|
|
61
|
+
db=root / DB_FILENAME,
|
|
62
|
+
versions_dir=root / "versions",
|
|
63
|
+
artifacts_dir=root / "artifacts",
|
|
64
|
+
outbox_dir=root / "outbox",
|
|
65
|
+
runs_dir=root / "runs",
|
|
66
|
+
cache_dir=root / "cache",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def ensure_scaffold(self) -> None:
|
|
70
|
+
"""Create every directory in the layout. Idempotent."""
|
|
71
|
+
for directory in (
|
|
72
|
+
self.root,
|
|
73
|
+
self.versions_dir,
|
|
74
|
+
self.artifacts_dir,
|
|
75
|
+
self.outbox_dir,
|
|
76
|
+
self.runs_dir,
|
|
77
|
+
self.cache_dir,
|
|
78
|
+
):
|
|
79
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def exists(self) -> bool:
|
|
83
|
+
return self.root.is_dir()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def find_project_root(start: Path | None = None) -> Path | None:
|
|
87
|
+
"""Walk upward from ``start`` (default: cwd) looking for a ``.devmemory/`` dir.
|
|
88
|
+
|
|
89
|
+
Returns the containing directory, or ``None`` if the filesystem root is
|
|
90
|
+
reached without finding one.
|
|
91
|
+
"""
|
|
92
|
+
current = (start or Path.cwd()).resolve()
|
|
93
|
+
for candidate in (current, *current.parents):
|
|
94
|
+
if (candidate / DEVMEMORY_DIRNAME).is_dir():
|
|
95
|
+
return candidate
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_project_paths(start: Path | None = None) -> ProjectPaths | None:
|
|
100
|
+
root = find_project_root(start)
|
|
101
|
+
return ProjectPaths.for_root(root) if root is not None else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"CONFIG_FILENAME",
|
|
106
|
+
"CONFIG_LOCAL_FILENAME",
|
|
107
|
+
"DB_FILENAME",
|
|
108
|
+
"DEVMEMORY_DIRNAME",
|
|
109
|
+
"ProjectPaths",
|
|
110
|
+
"find_project_paths",
|
|
111
|
+
"find_project_root",
|
|
112
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The ``devmemory checkpoint`` pipeline: turn a git commit + its context into a
|
|
2
|
+
persisted :class:`~devmemory.domain.models.DevelopmentVersion`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from devmemory.pipeline.checkpoint import CheckpointResult, run_checkpoint
|
|
6
|
+
|
|
7
|
+
__all__ = ["CheckpointResult", "run_checkpoint"]
|