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.
- devtime/__init__.py +9 -0
- devtime/ai/__init__.py +0 -0
- devtime/ai/local.py +11 -0
- devtime/ai/prompts.py +24 -0
- devtime/ai/providers.py +41 -0
- devtime/assets/devtimeignore.starter +23 -0
- devtime/cli.py +374 -0
- devtime/config.py +67 -0
- devtime/db/__init__.py +0 -0
- devtime/db/connection.py +16 -0
- devtime/db/migrations.py +114 -0
- devtime/db/repository.py +351 -0
- devtime/db/schema.sql +145 -0
- devtime/fixtures/__init__.py +0 -0
- devtime/fixtures/assertions.py +51 -0
- devtime/fixtures/loader.py +52 -0
- devtime/fixtures/runner.py +73 -0
- devtime/intelligence/__init__.py +0 -0
- devtime/intelligence/claims.py +235 -0
- devtime/intelligence/concepts.py +483 -0
- devtime/intelligence/context_pack.py +276 -0
- devtime/intelligence/evidence.py +127 -0
- devtime/intelligence/lineage.py +21 -0
- devtime/intelligence/risk.py +267 -0
- devtime/intelligence/scoring.py +99 -0
- devtime/mcp/__init__.py +0 -0
- devtime/mcp/schemas.py +39 -0
- devtime/mcp/server.py +35 -0
- devtime/mcp/tools.py +90 -0
- devtime/output/__init__.py +0 -0
- devtime/output/json_export.py +50 -0
- devtime/output/markdown.py +50 -0
- devtime/output/terminal.py +208 -0
- devtime/paths.py +40 -0
- devtime/privacy.py +96 -0
- devtime/scanner/__init__.py +0 -0
- devtime/scanner/extractors/__init__.py +0 -0
- devtime/scanner/extractors/base.py +83 -0
- devtime/scanner/extractors/config_files.py +41 -0
- devtime/scanner/extractors/docs.py +35 -0
- devtime/scanner/extractors/nextjs.py +82 -0
- devtime/scanner/extractors/python.py +81 -0
- devtime/scanner/extractors/tests.py +61 -0
- devtime/scanner/extractors/typescript.py +99 -0
- devtime/scanner/file_walker.py +96 -0
- devtime/scanner/ignore.py +96 -0
- devtime/scanner/language.py +36 -0
- devtime/scanner/signals.py +252 -0
- devtime_ei-0.1.0.dist-info/METADATA +289 -0
- devtime_ei-0.1.0.dist-info/RECORD +54 -0
- devtime_ei-0.1.0.dist-info/WHEEL +5 -0
- devtime_ei-0.1.0.dist-info/entry_points.txt +2 -0
- devtime_ei-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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)
|
devtime/ai/providers.py
ADDED
|
@@ -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
|
devtime/db/connection.py
ADDED
|
@@ -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
|
devtime/db/migrations.py
ADDED
|
@@ -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()
|