git-aware-coding-agent 1.0.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 (62) hide show
  1. avos_cli/__init__.py +3 -0
  2. avos_cli/agents/avos_ask_agent.md +47 -0
  3. avos_cli/agents/avos_ask_agent_JSON_converter.md +78 -0
  4. avos_cli/agents/avos_hisotry_agent_JSON_converter.md +92 -0
  5. avos_cli/agents/avos_history_agent.md +58 -0
  6. avos_cli/agents/git_diff_agent.md +63 -0
  7. avos_cli/artifacts/__init__.py +17 -0
  8. avos_cli/artifacts/base.py +47 -0
  9. avos_cli/artifacts/commit_builder.py +35 -0
  10. avos_cli/artifacts/doc_builder.py +30 -0
  11. avos_cli/artifacts/issue_builder.py +37 -0
  12. avos_cli/artifacts/pr_builder.py +50 -0
  13. avos_cli/cli/__init__.py +1 -0
  14. avos_cli/cli/main.py +504 -0
  15. avos_cli/commands/__init__.py +1 -0
  16. avos_cli/commands/ask.py +541 -0
  17. avos_cli/commands/connect.py +363 -0
  18. avos_cli/commands/history.py +549 -0
  19. avos_cli/commands/hook_install.py +260 -0
  20. avos_cli/commands/hook_sync.py +231 -0
  21. avos_cli/commands/ingest.py +506 -0
  22. avos_cli/commands/ingest_pr.py +239 -0
  23. avos_cli/config/__init__.py +1 -0
  24. avos_cli/config/hash_store.py +93 -0
  25. avos_cli/config/lock.py +122 -0
  26. avos_cli/config/manager.py +180 -0
  27. avos_cli/config/state.py +90 -0
  28. avos_cli/exceptions.py +272 -0
  29. avos_cli/models/__init__.py +58 -0
  30. avos_cli/models/api.py +75 -0
  31. avos_cli/models/artifacts.py +99 -0
  32. avos_cli/models/config.py +56 -0
  33. avos_cli/models/diff.py +117 -0
  34. avos_cli/models/query.py +234 -0
  35. avos_cli/parsers/__init__.py +21 -0
  36. avos_cli/parsers/artifact_ref_extractor.py +173 -0
  37. avos_cli/parsers/reference_parser.py +117 -0
  38. avos_cli/services/__init__.py +1 -0
  39. avos_cli/services/chronology_service.py +68 -0
  40. avos_cli/services/citation_validator.py +134 -0
  41. avos_cli/services/context_budget_service.py +104 -0
  42. avos_cli/services/diff_resolver.py +398 -0
  43. avos_cli/services/diff_summary_service.py +141 -0
  44. avos_cli/services/git_client.py +351 -0
  45. avos_cli/services/github_client.py +443 -0
  46. avos_cli/services/llm_client.py +312 -0
  47. avos_cli/services/memory_client.py +323 -0
  48. avos_cli/services/query_fallback_formatter.py +108 -0
  49. avos_cli/services/reply_output_service.py +341 -0
  50. avos_cli/services/sanitization_service.py +218 -0
  51. avos_cli/utils/__init__.py +1 -0
  52. avos_cli/utils/dotenv_load.py +50 -0
  53. avos_cli/utils/hashing.py +22 -0
  54. avos_cli/utils/logger.py +77 -0
  55. avos_cli/utils/output.py +232 -0
  56. avos_cli/utils/sanitization_diagnostics.py +81 -0
  57. avos_cli/utils/time_helpers.py +56 -0
  58. git_aware_coding_agent-1.0.0.dist-info/METADATA +390 -0
  59. git_aware_coding_agent-1.0.0.dist-info/RECORD +62 -0
  60. git_aware_coding_agent-1.0.0.dist-info/WHEEL +4 -0
  61. git_aware_coding_agent-1.0.0.dist-info/entry_points.txt +2 -0
  62. git_aware_coding_agent-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,239 @@
1
+ """Single-PR ingest command orchestrator.
2
+
3
+ Implements `avos ingest-pr org/repo PR_NUMBER` to ingest a specific PR
4
+ after it has been pushed/merged. Reuses the existing PR artifact builder
5
+ and hash store for deduplication.
6
+
7
+ Exit codes:
8
+ 0: success (stored or skipped)
9
+ 1: precondition failure (config missing, invalid args)
10
+ 2: hard external failure (GitHub API, Memory API)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from avos_cli.artifacts.pr_builder import PRThreadBuilder
19
+ from avos_cli.config.hash_store import IngestHashStore
20
+ from avos_cli.config.manager import load_config
21
+ from avos_cli.exceptions import AvosError, ConfigurationNotInitializedError
22
+ from avos_cli.models.artifacts import PRArtifact
23
+ from avos_cli.services.github_client import GitHubClient
24
+ from avos_cli.services.memory_client import AvosMemoryClient
25
+ from avos_cli.utils.logger import get_logger
26
+ from avos_cli.utils.output import (
27
+ print_error,
28
+ print_info,
29
+ print_json,
30
+ render_kv_panel,
31
+ )
32
+
33
+ _log = get_logger("commands.ingest_pr")
34
+
35
+
36
+ class IngestPROrchestrator:
37
+ """Orchestrates the `avos ingest-pr` command.
38
+
39
+ Fetches a single PR by number, builds its artifact, checks for
40
+ duplicates via content hash, and stores in Avos Memory.
41
+
42
+ Args:
43
+ memory_client: Avos Memory API client.
44
+ github_client: GitHub REST API client.
45
+ hash_store: Content hash store for deduplication.
46
+ repo_root: Path to the repository root.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ memory_client: AvosMemoryClient,
52
+ github_client: GitHubClient,
53
+ hash_store: IngestHashStore,
54
+ repo_root: Path,
55
+ ) -> None:
56
+ self._memory = memory_client
57
+ self._github = github_client
58
+ self._hash_store = hash_store
59
+ self._repo_root = repo_root
60
+ self._pr_builder = PRThreadBuilder()
61
+
62
+ def run(
63
+ self, repo_slug: str, pr_number: int, json_output: bool = False
64
+ ) -> int:
65
+ """Execute the single-PR ingest flow.
66
+
67
+ Args:
68
+ repo_slug: Repository identifier in 'org/repo' format.
69
+ pr_number: PR number to ingest.
70
+ json_output: If True, emit JSON output instead of human UI.
71
+
72
+ Returns:
73
+ Exit code: 0 success, 1 precondition, 2 hard error.
74
+ """
75
+ if not self._validate_slug(repo_slug):
76
+ if json_output:
77
+ print_json(
78
+ success=False,
79
+ data=None,
80
+ error={
81
+ "code": "REPOSITORY_CONTEXT_ERROR",
82
+ "message": "Invalid repo slug. Expected 'org/repo'.",
83
+ "hint": None,
84
+ "retryable": False,
85
+ },
86
+ )
87
+ else:
88
+ print_error("[REPOSITORY_CONTEXT_ERROR] Invalid repo slug. Expected 'org/repo'.")
89
+ return 1
90
+
91
+ owner, repo = repo_slug.split("/", 1)
92
+
93
+ try:
94
+ config = load_config(self._repo_root)
95
+ except ConfigurationNotInitializedError as e:
96
+ if json_output:
97
+ print_json(
98
+ success=False,
99
+ data=None,
100
+ error={
101
+ "code": "CONFIG_NOT_INITIALIZED",
102
+ "message": str(e),
103
+ "hint": "Run 'avos connect org/repo' first.",
104
+ "retryable": False,
105
+ },
106
+ )
107
+ else:
108
+ print_error(f"[CONFIG_NOT_INITIALIZED] {e}")
109
+ return 1
110
+ except AvosError as e:
111
+ if json_output:
112
+ print_json(
113
+ success=False,
114
+ data=None,
115
+ error={
116
+ "code": e.code,
117
+ "message": str(e),
118
+ "hint": getattr(e, "hint", None),
119
+ "retryable": getattr(e, "retryable", False),
120
+ },
121
+ )
122
+ else:
123
+ print_error(f"[{e.code}] {e}")
124
+ return 1
125
+
126
+ memory_id = config.memory_id
127
+
128
+ try:
129
+ pr_detail = self._github.get_pr_details(owner, repo, pr_number)
130
+ except AvosError as e:
131
+ if json_output:
132
+ print_json(
133
+ success=False,
134
+ data=None,
135
+ error={
136
+ "code": e.code,
137
+ "message": f"Failed to fetch PR #{pr_number}: {e}",
138
+ "hint": getattr(e, "hint", None),
139
+ "retryable": getattr(e, "retryable", True),
140
+ },
141
+ )
142
+ else:
143
+ print_error(f"[{e.code}] Failed to fetch PR #{pr_number}: {e}")
144
+ return 2
145
+
146
+ artifact = self._build_pr_artifact(repo, owner, pr_detail)
147
+ text = self._pr_builder.build(artifact)
148
+ content_hash = self._pr_builder.content_hash(artifact)
149
+
150
+ if self._hash_store.contains(content_hash):
151
+ result = {
152
+ "pr_number": pr_number,
153
+ "action": "skipped",
154
+ "note_id": None,
155
+ "reason": "already_ingested",
156
+ }
157
+ if json_output:
158
+ print_json(success=True, data=result, error=None)
159
+ else:
160
+ print_info(f"PR #{pr_number} already ingested. Skipping.")
161
+ return 0
162
+
163
+ try:
164
+ note_response = self._memory.add_memory(memory_id=memory_id, content=text)
165
+ note_id = note_response.note_id
166
+ except AvosError as e:
167
+ if json_output:
168
+ print_json(
169
+ success=False,
170
+ data=None,
171
+ error={
172
+ "code": e.code,
173
+ "message": f"Failed to store PR #{pr_number}: {e}",
174
+ "hint": getattr(e, "hint", None),
175
+ "retryable": getattr(e, "retryable", True),
176
+ },
177
+ )
178
+ else:
179
+ print_error(f"[{e.code}] Failed to store PR #{pr_number}: {e}")
180
+ return 2
181
+
182
+ self._hash_store.add(content_hash, "pr", str(pr_number))
183
+ self._hash_store.save()
184
+
185
+ result = {
186
+ "pr_number": pr_number,
187
+ "action": "stored",
188
+ "note_id": note_id,
189
+ "reason": None,
190
+ }
191
+
192
+ if json_output:
193
+ print_json(success=True, data=result, error=None)
194
+ else:
195
+ render_kv_panel(
196
+ f"PR #{pr_number} Ingested",
197
+ [
198
+ ("Title", artifact.title[:60] + "..." if len(artifact.title) > 60 else artifact.title),
199
+ ("Author", artifact.author),
200
+ ("Files", str(len(artifact.files))),
201
+ ("Note ID", note_id[:12] + "..." if len(note_id) > 12 else note_id),
202
+ ],
203
+ style="success",
204
+ )
205
+
206
+ return 0
207
+
208
+ def _build_pr_artifact(
209
+ self, repo: str, owner: str, pr_detail: dict[str, Any]
210
+ ) -> PRArtifact:
211
+ """Transform GitHub PR detail dict into a PRArtifact."""
212
+ files = [f["filename"] for f in pr_detail.get("files", [])]
213
+ comments = pr_detail.get("comments", [])
214
+ reviews = pr_detail.get("reviews", [])
215
+ discussion_parts: list[str] = []
216
+ for c in comments:
217
+ user = c.get("user", {}).get("login", "unknown")
218
+ discussion_parts.append(f"{user}: {c.get('body', '')}")
219
+ for r in reviews:
220
+ user = r.get("user", {}).get("login", "unknown")
221
+ discussion_parts.append(f"{user} ({r.get('state', '')}): {r.get('body', '')}")
222
+
223
+ return PRArtifact(
224
+ repo=f"{owner}/{repo}",
225
+ pr_number=pr_detail["number"],
226
+ title=pr_detail.get("title", ""),
227
+ author=pr_detail.get("user", {}).get("login", "unknown"),
228
+ merged_date=pr_detail.get("merged_at"),
229
+ files=files,
230
+ description=pr_detail.get("body"),
231
+ discussion="\n".join(discussion_parts) if discussion_parts else None,
232
+ )
233
+
234
+ @staticmethod
235
+ def _validate_slug(slug: str) -> bool:
236
+ if not slug or "/" not in slug:
237
+ return False
238
+ parts = slug.split("/", 1)
239
+ return bool(parts[0]) and bool(parts[1])
@@ -0,0 +1 @@
1
+ """Configuration management for AVOS CLI."""
@@ -0,0 +1,93 @@
1
+ """Ingest hash store for artifact deduplication.
2
+
3
+ Manages .avos/ingest_hashes.json which maps content hashes to metadata.
4
+ Used by the ingest command to skip already-stored artifacts on re-runs.
5
+ Each entry stores artifact_type, source_id, and stored_at timestamp.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from avos_cli.config.state import atomic_write
16
+ from avos_cli.utils.logger import get_logger
17
+
18
+ _log = get_logger("config.hash_store")
19
+
20
+ _HASH_STORE_FILENAME = "ingest_hashes.json"
21
+
22
+
23
+ class IngestHashStore:
24
+ """Manages the content hash store for ingest deduplication.
25
+
26
+ Args:
27
+ avos_dir: Path to the .avos directory.
28
+ """
29
+
30
+ def __init__(self, avos_dir: Path) -> None:
31
+ self._path = avos_dir / _HASH_STORE_FILENAME
32
+ self._store: dict[str, dict[str, str]] = {}
33
+
34
+ def load(self) -> None:
35
+ """Load the hash store from disk. Quarantines corrupt files."""
36
+ if not self._path.exists():
37
+ self._store = {}
38
+ return
39
+
40
+ try:
41
+ content = self._path.read_text(encoding="utf-8")
42
+ if not content.strip():
43
+ self._store = {}
44
+ return
45
+ data = json.loads(content)
46
+ if not isinstance(data, dict):
47
+ _log.warning("Hash store is not a dict, treating as corrupt")
48
+ self._quarantine()
49
+ self._store = {}
50
+ return
51
+ self._store = data
52
+ except (json.JSONDecodeError, UnicodeDecodeError):
53
+ _log.warning("Corrupt hash store, quarantining: %s", self._path)
54
+ self._quarantine()
55
+ self._store = {}
56
+
57
+ def save(self) -> None:
58
+ """Persist the hash store to disk atomically."""
59
+ content = json.dumps(self._store, indent=2, sort_keys=True)
60
+ self._path.parent.mkdir(parents=True, exist_ok=True)
61
+ atomic_write(self._path, content)
62
+
63
+ def contains(self, content_hash: str) -> bool:
64
+ """Check if a content hash is already stored."""
65
+ return content_hash in self._store
66
+
67
+ def add(self, content_hash: str, artifact_type: str, source_id: str) -> None:
68
+ """Add a content hash with metadata. Idempotent for duplicates."""
69
+ if content_hash in self._store:
70
+ return
71
+ self._store[content_hash] = {
72
+ "artifact_type": artifact_type,
73
+ "source_id": source_id,
74
+ "stored_at": datetime.now(tz=timezone.utc).isoformat(),
75
+ }
76
+
77
+ def get_entry(self, content_hash: str) -> dict[str, str] | None:
78
+ """Get the metadata entry for a hash, or None if not found."""
79
+ return self._store.get(content_hash)
80
+
81
+ def count(self) -> int:
82
+ """Return the number of stored hashes."""
83
+ return len(self._store)
84
+
85
+ def _quarantine(self) -> None:
86
+ """Move a corrupt hash store file to a .corrupt backup."""
87
+ ts = int(time.time())
88
+ corrupt_path = self._path.with_suffix(f"{self._path.suffix}.corrupt.{ts}")
89
+ try:
90
+ self._path.rename(corrupt_path)
91
+ _log.warning("Quarantined: %s -> %s", self._path, corrupt_path)
92
+ except OSError as e:
93
+ _log.error("Failed to quarantine %s: %s", self._path, e)
@@ -0,0 +1,122 @@
1
+ """Ingest lock manager for preventing concurrent ingest runs.
2
+
3
+ Uses a JSON lock file (.avos/ingest.lock) containing PID and timestamp.
4
+ Supports stale-lock detection via PID liveness check and configurable
5
+ time threshold (default 1 hour).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from avos_cli.exceptions import IngestLockError
16
+ from avos_cli.utils.logger import get_logger
17
+
18
+ _log = get_logger("config.lock")
19
+
20
+ _LOCK_FILENAME = "ingest.lock"
21
+ _DEFAULT_STALE_THRESHOLD = 3600 # 1 hour
22
+
23
+
24
+ class IngestLockManager:
25
+ """Manages the ingest lock file for single-process exclusion.
26
+
27
+ Args:
28
+ avos_dir: Path to the .avos directory.
29
+ stale_threshold_seconds: Seconds after which a lock with a dead PID
30
+ is considered stale and can be broken.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ avos_dir: Path,
36
+ stale_threshold_seconds: int = _DEFAULT_STALE_THRESHOLD,
37
+ ) -> None:
38
+ self._lock_path = avos_dir / _LOCK_FILENAME
39
+ self._stale_threshold = stale_threshold_seconds
40
+
41
+ def acquire(self) -> None:
42
+ """Acquire the ingest lock.
43
+
44
+ Raises:
45
+ IngestLockError: If another live process holds the lock.
46
+ """
47
+ if self._lock_path.exists():
48
+ existing = self._read_lock()
49
+ if existing is None:
50
+ _log.warning("Removing corrupt lock file: %s", self._lock_path)
51
+ self._lock_path.unlink(missing_ok=True)
52
+ elif self._is_stale(existing):
53
+ _log.warning(
54
+ "Breaking stale lock (pid=%d, age=%.0fs)",
55
+ int(existing["pid"]),
56
+ time.time() - float(existing["acquired_at"]),
57
+ )
58
+ self._lock_path.unlink(missing_ok=True)
59
+ else:
60
+ pid = int(existing["pid"])
61
+ raise IngestLockError(
62
+ f"Ingest lock held by pid {pid}",
63
+ holder_pid=pid,
64
+ )
65
+
66
+ self._write_lock()
67
+
68
+ def release(self) -> None:
69
+ """Release the ingest lock. Safe to call even if not held."""
70
+ self._lock_path.unlink(missing_ok=True)
71
+
72
+ def is_locked(self) -> bool:
73
+ """Check whether the lock file exists."""
74
+ return self._lock_path.exists()
75
+
76
+ def __enter__(self) -> IngestLockManager:
77
+ self.acquire()
78
+ return self
79
+
80
+ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
81
+ self.release()
82
+
83
+ def _write_lock(self) -> None:
84
+ """Write the lock file with current PID and timestamp."""
85
+ data = {"pid": os.getpid(), "acquired_at": time.time()}
86
+ self._lock_path.write_text(json.dumps(data), encoding="utf-8")
87
+
88
+ def _read_lock(self) -> dict[str, int | float] | None:
89
+ """Read and parse the lock file. Returns None if corrupt."""
90
+ try:
91
+ content = self._lock_path.read_text(encoding="utf-8")
92
+ if not content.strip():
93
+ return None
94
+ raw = json.loads(content)
95
+ if not isinstance(raw, dict):
96
+ return None
97
+ if "pid" not in raw or "acquired_at" not in raw:
98
+ return None
99
+ return {"pid": int(raw["pid"]), "acquired_at": float(raw["acquired_at"])}
100
+ except (json.JSONDecodeError, OSError, ValueError, TypeError):
101
+ return None
102
+
103
+ def _is_stale(self, lock_data: dict[str, int | float]) -> bool:
104
+ """Determine if a lock is stale (old AND holder PID is dead).
105
+
106
+ A lock is stale only when BOTH conditions are met:
107
+ 1. The lock age exceeds the stale threshold.
108
+ 2. The holder PID is no longer alive.
109
+ """
110
+ age = time.time() - float(lock_data["acquired_at"])
111
+ if age < self._stale_threshold:
112
+ return False
113
+ return not self._pid_alive(int(lock_data["pid"]))
114
+
115
+ @staticmethod
116
+ def _pid_alive(pid: int) -> bool:
117
+ """Check if a process with the given PID is alive."""
118
+ try:
119
+ os.kill(pid, 0)
120
+ return True
121
+ except (OSError, ProcessLookupError):
122
+ return False
@@ -0,0 +1,180 @@
1
+ """Configuration manager for AVOS CLI.
2
+
3
+ Handles repo root detection, config load/save with environment variable
4
+ overlay, and .avos directory management. Config resolution priority:
5
+ env vars > config file > defaults.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pydantic import ValidationError
16
+
17
+ from avos_cli.config.state import atomic_write, read_json_safe
18
+ from avos_cli.exceptions import (
19
+ ConfigurationNotInitializedError,
20
+ ConfigurationValidationError,
21
+ RepositoryContextError,
22
+ )
23
+ from avos_cli.models.config import RepoConfig
24
+
25
+ _CONFIG_FILENAME = "config.json"
26
+ _AVOS_DIR = ".avos"
27
+
28
+
29
+ def find_repo_root(start: Path) -> Path:
30
+ """Walk up from start directory to find the Git repository root.
31
+
32
+ Detects both standard repos (.git directory) and worktrees (.git file).
33
+
34
+ Args:
35
+ start: Directory to start searching from.
36
+
37
+ Returns:
38
+ Path to the repository root.
39
+
40
+ Raises:
41
+ RepositoryContextError: If no .git is found.
42
+ """
43
+ current = start.resolve()
44
+ while True:
45
+ git_path = current / ".git"
46
+ if git_path.exists():
47
+ return current
48
+ parent = current.parent
49
+ if parent == current:
50
+ break
51
+ current = parent
52
+ raise RepositoryContextError(f"No Git repository found at or above: {start}")
53
+
54
+
55
+ def load_config(repo_root: Path) -> RepoConfig:
56
+ """Load repository configuration from .avos/config.json with env overlay.
57
+
58
+ Resolution priority: env vars > config file values > defaults.
59
+
60
+ Args:
61
+ repo_root: Path to the repository root (must contain .avos/).
62
+
63
+ Returns:
64
+ Validated RepoConfig instance.
65
+
66
+ Raises:
67
+ ConfigurationNotInitializedError: If .avos/config.json doesn't exist.
68
+ ConfigurationValidationError: If config is malformed or invalid.
69
+ """
70
+ config_path = repo_root / _AVOS_DIR / _CONFIG_FILENAME
71
+
72
+ if not config_path.exists():
73
+ raise ConfigurationNotInitializedError()
74
+
75
+ raw_data = read_json_safe(config_path)
76
+ if raw_data is None:
77
+ raise ConfigurationValidationError(
78
+ f"Config file is corrupt or unreadable: {config_path}"
79
+ )
80
+
81
+ data: dict[str, Any] = dict(raw_data)
82
+ _apply_env_overlay(data)
83
+
84
+ try:
85
+ return RepoConfig(**data)
86
+ except ValidationError as e:
87
+ raise ConfigurationValidationError(
88
+ f"Invalid configuration in {config_path}: {e}"
89
+ ) from e
90
+
91
+
92
+ def connected_repo_slug(repo_root: Path) -> str | None:
93
+ """Return the repository slug persisted by ``avos connect`` (authoritative context).
94
+
95
+ After a successful connect, ``repo`` in ``.avos/config.json`` is the
96
+ canonical ``org/repo`` for this working copy. Pass it as ``default_repo``
97
+ to :class:`~avos_cli.parsers.reference_parser.ReferenceParser` when the
98
+ user omits owner/repo (e.g. ``PR #1245``, ``Commit 8c3a1b2``).
99
+
100
+ Args:
101
+ repo_root: Git repository root containing ``.avos/config.json``.
102
+
103
+ Returns:
104
+ Connected slug, or ``None`` if the project was never connected.
105
+
106
+ Raises:
107
+ ConfigurationValidationError: If the config file exists but is invalid.
108
+ """
109
+ try:
110
+ return load_config(repo_root).repo
111
+ except ConfigurationNotInitializedError:
112
+ return None
113
+
114
+
115
+ def _apply_env_overlay(data: dict[str, Any]) -> None:
116
+ """Apply environment variable overrides to config data dict.
117
+
118
+ Env vars take precedence over file values. Supports:
119
+ AVOS_API_KEY, AVOS_API_URL, GITHUB_TOKEN, AVOS_DEVELOPER,
120
+ AVOS_LLM_PROVIDER, AVOS_LLM_MODEL.
121
+ """
122
+ env_map = {
123
+ "AVOS_API_KEY": "api_key",
124
+ "AVOS_API_URL": "api_url",
125
+ "GITHUB_TOKEN": "github_token",
126
+ "AVOS_DEVELOPER": "developer",
127
+ }
128
+ for env_var, config_key in env_map.items():
129
+ value = os.environ.get(env_var)
130
+ if value is not None:
131
+ data[config_key] = value
132
+
133
+ llm_data = data.get("llm", {})
134
+ if not isinstance(llm_data, dict):
135
+ llm_data = {}
136
+ llm_provider_env = os.environ.get("AVOS_LLM_PROVIDER")
137
+ llm_model_env = os.environ.get("AVOS_LLM_MODEL")
138
+ if llm_provider_env:
139
+ llm_data["provider"] = llm_provider_env
140
+ if llm_model_env:
141
+ llm_data["model"] = llm_model_env
142
+
143
+ # Align model with provider when the user did not set AVOS_LLM_MODEL.
144
+ # Defaults: openai -> gpt-4o, anthropic -> claude-sonnet-4-5-20250929.
145
+ provider = (llm_data.get("provider") or "openai").lower()
146
+ if not llm_model_env:
147
+ model = llm_data.get("model")
148
+ if provider == "openai":
149
+ if model is None or (isinstance(model, str) and model.startswith("claude-")):
150
+ llm_data["model"] = "gpt-4o"
151
+ elif provider == "anthropic" and (
152
+ model is None
153
+ or (
154
+ isinstance(model, str)
155
+ and (
156
+ model.startswith("gpt-")
157
+ or model.startswith("o1")
158
+ or model.startswith("o3")
159
+ )
160
+ )
161
+ ):
162
+ llm_data["model"] = "claude-sonnet-4-5-20250929"
163
+ data["llm"] = llm_data
164
+
165
+
166
+ def save_config(repo_root: Path, config_data: dict[str, Any]) -> None:
167
+ """Save configuration data to .avos/config.json atomically.
168
+
169
+ Creates the .avos directory if it doesn't exist. Uses atomic
170
+ write with restrictive permissions (0o600).
171
+
172
+ Args:
173
+ repo_root: Path to the repository root.
174
+ config_data: Configuration dictionary to persist.
175
+ """
176
+ avos_dir = repo_root / _AVOS_DIR
177
+ avos_dir.mkdir(parents=True, exist_ok=True)
178
+ config_path = avos_dir / _CONFIG_FILENAME
179
+ content = json.dumps(config_data, indent=2, sort_keys=True)
180
+ atomic_write(config_path, content, permissions=0o600)