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