devtime-ei 0.1.0__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 (54) hide show
  1. devtime/__init__.py +9 -0
  2. devtime/ai/__init__.py +0 -0
  3. devtime/ai/local.py +11 -0
  4. devtime/ai/prompts.py +24 -0
  5. devtime/ai/providers.py +41 -0
  6. devtime/assets/devtimeignore.starter +23 -0
  7. devtime/cli.py +374 -0
  8. devtime/config.py +67 -0
  9. devtime/db/__init__.py +0 -0
  10. devtime/db/connection.py +16 -0
  11. devtime/db/migrations.py +114 -0
  12. devtime/db/repository.py +351 -0
  13. devtime/db/schema.sql +145 -0
  14. devtime/fixtures/__init__.py +0 -0
  15. devtime/fixtures/assertions.py +51 -0
  16. devtime/fixtures/loader.py +52 -0
  17. devtime/fixtures/runner.py +73 -0
  18. devtime/intelligence/__init__.py +0 -0
  19. devtime/intelligence/claims.py +235 -0
  20. devtime/intelligence/concepts.py +483 -0
  21. devtime/intelligence/context_pack.py +276 -0
  22. devtime/intelligence/evidence.py +127 -0
  23. devtime/intelligence/lineage.py +21 -0
  24. devtime/intelligence/risk.py +267 -0
  25. devtime/intelligence/scoring.py +99 -0
  26. devtime/mcp/__init__.py +0 -0
  27. devtime/mcp/schemas.py +39 -0
  28. devtime/mcp/server.py +35 -0
  29. devtime/mcp/tools.py +90 -0
  30. devtime/output/__init__.py +0 -0
  31. devtime/output/json_export.py +50 -0
  32. devtime/output/markdown.py +50 -0
  33. devtime/output/terminal.py +208 -0
  34. devtime/paths.py +40 -0
  35. devtime/privacy.py +96 -0
  36. devtime/scanner/__init__.py +0 -0
  37. devtime/scanner/extractors/__init__.py +0 -0
  38. devtime/scanner/extractors/base.py +83 -0
  39. devtime/scanner/extractors/config_files.py +41 -0
  40. devtime/scanner/extractors/docs.py +35 -0
  41. devtime/scanner/extractors/nextjs.py +82 -0
  42. devtime/scanner/extractors/python.py +81 -0
  43. devtime/scanner/extractors/tests.py +61 -0
  44. devtime/scanner/extractors/typescript.py +99 -0
  45. devtime/scanner/file_walker.py +96 -0
  46. devtime/scanner/ignore.py +96 -0
  47. devtime/scanner/language.py +36 -0
  48. devtime/scanner/signals.py +252 -0
  49. devtime_ei-0.1.0.dist-info/METADATA +289 -0
  50. devtime_ei-0.1.0.dist-info/RECORD +54 -0
  51. devtime_ei-0.1.0.dist-info/WHEEL +5 -0
  52. devtime_ei-0.1.0.dist-info/entry_points.txt +2 -0
  53. devtime_ei-0.1.0.dist-info/licenses/LICENSE +201 -0
  54. devtime_ei-0.1.0.dist-info/top_level.txt +1 -0
devtime/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """DevTime - local-first Engineering Intelligence for repository memory."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Version metadata (Builder Edition, Chapter 20).
6
+ EVIDENCE_MODEL = "2026.06.1"
7
+ FIXTURE_SUITE = "2026.06.1"
8
+ RISK_MODEL = "preview-1"
9
+ MCP_SCHEMA = "0.1"
devtime/ai/__init__.py ADDED
File without changes
devtime/ai/local.py ADDED
@@ -0,0 +1,11 @@
1
+ """Placeholder for a local model provider (e.g. Ollama) - disabled in V0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from devtime.ai.providers import DisabledProvider
6
+
7
+
8
+ class LocalModelProvider(DisabledProvider):
9
+ """Local provider stub. Stays disabled until a user explicitly configures it."""
10
+
11
+ name = "local"
devtime/ai/prompts.py ADDED
@@ -0,0 +1,24 @@
1
+ """Prompt guard for AI narration (Builder Edition, Chapter 15).
2
+
3
+ The model narrates governed memory; it does not invent rationale or upgrade
4
+ confidence.
5
+ """
6
+
7
+ PROMPT_GUARD = """SYSTEM:
8
+ You narrate governed repository memory.
9
+ Use only the claims, evidence, decisions, and uncertainty provided.
10
+ Do not invent rationale.
11
+ Do not upgrade confidence.
12
+ Preserve uncertainty.
13
+ If decision evidence is missing, say it is missing.
14
+
15
+ INPUT:
16
+ {context_pack_json}
17
+
18
+ OUTPUT:
19
+ A concise explanation for the developer.
20
+ """
21
+
22
+
23
+ def build_prompt(context_pack_json: str) -> str:
24
+ return PROMPT_GUARD.format(context_pack_json=context_pack_json)
@@ -0,0 +1,41 @@
1
+ """Local AI provider layer (Builder Edition, Chapter 15).
2
+
3
+ AI is optional, explicit, and never the truth layer. V0 ships a disabled provider
4
+ by default and never calls AI automatically.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol
10
+
11
+
12
+ class AIProvider(Protocol):
13
+ name: str
14
+
15
+ def is_configured(self) -> bool: ...
16
+
17
+ def generate(self, prompt: str, *, max_tokens: int) -> str: ...
18
+
19
+
20
+ class DisabledProvider:
21
+ name = "none"
22
+
23
+ def is_configured(self) -> bool:
24
+ return False
25
+
26
+ def generate(self, prompt: str, *, max_tokens: int) -> str:
27
+ raise RuntimeError("AI is disabled. Enable explicitly before generating.")
28
+
29
+
30
+ def default_provider() -> AIProvider:
31
+ return DisabledProvider()
32
+
33
+
34
+ def ai_status() -> dict:
35
+ provider = default_provider()
36
+ return {
37
+ "ai": "Disabled" if not provider.is_configured() else "Enabled",
38
+ "provider": provider.name,
39
+ "automatic_ai_calls": "Disabled",
40
+ "data_sent": "Nothing",
41
+ }
@@ -0,0 +1,23 @@
1
+ # Secrets
2
+ .env
3
+ .env.*
4
+ *.pem
5
+ *.key
6
+ *.p12
7
+ *.pfx
8
+ id_rsa
9
+ id_ed25519
10
+ secrets.*
11
+ credentials.*
12
+ service-account*.json
13
+
14
+ # Generated
15
+ node_modules/
16
+ dist/
17
+ build/
18
+ coverage/
19
+ .next/
20
+ .cache/
21
+
22
+ # VCS
23
+ .git/
devtime/cli.py ADDED
@@ -0,0 +1,374 @@
1
+ """DevTime CLI (Builder Edition, Chapter 4 + Appendix A).
2
+
3
+ The CLI is the first product surface. It must make trust visible.
4
+
5
+ Exit codes (Appendix A):
6
+ 0 success | 1 general error | 2 not initialized | 3 scan failed
7
+ 4 migration failed | 5 privacy boundary violation | 6 fixture assertion failed
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from devtime import __version__, paths
18
+
19
+ app = typer.Typer(
20
+ help="DevTime - local-first Engineering Intelligence", no_args_is_help=True
21
+ )
22
+ console = Console()
23
+
24
+ claim_app = typer.Typer(help="Inspect and govern claims.")
25
+ decision_app = typer.Typer(help="Record human decisions.")
26
+ mcp_app = typer.Typer(help="Local read-only MCP server.")
27
+ app.add_typer(claim_app, name="claim")
28
+ app.add_typer(decision_app, name="decision")
29
+ app.add_typer(mcp_app, name="mcp")
30
+
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # Initialization
34
+ # --------------------------------------------------------------------------- #
35
+
36
+ @app.command()
37
+ def init() -> None:
38
+ """Create .devtime, config, and SQLite database."""
39
+ from devtime.db.migrations import init_repo
40
+
41
+ init_repo()
42
+ console.print("[green]DevTime initialized.[/green] Local memory at .devtime/devtime.sqlite")
43
+ console.print("AI disabled. Cloud disabled. Telemetry off. MCP read-only.")
44
+
45
+
46
+ @app.command()
47
+ def status() -> None:
48
+ """Show local storage, AI, cloud, telemetry, MCP, and scan status."""
49
+ from devtime.output.terminal import print_status
50
+
51
+ print_status()
52
+
53
+
54
+ @app.command()
55
+ def doctor(
56
+ privacy: bool = typer.Option(False, "--privacy", help="Show privacy and boundary checks."),
57
+ ) -> None:
58
+ """Run environment and repository checks."""
59
+ from devtime.privacy import privacy_report
60
+
61
+ report = privacy_report()
62
+ console.print("[bold]Privacy check[/bold]\n")
63
+ console.print("[green]Good:[/green]")
64
+ for item in report["good"]:
65
+ console.print(f" - {item}")
66
+ if report["warning"]:
67
+ console.print("[yellow]Warning:[/yellow]")
68
+ for item in report["warning"]:
69
+ console.print(f" - {item}")
70
+ if report["recommended"]:
71
+ console.print("[bold]Recommended:[/bold]")
72
+ for item in report["recommended"]:
73
+ console.print(f" {item}")
74
+
75
+
76
+ # --------------------------------------------------------------------------- #
77
+ # Scanning
78
+ # --------------------------------------------------------------------------- #
79
+
80
+ @app.command()
81
+ def scan(
82
+ refresh: bool = typer.Option(False, "--refresh", help="Recompute concepts, claims, scores."),
83
+ show_ignored: bool = typer.Option(False, "--show-ignored", help="(reserved)"),
84
+ ) -> None:
85
+ """Scan repository and update local memory."""
86
+ from devtime.scanner.signals import run_scan
87
+
88
+ try:
89
+ result = run_scan(refresh=refresh, progress=True)
90
+ except Exception as exc: # noqa: BLE001
91
+ console.print(f"[red]Scan failed:[/red] {exc}")
92
+ raise typer.Exit(code=3)
93
+ console.print(
94
+ f"[green]Scan complete.[/green] Scanned {result.file_count} files, "
95
+ f"{result.signal_count} signals, {result.concept_count} concepts "
96
+ f"in {result.duration_seconds}s."
97
+ )
98
+ console.print(
99
+ f"Pruned directories: {result.pruned_dirs} "
100
+ f"Skipped/ignored files: {result.skipped_files}"
101
+ )
102
+ console.print("Supported V0 concept families: 6 (closed ontology).")
103
+ for w in result.framework_warnings:
104
+ console.print(f"[yellow]{w}[/yellow]")
105
+ console.print("Nothing left this machine. Run [bold]dtc concepts[/bold] to inspect.")
106
+
107
+
108
+ # --------------------------------------------------------------------------- #
109
+ # Understanding
110
+ # --------------------------------------------------------------------------- #
111
+
112
+ @app.command()
113
+ def concepts() -> None:
114
+ """List detected concepts."""
115
+ from devtime.output.terminal import print_concepts
116
+
117
+ print_concepts()
118
+
119
+
120
+ @app.command()
121
+ def explain(concept: str) -> None:
122
+ """Explain a concept from evidence."""
123
+ from devtime.output.terminal import print_explanation
124
+
125
+ print_explanation(concept)
126
+
127
+
128
+ @app.command()
129
+ def evidence(concept: str) -> None:
130
+ """Show evidence for a concept."""
131
+ from devtime.output.terminal import print_evidence
132
+
133
+ print_evidence(concept)
134
+
135
+
136
+ @app.command()
137
+ def debt() -> None:
138
+ """Show top Understanding Debt by concept."""
139
+ from devtime.output.terminal import print_debt
140
+
141
+ print_debt()
142
+
143
+
144
+ @app.command()
145
+ def understand() -> None:
146
+ """Show Understanding Debt across concepts (alias of debt)."""
147
+ from devtime.output.terminal import print_debt
148
+
149
+ print_debt()
150
+
151
+
152
+ # --------------------------------------------------------------------------- #
153
+ # Risk
154
+ # --------------------------------------------------------------------------- #
155
+
156
+ @app.command()
157
+ def risk(
158
+ diff: bool = typer.Option(False, "--diff", help="Review current diff against memory."),
159
+ base: str = typer.Option("HEAD", "--base", help="Base ref for the diff."),
160
+ fmt: str = typer.Option("text", "--format", help="text or markdown."),
161
+ ) -> None:
162
+ """Review local changes against repository memory."""
163
+ import subprocess
164
+
165
+ from devtime.db import connection, repository
166
+ from devtime.intelligence.risk import (
167
+ STATE_REVIEW_FAILED,
168
+ parse_unified_diff,
169
+ review_diff,
170
+ review_failed,
171
+ )
172
+ from devtime.output.markdown import render_risk_review
173
+
174
+ if not paths.is_initialized():
175
+ console.print("[red]Not initialized.[/red] Run dtc init.")
176
+ raise typer.Exit(code=2)
177
+
178
+ # Git failure must surface as review_failed, never as "no findings".
179
+ try:
180
+ # --relative emits paths relative to the current directory, so diff paths
181
+ # match scan-root-relative evidence even when the scan root is a subdirectory
182
+ # of the git repository (e.g. running the demo from examples/demo-saas).
183
+ proc = subprocess.run(
184
+ ["git", "diff", "--relative", base],
185
+ capture_output=True,
186
+ text=True,
187
+ check=False,
188
+ )
189
+ except FileNotFoundError:
190
+ console.print(render_risk_review(review_failed("git executable not found")), markup=False)
191
+ raise typer.Exit(code=1)
192
+
193
+ if proc.returncode != 0:
194
+ reason = (proc.stderr or "git diff returned a non-zero exit code").strip()
195
+ console.print(render_risk_review(review_failed(reason)), markup=False)
196
+ raise typer.Exit(code=1)
197
+
198
+ info = parse_unified_diff(proc.stdout)
199
+ conn = connection.connect()
200
+ try:
201
+ intelligence = repository.load_all_concepts(conn)
202
+ finally:
203
+ conn.close()
204
+
205
+ review = review_diff(info, intelligence)
206
+ console.print(render_risk_review(review), markup=False)
207
+ if review.state == STATE_REVIEW_FAILED:
208
+ raise typer.Exit(code=1)
209
+
210
+
211
+ # --------------------------------------------------------------------------- #
212
+ # AI workflow
213
+ # --------------------------------------------------------------------------- #
214
+
215
+ @app.command()
216
+ def context(
217
+ concept: str,
218
+ mode: str = typer.Option("risk", "--mode", help="overview|risk|implementation|testing|onboarding|security"),
219
+ copy: bool = typer.Option(False, "--copy", help="(reserved) copy to clipboard"),
220
+ ) -> None:
221
+ """Generate a Context Pack for humans or AI agents."""
222
+ from devtime.output.terminal import print_context
223
+
224
+ print_context(concept, mode)
225
+
226
+
227
+ # --------------------------------------------------------------------------- #
228
+ # Memory: claims and decisions
229
+ # --------------------------------------------------------------------------- #
230
+
231
+ @claim_app.command("show")
232
+ def claim_show(claim_id: str) -> None:
233
+ """Inspect a claim, evidence, confidence, and uncertainty."""
234
+ from devtime.db import connection
235
+
236
+ conn = connection.connect()
237
+ try:
238
+ row = conn.execute("SELECT * FROM claims WHERE id = ?", (claim_id,)).fetchone()
239
+ if not row:
240
+ console.print(f"[yellow]No claim[/yellow] {claim_id}")
241
+ return
242
+ console.print(dict(row))
243
+ finally:
244
+ conn.close()
245
+
246
+
247
+ @claim_app.command("challenge")
248
+ def claim_challenge(claim_id: str) -> None:
249
+ """Mark a claim as challenged."""
250
+ from devtime.db import connection
251
+
252
+ conn = connection.connect()
253
+ try:
254
+ cur = conn.execute(
255
+ "UPDATE claims SET state = 'challenged' WHERE id = ?", (claim_id,)
256
+ )
257
+ conn.commit()
258
+ if cur.rowcount:
259
+ console.print(f"[green]Claim {claim_id} challenged.[/green]")
260
+ else:
261
+ console.print(f"[yellow]No claim[/yellow] {claim_id}")
262
+ finally:
263
+ conn.close()
264
+
265
+
266
+ @claim_app.command("confirm")
267
+ def claim_confirm(claim_id: str) -> None:
268
+ """Confirm a claim (human confirmation)."""
269
+ from devtime.db import connection
270
+
271
+ conn = connection.connect()
272
+ try:
273
+ cur = conn.execute(
274
+ "UPDATE claims SET state = 'confirmed', created_by = 'human' WHERE id = ?",
275
+ (claim_id,),
276
+ )
277
+ conn.commit()
278
+ if cur.rowcount:
279
+ console.print(f"[green]Claim {claim_id} confirmed.[/green]")
280
+ else:
281
+ console.print(f"[yellow]No claim[/yellow] {claim_id}")
282
+ finally:
283
+ conn.close()
284
+
285
+
286
+ @decision_app.command("add")
287
+ def decision_add(
288
+ title: str = typer.Option(..., "--title", help="Decision title."),
289
+ body: str = typer.Option(..., "--body", help="Why this behavior exists or changed."),
290
+ concept: str = typer.Option(None, "--concept", help="Concept slug to attach to."),
291
+ ) -> None:
292
+ """Record why a behavior exists or changed."""
293
+ from devtime.db import connection, repository
294
+
295
+ conn = connection.connect()
296
+ try:
297
+ did = repository.add_decision(conn, title, body, concept)
298
+ console.print(f"[green]Decision recorded[/green] ({did}).")
299
+ finally:
300
+ conn.close()
301
+
302
+
303
+ # --------------------------------------------------------------------------- #
304
+ # MCP
305
+ # --------------------------------------------------------------------------- #
306
+
307
+ @mcp_app.command("start")
308
+ def mcp_start() -> None:
309
+ """Preview planned read-only MCP tools. Does NOT start a server in V0."""
310
+ from devtime.mcp.server import describe_server
311
+
312
+ console.print(describe_server())
313
+ # Honest exit: nothing was started, so a command named "start" returns nonzero.
314
+ raise typer.Exit(code=1)
315
+
316
+
317
+ @mcp_app.command("preview")
318
+ def mcp_preview() -> None:
319
+ """Preview planned read-only MCP tools (transport not implemented in V0)."""
320
+ from devtime.mcp.server import describe_server
321
+
322
+ console.print(describe_server())
323
+
324
+
325
+ @mcp_app.command("status")
326
+ def mcp_status() -> None:
327
+ """Inspect MCP permissions and clients."""
328
+ from devtime.mcp.server import describe_permissions
329
+
330
+ console.print(describe_permissions())
331
+
332
+
333
+ # --------------------------------------------------------------------------- #
334
+ # Export and reset
335
+ # --------------------------------------------------------------------------- #
336
+
337
+ @app.command()
338
+ def export(fmt: str = typer.Option("json", "--format", help="json or markdown.")) -> None:
339
+ """Export reviewable memory."""
340
+ from devtime.output.json_export import export_memory
341
+
342
+ if not paths.is_initialized():
343
+ console.print("[red]Not initialized.[/red]")
344
+ raise typer.Exit(code=2)
345
+ console.print(export_memory(fmt))
346
+
347
+
348
+ @app.command()
349
+ def reset(
350
+ yes: bool = typer.Option(False, "--yes", help="Skip confirmation."),
351
+ ) -> None:
352
+ """Delete local memory after confirmation."""
353
+ import shutil
354
+
355
+ if not paths.is_initialized():
356
+ console.print("Nothing to reset.")
357
+ return
358
+ if not yes:
359
+ confirm = typer.confirm("Delete all local DevTime memory? Source code is untouched.")
360
+ if not confirm:
361
+ console.print("Aborted.")
362
+ return
363
+ shutil.rmtree(paths.devtime_dir())
364
+ console.print("[green]Local memory deleted.[/green] Source code untouched.")
365
+
366
+
367
+ @app.command()
368
+ def version() -> None:
369
+ """Show DevTime version."""
370
+ console.print(f"DevTime CLI: {__version__}")
371
+
372
+
373
+ if __name__ == "__main__":
374
+ app()
devtime/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """Configuration loading and defaults (Builder Edition, Chapter 5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from devtime import paths
11
+
12
+ DEFAULT_CONFIG: dict[str, Any] = {
13
+ "version": 1,
14
+ "repository": {
15
+ "name": None,
16
+ "root": ".",
17
+ },
18
+ "scanner": {
19
+ "max_file_size_kb": 512,
20
+ "follow_symlinks": False,
21
+ "respect_gitignore": True,
22
+ "respect_devtimeignore": True,
23
+ "include_tests": True,
24
+ "include_docs": True,
25
+ },
26
+ "privacy": {
27
+ "ai_enabled": False,
28
+ "cloud_enabled": False,
29
+ "telemetry_enabled": False,
30
+ "store_ask_history": False,
31
+ "store_source_excerpts": False,
32
+ },
33
+ "context_packs": {
34
+ "default_mode": "risk",
35
+ "include_evidence_paths": True,
36
+ "include_source_excerpts": False,
37
+ },
38
+ "mcp": {
39
+ "enabled": False,
40
+ "bind": "127.0.0.1",
41
+ "read_only": True,
42
+ "expose_source": False,
43
+ },
44
+ }
45
+
46
+
47
+ def default_config_yaml() -> str:
48
+ return yaml.safe_dump(DEFAULT_CONFIG, sort_keys=False)
49
+
50
+
51
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
52
+ out = dict(base)
53
+ for key, value in (override or {}).items():
54
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
55
+ out[key] = _deep_merge(out[key], value)
56
+ else:
57
+ out[key] = value
58
+ return out
59
+
60
+
61
+ def load_config(root: Path | None = None) -> dict[str, Any]:
62
+ """Load config.yaml merged over defaults. Missing file returns defaults."""
63
+ path = paths.config_path(root)
64
+ if not path.exists():
65
+ return dict(DEFAULT_CONFIG)
66
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
67
+ return _deep_merge(DEFAULT_CONFIG, loaded)
devtime/db/__init__.py ADDED
File without changes
@@ -0,0 +1,16 @@
1
+ """SQLite connection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+
8
+ from devtime import paths
9
+
10
+
11
+ def connect(root: Path | None = None) -> sqlite3.Connection:
12
+ """Open the local memory database with sane defaults."""
13
+ conn = sqlite3.connect(paths.db_path(root))
14
+ conn.row_factory = sqlite3.Row
15
+ conn.execute("PRAGMA foreign_keys = ON;")
16
+ return conn
@@ -0,0 +1,114 @@
1
+ """Database initialization and migrations (Builder Edition, Chapter 20).
2
+
3
+ Every release can change what users believe, so migrations must preserve
4
+ decisions, challenged claims, rejected claims, and human confirmations.
5
+ V0 ships a single schema version; the migration framework is in place so later
6
+ versions can back up and migrate without losing memory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import sqlite3
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ from devtime import __version__, config, paths
18
+ from devtime.db import connection
19
+
20
+ SCHEMA_VERSION = 1
21
+ SCHEMA_FILE = Path(__file__).with_name("schema.sql")
22
+ IGNORE_STARTER = (
23
+ Path(__file__).resolve().parents[1] / "assets" / "devtimeignore.starter"
24
+ )
25
+
26
+
27
+ def _now() -> str:
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ def _ensure_dirs(root: Path | None = None) -> None:
32
+ paths.devtime_dir(root).mkdir(parents=True, exist_ok=True)
33
+ paths.backups_dir(root).mkdir(parents=True, exist_ok=True)
34
+ paths.logs_dir(root).mkdir(parents=True, exist_ok=True)
35
+
36
+
37
+ def _write_starter_files(root: Path | None = None) -> None:
38
+ cfg = paths.config_path(root)
39
+ if not cfg.exists():
40
+ cfg.write_text(config.default_config_yaml(), encoding="utf-8")
41
+ ignore = paths.ignore_path(root)
42
+ if not ignore.exists() and IGNORE_STARTER.exists():
43
+ ignore.write_text(IGNORE_STARTER.read_text(encoding="utf-8"), encoding="utf-8")
44
+
45
+
46
+ def current_version(conn: sqlite3.Connection) -> int:
47
+ row = conn.execute(
48
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'"
49
+ ).fetchone()
50
+ if row is None:
51
+ return 0
52
+ res = conn.execute("SELECT MAX(version) AS v FROM schema_migrations").fetchone()
53
+ return int(res["v"]) if res and res["v"] is not None else 0
54
+
55
+
56
+ def _apply_schema(conn: sqlite3.Connection) -> None:
57
+ conn.executescript(SCHEMA_FILE.read_text(encoding="utf-8"))
58
+
59
+
60
+ def init_repo(root: Path | None = None) -> str:
61
+ """Create local DevTime memory safely. Returns the repository id."""
62
+ _ensure_dirs(root)
63
+ _write_starter_files(root)
64
+
65
+ conn = connection.connect(root)
66
+ try:
67
+ _apply_schema(conn)
68
+ version = current_version(conn)
69
+ if version < SCHEMA_VERSION:
70
+ conn.execute(
71
+ "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)",
72
+ (SCHEMA_VERSION, _now()),
73
+ )
74
+
75
+ root_path = str((root or paths.repo_root()).resolve())
76
+ existing = conn.execute("SELECT id FROM repositories LIMIT 1").fetchone()
77
+ if existing:
78
+ repo_id = existing["id"]
79
+ conn.execute(
80
+ "UPDATE repositories SET root_path = ?, updated_at = ? WHERE id = ?",
81
+ (root_path, _now(), repo_id),
82
+ )
83
+ else:
84
+ repo_id = f"repo-{uuid.uuid4().hex[:12]}"
85
+ conn.execute(
86
+ "INSERT INTO repositories(id, root_path, created_at, updated_at) "
87
+ "VALUES (?, ?, ?, ?)",
88
+ (repo_id, root_path, _now(), _now()),
89
+ )
90
+ conn.commit()
91
+ return repo_id
92
+ finally:
93
+ conn.close()
94
+
95
+
96
+ def backup_database(from_version: int, root: Path | None = None) -> Path | None:
97
+ """Create a pre-migration backup (Chapter 20 migration flow step 2)."""
98
+ db = paths.db_path(root)
99
+ if not db.exists():
100
+ return None
101
+ dest = paths.backups_dir(root) / f"devtime-before-schema-{from_version}.sqlite"
102
+ shutil.copy2(db, dest)
103
+ return dest
104
+
105
+
106
+ def get_repository_id(root: Path | None = None) -> str | None:
107
+ if not paths.is_initialized(root):
108
+ return None
109
+ conn = connection.connect(root)
110
+ try:
111
+ row = conn.execute("SELECT id FROM repositories LIMIT 1").fetchone()
112
+ return row["id"] if row else None
113
+ finally:
114
+ conn.close()