gdmcode 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 (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
src/config.py ADDED
@@ -0,0 +1,762 @@
1
+ """GdmConfig — loads and validates all configuration.
2
+
3
+ Legacy session config priority (highest → lowest):
4
+ 1. Environment variables (XAI_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, GDM_*)
5
+ 2. System keychain (via CredentialStore / keyring)
6
+ 3. ~/.config/gdm/config.toml [api] section
7
+ 4. Built-in defaults
8
+
9
+ The config object is immutable once created. Re-create to reload.
10
+
11
+ Application settings (sdk-002) — ConfigLoader with 7-layer precedence:
12
+ 1. Hardcoded defaults
13
+ 2. User config (~/.config/gdm/config.toml)
14
+ 3. Project config (.gdm/config.toml)
15
+ 4. Team preferences (.gdm/team.toml [preferences])
16
+ 5. Team policy (.gdm/team.toml [policy]) ← overrides env + CLI
17
+ 6. Environment variables (GDM_* prefix)
18
+ 7. CLI flags
19
+
20
+ Secrets (api_key, token, password, credential) go to OS keychain — never to TOML.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import os
26
+ import re
27
+ import tomllib
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from pydantic import BaseModel, Field, field_validator
33
+
34
+ from src._internal.constants import (
35
+ _CONTEXT_MEMORY_DIR,
36
+ _DEFAULT_COST_LIMIT_USD,
37
+ _MAX_AGENT_TURNS,
38
+ )
39
+ from src.exceptions import ConfigError
40
+ from src.models.definitions import Provider
41
+
42
+ __all__ = ["GdmConfig", "GdmSettings", "ConfigValue", "ConfigLoader", "KEYCHAIN_KEYS", "load_config"]
43
+
44
+ log = logging.getLogger(__name__)
45
+
46
+ _GLOBAL_CONFIG_DIR = Path.home() / ".config" / "gdm"
47
+ _GLOBAL_CONFIG_FILE = _GLOBAL_CONFIG_DIR / "config.toml"
48
+ _GLOBAL_INSTRUCTIONS_FILE = _GLOBAL_CONFIG_DIR / "instructions.md"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class GdmConfig:
53
+ """Immutable configuration for a gdm session."""
54
+
55
+ # Provider + API keys
56
+ provider: str
57
+ api_key: str
58
+ xai_api_key: str | None
59
+ gemini_api_key: str | None
60
+ openai_api_key: str | None
61
+
62
+ # Project paths
63
+ project_root: Path
64
+ context_memory_dir: Path
65
+ gdm_instructions: str # contents of .gdm + global instructions, pre-merged
66
+
67
+ # Agent behaviour
68
+ max_turns: int
69
+ cost_limit_usd: float
70
+
71
+ # Spinner customisation (user-appended verbs from config.toml)
72
+ extra_spinner_verbs: list[str] = field(default_factory=list)
73
+
74
+ # Model ID overrides — resolved from env vars + TOML [models] section
75
+ model_ids: dict[str, str] = field(default_factory=dict)
76
+
77
+ # Tool execution settings
78
+ tools_timeout_secs: int = 30
79
+
80
+ # API fallback settings
81
+ fallback_provider: str | None = None
82
+ model_id_map: dict[str, str] = field(default_factory=dict)
83
+
84
+ # Debug loop settings
85
+ debug_auto_search_iteration: int = 3 # 0 = disabled
86
+
87
+ # Debate model override — "" means use reasoner/debate tier from config
88
+ debate_model: str = ""
89
+
90
+ # Artifact auto-detection (opt-in, off by default to prevent alert fatigue)
91
+ artifacts_auto_detect: bool = False
92
+
93
+ # Whole-codebase mode: load all source files upfront when project fits in context
94
+ whole_codebase: str = field(default="auto") # "auto" | "always" | "never"
95
+
96
+ # Quiet mode: suppress non-essential startup output (e.g. memory hints)
97
+ gdm_quiet: bool = False
98
+
99
+ # Local provider configurations (Ollama, vLLM) — parsed from [providers.*] TOML
100
+ local_providers: list = field(default_factory=list) # list[LocalProviderConfig]
101
+
102
+ # Proxy settings — allows geo-restricted regions to route calls via gdm relay.
103
+ # proxy_token comes from OS keychain / env; never stored in TOML plaintext.
104
+ proxy_url: str = ""
105
+ proxy_token: str | None = None
106
+ proxy_enabled: bool = False
107
+
108
+ @property
109
+ def has_grok(self) -> bool:
110
+ return bool(self.xai_api_key)
111
+
112
+ @property
113
+ def has_gemini(self) -> bool:
114
+ return bool(self.gemini_api_key)
115
+
116
+ @property
117
+ def has_codex(self) -> bool:
118
+ return bool(self.openai_api_key)
119
+
120
+
121
+ def load_config(project_root: Path | None = None) -> GdmConfig:
122
+ """Load and validate configuration from all sources.
123
+
124
+ Args:
125
+ project_root: project directory (defaults to cwd).
126
+
127
+ Raises:
128
+ ConfigError: if no API key is found from any source.
129
+ """
130
+ from src.auth import CredentialStore
131
+
132
+ root = (project_root or Path.cwd()).resolve()
133
+ store = CredentialStore()
134
+ creds = store.load_all()
135
+
136
+ # ── 1. API keys: env → keychain (merged in load_all) ────────────────
137
+ xai_key = creds.xai_api_key
138
+ gemini_key = creds.gemini_api_key
139
+ openai_key = creds.openai_api_key
140
+
141
+ # ── 2. Global config.toml ─────────────────────────────────────────
142
+ toml_cfg: dict = {} # type: ignore[type-arg]
143
+ if _GLOBAL_CONFIG_FILE.exists():
144
+ try:
145
+ toml_cfg = tomllib.loads(_GLOBAL_CONFIG_FILE.read_text(encoding="utf-8"))
146
+ except Exception as exc:
147
+ log.warning("Could not parse %s: %s", _GLOBAL_CONFIG_FILE, exc)
148
+
149
+ # Keys can also live in config.toml under [api] (lowest priority)
150
+ api_section = toml_cfg.get("api", {})
151
+ if not xai_key:
152
+ xai_key = api_section.get("xai_api_key") or None
153
+ if not gemini_key:
154
+ gemini_key = api_section.get("gemini_api_key") or None
155
+ if not openai_key:
156
+ openai_key = api_section.get("openai_api_key") or None
157
+
158
+ # ── 3. Provider selection ─────────────────────────────────────────
159
+ preferred = os.environ.get("GDM_PROVIDER") or toml_cfg.get("provider") or None
160
+
161
+ provider, api_key = _select_provider(preferred, xai_key, gemini_key, openai_key)
162
+
163
+ # ── 4. User instructions (.gdm + global) ─────────────────────────
164
+ instructions_parts: list[str] = []
165
+ if _GLOBAL_INSTRUCTIONS_FILE.exists():
166
+ instructions_parts.append(_GLOBAL_INSTRUCTIONS_FILE.read_text(encoding="utf-8").strip())
167
+
168
+ project_gdm = root / ".gdm"
169
+ _maybe_migrate_gdm_dir(root) # migrate flat .gdm file to .gdm/ directory
170
+ if project_gdm.is_dir():
171
+ instructions_file = project_gdm / "instructions.md"
172
+ if instructions_file.exists():
173
+ instructions_parts.append(instructions_file.read_text(encoding="utf-8").strip())
174
+ elif project_gdm.is_file():
175
+ instructions_parts.append(project_gdm.read_text(encoding="utf-8").strip())
176
+
177
+ gdm_instructions = "\n\n".join(instructions_parts)
178
+
179
+ # ── 5. Agent settings ─────────────────────────────────────────────
180
+ agent_cfg = toml_cfg.get("agent", {})
181
+ max_turns: int = int(
182
+ os.environ.get("GDM_MAX_TURNS") or agent_cfg.get("max_turns", _MAX_AGENT_TURNS)
183
+ )
184
+ cost_limit: float = float(
185
+ os.environ.get("GDM_COST_LIMIT") or agent_cfg.get("cost_limit_usd", _DEFAULT_COST_LIMIT_USD)
186
+ )
187
+
188
+ # ── 6. Spinner extra verbs ─────────────────────────────────────────
189
+ extra_verbs: list[str] = toml_cfg.get("spinner", {}).get("extra_verbs", [])
190
+
191
+ # ── 7. Model ID overrides (env vars → TOML [models]) ──────────────
192
+ model_ids = _load_model_id_overrides(toml_cfg)
193
+
194
+ # Apply overrides to module-level ModelDef constants before first use
195
+ from src.models.definitions import apply_model_id_overrides
196
+ apply_model_id_overrides(model_ids)
197
+
198
+ # ── 8. Debug settings ─────────────────────────────────────────────
199
+ debug_cfg = toml_cfg.get("debug", {})
200
+ debug_auto_search_iteration: int = int(
201
+ os.environ.get("GDM_DEBUG_AUTO_SEARCH_ITERATION")
202
+ or debug_cfg.get("auto_search_iteration", 3)
203
+ )
204
+
205
+ # ── 9. Tool settings ──────────────────────────────────────────────
206
+ tools_cfg = toml_cfg.get("tools", {})
207
+ tools_timeout_secs: int = int(
208
+ os.environ.get("GDM_TOOLS_TIMEOUT") or tools_cfg.get("timeout_secs", 30)
209
+ )
210
+
211
+ # ── 10. API fallback settings ─────────────────────────────────────
212
+ fallback_cfg = toml_cfg.get("fallback", {})
213
+ fallback_provider: str | None = (
214
+ os.environ.get("GDM_FALLBACK_PROVIDER") or fallback_cfg.get("provider") or None
215
+ )
216
+ model_id_map: dict[str, str] = dict(fallback_cfg.get("model_id_map", {}))
217
+
218
+ # ── 11. Proxy settings ────────────────────────────────────────────
219
+ proxy_cfg = toml_cfg.get("proxy", {})
220
+ proxy_url: str = os.environ.get("GDM_PROXY_URL") or proxy_cfg.get("url") or ""
221
+ # Token comes from env → keychain only; never stored in TOML plaintext.
222
+ proxy_token: str | None = creds.proxy_token
223
+ proxy_enabled: bool = bool(
224
+ os.environ.get("GDM_PROXY_ENABLED", "").lower() in ("1", "true", "yes")
225
+ or proxy_cfg.get("enabled", False)
226
+ )
227
+
228
+ return GdmConfig(
229
+ provider=provider,
230
+ api_key=api_key,
231
+ xai_api_key=xai_key,
232
+ gemini_api_key=gemini_key,
233
+ openai_api_key=openai_key,
234
+ project_root=root,
235
+ context_memory_dir=root / _CONTEXT_MEMORY_DIR,
236
+ gdm_instructions=gdm_instructions,
237
+ max_turns=max_turns,
238
+ cost_limit_usd=cost_limit,
239
+ extra_spinner_verbs=extra_verbs,
240
+ model_ids=model_ids,
241
+ debug_auto_search_iteration=debug_auto_search_iteration,
242
+ tools_timeout_secs=tools_timeout_secs,
243
+ fallback_provider=fallback_provider,
244
+ model_id_map=model_id_map,
245
+ debate_model=toml_cfg.get("debate", {}).get("model", ""),
246
+ artifacts_auto_detect=bool(toml_cfg.get("artifacts", {}).get("auto_detect", False)),
247
+ whole_codebase=toml_cfg.get("context", {}).get("whole_codebase", "auto"),
248
+ gdm_quiet=bool(
249
+ os.environ.get("GDM_QUIET", "").lower() in ("1", "true", "yes")
250
+ or toml_cfg.get("agent", {}).get("quiet", False)
251
+ ),
252
+ local_providers=_load_local_providers(toml_cfg),
253
+ proxy_url=proxy_url,
254
+ proxy_token=proxy_token,
255
+ proxy_enabled=proxy_enabled,
256
+ )
257
+
258
+
259
+ def _load_local_providers(toml_cfg: dict) -> list: # type: ignore[type-arg]
260
+ """Parse ``[providers.ollama]`` and ``[providers.vllm]`` TOML sections.
261
+
262
+ Returns a list of ``LocalProviderConfig`` for each enabled local provider.
263
+ Providers with ``enabled = false`` (or missing) are skipped.
264
+ """
265
+ # Lazy import avoids circular dependency (client.py imports config.py)
266
+ from src.models.client import LocalProviderConfig
267
+
268
+ _DEFAULTS: dict[str, str] = {
269
+ "ollama": "http://localhost:11434",
270
+ "vllm": "http://localhost:8000",
271
+ }
272
+ providers_cfg = toml_cfg.get("providers", {})
273
+ result: list = []
274
+
275
+ for provider_name in ("ollama", "vllm"):
276
+ section = providers_cfg.get(provider_name, {})
277
+ if not section.get("enabled", False):
278
+ continue
279
+ result.append(
280
+ LocalProviderConfig(
281
+ provider=provider_name,
282
+ base_url=section.get("base_url", _DEFAULTS[provider_name]),
283
+ model=section.get("model", ""),
284
+ local_only=bool(section.get("local_only", True)),
285
+ allow_cloud_fallback=bool(section.get("allow_cloud_fallback", False)),
286
+ allow_external=bool(section.get("allow_external", False)),
287
+ )
288
+ )
289
+
290
+ return result
291
+
292
+
293
+ def _load_model_id_overrides(toml_cfg: dict) -> dict[str, str]: # type: ignore[type-arg]
294
+ """Build a ``{provider.tier: model_id}`` dict from env vars and TOML.
295
+
296
+ Resolution order per key: env var > config.toml > (omitted = use default).
297
+ """
298
+ _PROVIDERS = ("grok", "gemini", "codex")
299
+ _TIERS = ("scout", "coder", "thinker", "reasoner", "debate", "standard")
300
+ models_section = toml_cfg.get("models", {})
301
+ result: dict[str, str] = {}
302
+ for provider in _PROVIDERS:
303
+ provider_cfg = models_section.get(provider, {})
304
+ for tier in _TIERS:
305
+ env_key = f"GDM_MODEL_{provider.upper()}_{tier.upper()}"
306
+ env_val = os.environ.get(env_key)
307
+ if env_val:
308
+ result[f"{provider}.{tier}"] = env_val
309
+ elif tier in provider_cfg:
310
+ result[f"{provider}.{tier}"] = str(provider_cfg[tier])
311
+ return result
312
+
313
+
314
+ def _select_provider( preferred: str | None,
315
+ xai_key: str | None,
316
+ gemini_key: str | None,
317
+ openai_key: str | None,
318
+ ) -> tuple[str, str]:
319
+ """Return (provider_name, api_key) based on preference and available keys.
320
+
321
+ Raises ConfigError if no key is available.
322
+ """
323
+ # Try the preferred provider first.
324
+ if preferred:
325
+ match preferred.lower():
326
+ case "grok" | "xai" if xai_key:
327
+ return Provider.GROK, xai_key
328
+ case "gemini" | "google" if gemini_key:
329
+ return Provider.GEMINI, gemini_key
330
+ case "codex" | "openai" if openai_key:
331
+ return Provider.CODEX, openai_key
332
+ case _:
333
+ log.warning("Preferred provider %r has no key — falling back", preferred)
334
+
335
+ # Auto-detect: Grok > Gemini > Codex.
336
+ if xai_key:
337
+ return Provider.GROK, xai_key
338
+ if gemini_key:
339
+ return Provider.GEMINI, gemini_key
340
+ if openai_key:
341
+ return Provider.CODEX, openai_key
342
+
343
+ raise ConfigError(
344
+ "No API key found. Set one of:\n"
345
+ " export XAI_API_KEY=xai-... # xAI Grok (recommended)\n"
346
+ " export GEMINI_API_KEY=AIza... # Google Gemini (free tier)\n"
347
+ " export OPENAI_API_KEY=sk-... # OpenAI Codex\n"
348
+ "Or run: gdm login"
349
+ )
350
+
351
+
352
+ # ═══════════════════════════════════════════════════════════════════════════════
353
+ # Application Settings System (sdk-002)
354
+ # ═══════════════════════════════════════════════════════════════════════════════
355
+
356
+ # ── Secret keys that NEVER go to TOML ─────────────────────────────────────────
357
+ KEYCHAIN_KEYS: frozenset[str] = frozenset({
358
+ "xai_api_key", "openai_api_key", "anthropic_api_key",
359
+ "google_api_key", "github_token",
360
+ })
361
+
362
+
363
+ # ── Source-annotated config value ──────────────────────────────────────────────
364
+ @dataclass
365
+ class ConfigValue:
366
+ """A configuration value annotated with its source layer."""
367
+
368
+ value: Any
369
+ source: str # e.g. "default", "user:~/.config/gdm/config.toml",
370
+ # "project:.gdm/config.toml", "env:GDM_AUTONOMY_LEVEL",
371
+ # "cli", "team-policy:.gdm/team.toml[policy]"
372
+
373
+
374
+ # ── Pydantic application settings model ───────────────────────────────────────
375
+ class GdmSettings(BaseModel):
376
+ """Immutable application settings loaded by ConfigLoader."""
377
+
378
+ model_config = {"frozen": True}
379
+
380
+ # [models]
381
+ primary_model: str = "grok-4-0106"
382
+ fallback_model: str = "o3-mini"
383
+
384
+ # [budget]
385
+ session_limit_usd: float = Field(default=10.0, ge=0)
386
+ monthly_limit_usd: float = Field(default=200.0, ge=0)
387
+
388
+ # [autonomy]
389
+ autonomy_level: int = Field(default=2, ge=0, le=5)
390
+
391
+ # [audit]
392
+ retain_days: int = Field(default=90, ge=1)
393
+
394
+ # [analytics]
395
+ consent_required: bool = False
396
+ roi_estimation: bool = False
397
+ anonymise: bool = True
398
+
399
+ # Enterprise policy fields (written from team.toml [policy])
400
+ policy_enforced: bool = False
401
+ allow_git_push: bool = True
402
+ allow_network: bool = True
403
+ network_allowlist: list[str] = Field(default_factory=list)
404
+
405
+ @field_validator("autonomy_level")
406
+ @classmethod
407
+ def autonomy_in_range(cls, v: int) -> int:
408
+ if not 0 <= v <= 5:
409
+ raise ValueError(f"autonomy_level must be 0–5, got {v}")
410
+ return v
411
+
412
+
413
+ # ── TOML section → GdmSettings field mapping ──────────────────────────────────
414
+ # Maps TOML dot-notation keys to GdmSettings field names.
415
+ _TOML_KEY_MAP: dict[str, str] = {
416
+ "models.primary": "primary_model",
417
+ "models.fallback": "fallback_model",
418
+ "budget.session_limit_usd": "session_limit_usd",
419
+ "budget.monthly_limit_usd": "monthly_limit_usd",
420
+ "autonomy.level": "autonomy_level",
421
+ "audit.retain_days": "retain_days",
422
+ "analytics.consent_required": "consent_required",
423
+ "analytics.roi_estimation": "roi_estimation",
424
+ "analytics.anonymise": "anonymise",
425
+ }
426
+ # Reverse: GdmSettings field names → TOML dot-notation keys
427
+ _FIELD_TO_TOML: dict[str, str] = {v: k for k, v in _TOML_KEY_MAP.items()}
428
+
429
+ # Env var names → GdmSettings field names
430
+ _ENV_KEY_MAP: dict[str, str] = {
431
+ "GDM_MODELS_PRIMARY": "primary_model",
432
+ "GDM_MODELS_FALLBACK": "fallback_model",
433
+ "GDM_BUDGET_SESSION_LIMIT_USD": "session_limit_usd",
434
+ "GDM_BUDGET_MONTHLY_LIMIT_USD": "monthly_limit_usd",
435
+ "GDM_AUTONOMY_LEVEL": "autonomy_level",
436
+ "GDM_AUDIT_RETAIN_DAYS": "retain_days",
437
+ "GDM_ANALYTICS_CONSENT_REQUIRED": "consent_required",
438
+ "GDM_ANALYTICS_ROI_ESTIMATION": "roi_estimation",
439
+ "GDM_ANALYTICS_ANONYMISE": "anonymise",
440
+ }
441
+
442
+ # Policy keys in team.toml [policy] that map to GdmSettings fields
443
+ # "max_autonomy_level" is the policy spelling for "autonomy_level"
444
+ _POLICY_KEY_MAP: dict[str, str] = {
445
+ "max_autonomy_level": "autonomy_level",
446
+ "policy_enforced": "policy_enforced",
447
+ "allow_git_push": "allow_git_push",
448
+ "allow_network": "allow_network",
449
+ "network_allowlist": "network_allowlist",
450
+ }
451
+
452
+ # Integer, float, and bool field sets for env-var coercion
453
+ _INT_FIELDS: frozenset[str] = frozenset({"autonomy_level", "retain_days"})
454
+ _FLOAT_FIELDS: frozenset[str] = frozenset({"session_limit_usd", "monthly_limit_usd"})
455
+ _BOOL_FIELDS: frozenset[str] = frozenset({"consent_required", "roi_estimation", "anonymise"})
456
+
457
+
458
+ def _maybe_migrate_gdm_dir(project_root: Path | None = None) -> None:
459
+ """Migrate legacy .gdm flat-file to .gdm/ directory layout.
460
+
461
+ The legacy layout stores ``.gdm`` as a plain text file containing
462
+ agent instructions. The new layout uses ``.gdm/`` as a directory
463
+ that contains ``config.toml``, ``team.toml``, ``instructions.md``, etc.
464
+ This shim is idempotent — if ``.gdm`` is already a directory (or absent)
465
+ it does nothing.
466
+ """
467
+ root = project_root or Path.cwd()
468
+ gdm_path = root / ".gdm"
469
+ if not gdm_path.exists() or gdm_path.is_dir():
470
+ return
471
+ # .gdm exists as a file — migrate to a directory
472
+ legacy_content = gdm_path.read_text(encoding="utf-8", errors="replace")
473
+ gdm_path.unlink()
474
+ gdm_path.mkdir(parents=True, exist_ok=True)
475
+ (gdm_path / "instructions.md").write_text(legacy_content, encoding="utf-8")
476
+ log.info("Migrated .gdm flat file to .gdm/instructions.md")
477
+
478
+
479
+ def _is_secret_key(key: str) -> bool:
480
+ """Return True if *key* matches a known secret pattern."""
481
+ if key in KEYCHAIN_KEYS:
482
+ return True
483
+ return bool(re.search(r"(api_key|token|secret|password|credential)", key, re.I))
484
+
485
+
486
+ def _coerce_env_value(field_name: str, value: str) -> Any:
487
+ """Coerce an env-var string to the correct Python type for *field_name*."""
488
+ if field_name in _INT_FIELDS:
489
+ return int(value)
490
+ if field_name in _FLOAT_FIELDS:
491
+ return float(value)
492
+ if field_name in _BOOL_FIELDS:
493
+ return value.lower() in ("1", "true", "yes", "on")
494
+ return value
495
+
496
+
497
+ def _flatten_toml(data: dict[str, Any]) -> dict[str, Any]:
498
+ """Flatten a TOML section dict to GdmSettings field names.
499
+
500
+ Handles the standard section layout ([models], [budget], [autonomy], …)
501
+ *and* flat dicts where keys already match GdmSettings field names
502
+ (used for team.toml [preferences]).
503
+ """
504
+ result: dict[str, Any] = {}
505
+
506
+ # [models]
507
+ models = data.get("models", {})
508
+ if isinstance(models, dict):
509
+ if "primary" in models:
510
+ result["primary_model"] = models["primary"]
511
+ if "fallback" in models:
512
+ result["fallback_model"] = models["fallback"]
513
+
514
+ # [budget]
515
+ budget = data.get("budget", {})
516
+ if isinstance(budget, dict):
517
+ for k in ("session_limit_usd", "monthly_limit_usd"):
518
+ if k in budget:
519
+ result[k] = budget[k]
520
+
521
+ # [autonomy]
522
+ autonomy = data.get("autonomy", {})
523
+ if isinstance(autonomy, dict) and "level" in autonomy:
524
+ result["autonomy_level"] = autonomy["level"]
525
+
526
+ # [audit]
527
+ audit = data.get("audit", {})
528
+ if isinstance(audit, dict) and "retain_days" in audit:
529
+ result["retain_days"] = audit["retain_days"]
530
+
531
+ # [analytics]
532
+ analytics = data.get("analytics", {})
533
+ if isinstance(analytics, dict):
534
+ for k in ("consent_required", "roi_estimation", "anonymise"):
535
+ if k in analytics:
536
+ result[k] = analytics[k]
537
+
538
+ # Flat keys that directly match GdmSettings field names (team-prefs convenience)
539
+ for field_name in GdmSettings.model_fields:
540
+ if field_name in data and field_name not in result:
541
+ result[field_name] = data[field_name]
542
+
543
+ return result
544
+
545
+
546
+ class ConfigLoader:
547
+ """Loads :class:`GdmSettings` from the 7-layer precedence stack.
548
+
549
+ Call :meth:`load` to get ``(settings, provenance)`` where *provenance*
550
+ maps every field name to a :class:`ConfigValue` showing value + origin.
551
+ """
552
+
553
+ def __init__(self, project_root: Path | None = None) -> None:
554
+ self._project_root = (project_root or Path.cwd()).resolve()
555
+
556
+ # ── Public API ──────────────────────────────────────────────────────────
557
+
558
+ def load(
559
+ self, cli_overrides: dict[str, Any] | None = None
560
+ ) -> tuple[GdmSettings, dict[str, ConfigValue]]:
561
+ """Return ``(GdmSettings, provenance)`` after merging all layers.
562
+
563
+ *cli_overrides* is a dict of ``{field_name: value}`` pairs provided
564
+ by CLI flags (highest normal priority, overridden by team policy).
565
+ """
566
+ user_path = self._user_config_path()
567
+ project_path = self._project_config_path()
568
+ team_path = self._team_config_path()
569
+
570
+ # Load raw values per source
571
+ defaults_vals = self._defaults()
572
+ user_vals = self._load_toml_file(user_path, f"user:{user_path}")
573
+ project_vals = self._load_toml_file(project_path, f"project:{project_path}")
574
+ team_prefs_vals = self._load_team_prefs()
575
+ team_policy_vals = self._load_team_policy()
576
+ env_vals = self._load_env()
577
+ cli_vals: dict[str, Any] = cli_overrides or {}
578
+
579
+ # Apply layers lowest → highest (each overrides previous)
580
+ merged: dict[str, ConfigValue] = {}
581
+
582
+ for field_name, value in defaults_vals.items():
583
+ merged[field_name] = ConfigValue(value=value, source="default")
584
+
585
+ for field_name, cv in user_vals.items():
586
+ merged[field_name] = cv
587
+
588
+ for field_name, cv in project_vals.items():
589
+ merged[field_name] = cv
590
+
591
+ for field_name, cv in team_prefs_vals.items():
592
+ merged[field_name] = cv
593
+
594
+ # env and cli are applied before policy so policy can override them
595
+ for field_name, (value, env_key) in env_vals.items():
596
+ merged[field_name] = ConfigValue(value=value, source=f"env:{env_key}")
597
+
598
+ for field_name, value in cli_vals.items():
599
+ merged[field_name] = ConfigValue(value=value, source="cli")
600
+
601
+ # Team policy overrides env + cli (intentional enterprise enforcement)
602
+ policy_source = f"team-policy:{team_path}[policy]"
603
+ for field_name, value in team_policy_vals.items():
604
+ merged[field_name] = ConfigValue(value=value, source=policy_source)
605
+
606
+ settings_kwargs = {k: cv.value for k, cv in merged.items()}
607
+ settings = GdmSettings(**settings_kwargs)
608
+ return settings, merged
609
+
610
+ def write_user(self, key: str, value: Any) -> None:
611
+ """Write *key* to the user config TOML (or keychain for secrets).
612
+
613
+ *key* uses TOML dot notation (e.g. ``"models.primary"``).
614
+ """
615
+ if _is_secret_key(key):
616
+ import keyring # type: ignore[import-untyped]
617
+ keyring.set_password("gdm", key, str(value))
618
+ return
619
+ path = self._user_config_path()
620
+ path.parent.mkdir(parents=True, exist_ok=True)
621
+ self._atomic_write(path, key, value)
622
+
623
+ def write_project(self, key: str, value: Any) -> None:
624
+ """Write *key* to the project config TOML.
625
+
626
+ Raises :class:`ValueError` if *key* is a secret — secrets must
627
+ go to the OS keychain, never to project TOML.
628
+ """
629
+ if _is_secret_key(key):
630
+ raise ValueError(
631
+ f"{key!r} is a secret — stored in keychain, never in project TOML. "
632
+ "Use `gdm config set` without --project for secrets."
633
+ )
634
+ _maybe_migrate_gdm_dir(self._project_root)
635
+ path = self._project_config_path()
636
+ path.parent.mkdir(parents=True, exist_ok=True)
637
+ self._atomic_write(path, key, value)
638
+
639
+ def unset_user(self, key: str) -> None:
640
+ """Remove *key* from the user config TOML atomically."""
641
+ path = self._user_config_path()
642
+ if not path.exists():
643
+ return
644
+ existing = tomllib.loads(path.read_text(encoding="utf-8"))
645
+ keys = key.split(".")
646
+ node: Any = existing
647
+ for k in keys[:-1]:
648
+ if not isinstance(node, dict) or k not in node:
649
+ return
650
+ node = node[k]
651
+ if isinstance(node, dict):
652
+ node.pop(keys[-1], None)
653
+ import tomli_w # type: ignore[import-untyped]
654
+ tmp = path.with_suffix(".tmp")
655
+ try:
656
+ tmp.write_bytes(tomli_w.dumps(existing).encode("utf-8"))
657
+ tmp.replace(path)
658
+ except Exception:
659
+ tmp.unlink(missing_ok=True)
660
+ raise
661
+
662
+ # ── Path helpers ────────────────────────────────────────────────────────
663
+
664
+ def _user_config_path(self) -> Path:
665
+ return _GLOBAL_CONFIG_FILE
666
+
667
+ def _project_config_path(self) -> Path:
668
+ return self._project_root / ".gdm" / "config.toml"
669
+
670
+ def _team_config_path(self) -> Path:
671
+ return self._project_root / ".gdm" / "team.toml"
672
+
673
+ # ── Layer loaders ────────────────────────────────────────────────────────
674
+
675
+ def _defaults(self) -> dict[str, Any]:
676
+ return GdmSettings().model_dump()
677
+
678
+ def _load_toml_file(
679
+ self, path: Path, source_label: str
680
+ ) -> dict[str, ConfigValue]:
681
+ """Load a TOML config file and return ``{field: ConfigValue}``."""
682
+ if not path.exists():
683
+ return {}
684
+ try:
685
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
686
+ flat = _flatten_toml(data)
687
+ return {k: ConfigValue(value=v, source=source_label) for k, v in flat.items()}
688
+ except Exception as exc:
689
+ log.warning("Could not parse %s: %s", path, exc)
690
+ return {}
691
+
692
+ def _load_team_prefs(self) -> dict[str, ConfigValue]:
693
+ """Load team preferences from team.toml ``[preferences]`` section."""
694
+ path = self._team_config_path()
695
+ if not path.exists():
696
+ return {}
697
+ try:
698
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
699
+ prefs = data.get("preferences", {})
700
+ flat = _flatten_toml(prefs)
701
+ source = f"team-prefs:{path}"
702
+ return {k: ConfigValue(value=v, source=source) for k, v in flat.items()}
703
+ except Exception as exc:
704
+ log.warning("Could not parse team.toml [preferences]: %s", exc)
705
+ return {}
706
+
707
+ def _load_team_policy(self) -> dict[str, Any]:
708
+ """Load team policy from team.toml ``[policy]`` section.
709
+
710
+ Returns ``{field_name: value}`` — stored separately from normal
711
+ ConfigValues so callers can apply policy *after* env + cli.
712
+ """
713
+ path = self._team_config_path()
714
+ if not path.exists():
715
+ return {}
716
+ try:
717
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
718
+ policy = data.get("policy", {})
719
+ result: dict[str, Any] = {}
720
+ for k, v in policy.items():
721
+ # max_autonomy_level is the policy spelling; maps to autonomy_level
722
+ target = _POLICY_KEY_MAP.get(k) or (k if k in GdmSettings.model_fields else None)
723
+ if target:
724
+ result[target] = v
725
+ return result
726
+ except Exception as exc:
727
+ log.warning("Could not parse team.toml [policy]: %s", exc)
728
+ return {}
729
+
730
+ def _load_env(self) -> dict[str, tuple[Any, str]]:
731
+ """Load GDM_* env vars, returning ``{field_name: (value, env_key)}``."""
732
+ result: dict[str, tuple[Any, str]] = {}
733
+ for env_key, field_name in _ENV_KEY_MAP.items():
734
+ val = os.environ.get(env_key)
735
+ if val is not None:
736
+ result[field_name] = (_coerce_env_value(field_name, val), env_key)
737
+ return result
738
+
739
+ # ── Atomic TOML write ───────────────────────────────────────────────────
740
+
741
+ def _atomic_write(self, path: Path, key: str, value: Any) -> None:
742
+ """Write *key=value* to a TOML file atomically via tempfile+rename.
743
+
744
+ *key* uses dot notation (e.g. ``"models.primary"``). Partial writes
745
+ never corrupt the existing file because the rename is atomic on POSIX
746
+ and best-effort on Windows.
747
+ """
748
+ existing = tomllib.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
749
+ keys = key.split(".")
750
+ node: Any = existing
751
+ for k in keys[:-1]:
752
+ node = node.setdefault(k, {})
753
+ node[keys[-1]] = value
754
+ import tomli_w # type: ignore[import-untyped]
755
+ tmp = path.with_suffix(".tmp")
756
+ try:
757
+ tmp.write_bytes(tomli_w.dumps(existing).encode("utf-8"))
758
+ tmp.replace(path) # atomic on POSIX; best-effort on Windows
759
+ except Exception:
760
+ tmp.unlink(missing_ok=True)
761
+ raise
762
+