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,141 @@
1
+ """Diff summary service for git diff analysis.
2
+
3
+ Uses the REPLY_MODEL to summarize git diffs via the git_diff_agent.md
4
+ prompt template. Summaries are held in-memory only and discarded after
5
+ being injected into artifact content.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+
14
+ from avos_cli.models.diff import DiffResult, DiffStatus
15
+ from avos_cli.utils.logger import get_logger
16
+
17
+ _log = get_logger("diff_summary_service")
18
+
19
+ _AGENTS_DIR = Path(__file__).resolve().parent.parent / "agents"
20
+ _GIT_DIFF_AGENT_PATH = _AGENTS_DIR / "git_diff_agent.md"
21
+
22
+ _MAX_TOKENS = 1500
23
+ _TEMPERATURE = 0.1
24
+ _REQUEST_TIMEOUT = 60.0
25
+
26
+
27
+ class DiffSummaryService:
28
+ """Summarizes git diffs using REPLY_MODEL via git_diff_agent.md prompt.
29
+
30
+ Takes resolved DiffResult objects, sends each diff to the LLM for
31
+ summarization, and returns a mapping of canonical_id to summary string.
32
+ Summaries are in-memory only -- no files written to disk.
33
+
34
+ Args:
35
+ api_key: API key for the reply model.
36
+ api_url: Endpoint URL (OpenAI-compatible chat completions).
37
+ model: Model identifier.
38
+ """
39
+
40
+ def __init__(self, api_key: str, api_url: str, model: str) -> None:
41
+ self._api_key = api_key
42
+ self._api_url = api_url.rstrip("/")
43
+ self._model = model
44
+ self._client = httpx.Client(
45
+ headers={
46
+ "Authorization": f"Bearer {api_key}",
47
+ "Content-Type": "application/json",
48
+ },
49
+ timeout=_REQUEST_TIMEOUT,
50
+ )
51
+ self._prompt_template: str | None = None
52
+
53
+ def summarize_diffs(self, results: list[DiffResult]) -> dict[str, str]:
54
+ """Summarize resolved diffs using the git_diff_agent prompt.
55
+
56
+ Args:
57
+ results: List of DiffResult objects from DiffResolver.
58
+
59
+ Returns:
60
+ Dict mapping canonical_id to summary string for each successfully
61
+ summarized diff. Unresolved, suppressed, or failed diffs are skipped.
62
+ """
63
+ summaries: dict[str, str] = {}
64
+
65
+ for result in results:
66
+ if result.status != DiffStatus.RESOLVED:
67
+ continue
68
+
69
+ if result.diff_text is None or not result.diff_text.strip():
70
+ continue
71
+
72
+ summary = self._summarize_single(result.canonical_id, result.diff_text)
73
+ if summary:
74
+ summaries[result.canonical_id] = summary
75
+
76
+ return summaries
77
+
78
+ def _summarize_single(self, canonical_id: str, diff_text: str) -> str | None:
79
+ """Summarize a single diff via LLM call.
80
+
81
+ Args:
82
+ canonical_id: The canonical ID for logging.
83
+ diff_text: The raw git diff text.
84
+
85
+ Returns:
86
+ Summary string, or None on failure.
87
+ """
88
+ prompt = self._load_prompt(diff_text)
89
+ if not prompt:
90
+ _log.warning("Failed to load git_diff_agent prompt for %s", canonical_id)
91
+ return None
92
+
93
+ try:
94
+ body = {
95
+ "model": self._model,
96
+ "messages": [{"role": "user", "content": prompt}],
97
+ "max_tokens": _MAX_TOKENS,
98
+ "temperature": _TEMPERATURE,
99
+ }
100
+ response = self._client.post(self._api_url, json=body)
101
+ response.raise_for_status()
102
+ data = response.json()
103
+
104
+ choices = data.get("choices", [])
105
+ if not choices:
106
+ _log.warning("Empty choices in response for %s", canonical_id)
107
+ return None
108
+
109
+ content = choices[0].get("message", {}).get("content")
110
+ if not content:
111
+ _log.warning("No content in response for %s", canonical_id)
112
+ return None
113
+
114
+ return str(content).strip()
115
+
116
+ except httpx.HTTPStatusError as e:
117
+ _log.warning("HTTP error summarizing %s: %s", canonical_id, e)
118
+ return None
119
+ except httpx.TimeoutException as e:
120
+ _log.warning("Timeout summarizing %s: %s", canonical_id, e)
121
+ return None
122
+ except Exception as e:
123
+ _log.warning("Error summarizing %s: %s", canonical_id, e)
124
+ return None
125
+
126
+ def _load_prompt(self, diff_text: str) -> str:
127
+ """Load and format the git_diff_agent prompt template.
128
+
129
+ Args:
130
+ diff_text: The raw git diff to insert into the template.
131
+
132
+ Returns:
133
+ Formatted prompt string, or empty string on failure.
134
+ """
135
+ if self._prompt_template is None:
136
+ if not _GIT_DIFF_AGENT_PATH.exists():
137
+ _log.warning("git_diff_agent.md not found at %s", _GIT_DIFF_AGENT_PATH)
138
+ return ""
139
+ self._prompt_template = _GIT_DIFF_AGENT_PATH.read_text(encoding="utf-8")
140
+
141
+ return self._prompt_template.format(git_diff=diff_text)
@@ -0,0 +1,351 @@
1
+ """Local Git operations wrapper using subprocess.
2
+
3
+ Provides commit log, branch detection, diff stats, remote parsing,
4
+ modified file listing, and worktree detection. All commands use fixed
5
+ templates (no shell interpolation) for safety.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from avos_cli.exceptions import (
15
+ DependencyUnavailableError,
16
+ RepositoryContextError,
17
+ ServiceParseError,
18
+ )
19
+ from avos_cli.utils.logger import get_logger
20
+
21
+ _log = get_logger("git_client")
22
+ _TIMEOUT = 30
23
+
24
+
25
+ class GitClient:
26
+ """Wrapper for local Git CLI operations.
27
+
28
+ All methods accept a repo_path and shell out to the git binary.
29
+ Errors are normalized to typed exceptions.
30
+ """
31
+
32
+ def _run_git(self, args: list[str], cwd: Path) -> str:
33
+ """Execute a git command and return stdout.
34
+
35
+ Args:
36
+ args: Git subcommand and arguments (e.g. ['log', '--oneline']).
37
+ cwd: Working directory for the command.
38
+
39
+ Returns:
40
+ Stripped stdout string.
41
+
42
+ Raises:
43
+ DependencyUnavailableError: If git binary is not found.
44
+ RepositoryContextError: If cwd is not a git repo.
45
+ ServiceParseError: If the command fails unexpectedly.
46
+ """
47
+ cmd = ["git", *args]
48
+ try:
49
+ result = subprocess.run(
50
+ cmd,
51
+ cwd=str(cwd),
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=_TIMEOUT,
55
+ )
56
+ except FileNotFoundError as e:
57
+ raise DependencyUnavailableError("git") from e
58
+ except subprocess.TimeoutExpired as e:
59
+ raise ServiceParseError(f"Git command timed out: {' '.join(cmd)}") from e
60
+
61
+ if result.returncode != 0:
62
+ stderr = result.stderr.strip()
63
+ if "not a git repository" in stderr.lower():
64
+ raise RepositoryContextError(f"Not a Git repository: {cwd}")
65
+ if result.returncode != 0 and stderr:
66
+ _log.debug("git stderr: %s", stderr)
67
+ if result.returncode != 0 and not result.stdout.strip():
68
+ return ""
69
+ return result.stdout.rstrip("\n\r")
70
+
71
+ def current_branch(self, repo_path: Path) -> str:
72
+ """Get the current branch name.
73
+
74
+ Args:
75
+ repo_path: Path to the git repository.
76
+
77
+ Returns:
78
+ Branch name string (e.g. 'main', 'feature/foo').
79
+ """
80
+ output = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"], repo_path)
81
+ if not output:
82
+ raise ServiceParseError("Could not determine current branch")
83
+ return output
84
+
85
+ def user_name(self, repo_path: Path) -> str:
86
+ """Get the configured git user.name."""
87
+ return self._run_git(["config", "user.name"], repo_path)
88
+
89
+ def user_email(self, repo_path: Path) -> str:
90
+ """Get the configured git user.email."""
91
+ return self._run_git(["config", "user.email"], repo_path)
92
+
93
+ def commit_log(
94
+ self, repo_path: Path, since_date: str | None = None
95
+ ) -> list[dict[str, str]]:
96
+ """Get commit log as a list of dicts.
97
+
98
+ Args:
99
+ repo_path: Path to the git repository.
100
+ since_date: Optional ISO date string for lower bound filter.
101
+
102
+ Returns:
103
+ List of dicts with keys: hash, message, author, date.
104
+ """
105
+ args = [
106
+ "log",
107
+ "--format=%H|%s|%an|%aI",
108
+ ]
109
+ if since_date:
110
+ args.append(f"--since={since_date}")
111
+
112
+ output = self._run_git(args, repo_path)
113
+ if not output:
114
+ return []
115
+
116
+ commits: list[dict[str, str]] = []
117
+ for line in output.splitlines():
118
+ parts = line.split("|", 3)
119
+ if len(parts) >= 4:
120
+ commits.append({
121
+ "hash": parts[0],
122
+ "message": parts[1],
123
+ "author": parts[2],
124
+ "date": parts[3],
125
+ })
126
+ return commits
127
+
128
+ def commit_log_range(
129
+ self, repo_path: Path, old_sha: str, new_sha: str
130
+ ) -> list[dict[str, str]]:
131
+ """Get commits between two SHAs (exclusive old, inclusive new).
132
+
133
+ Used by hook-sync to get commits being pushed. The range old_sha..new_sha
134
+ returns commits reachable from new_sha but not from old_sha.
135
+
136
+ Args:
137
+ repo_path: Path to the git repository.
138
+ old_sha: Base commit SHA (exclusive). Use empty string for new branches.
139
+ new_sha: Target commit SHA (inclusive).
140
+
141
+ Returns:
142
+ List of dicts with keys: hash, message, author, date.
143
+ Returns empty list if range is invalid or empty.
144
+ """
145
+ if not new_sha:
146
+ return []
147
+
148
+ range_spec = f"{old_sha}..{new_sha}" if old_sha else new_sha
149
+
150
+ args = ["log", "--format=%H|%s|%an|%aI", range_spec]
151
+ output = self._run_git(args, repo_path)
152
+ if not output:
153
+ return []
154
+
155
+ commits: list[dict[str, str]] = []
156
+ for line in output.splitlines():
157
+ parts = line.split("|", 3)
158
+ if len(parts) >= 4:
159
+ commits.append({
160
+ "hash": parts[0],
161
+ "message": parts[1],
162
+ "author": parts[2],
163
+ "date": parts[3],
164
+ })
165
+ return commits
166
+
167
+ def diff_stats(self, repo_path: Path) -> str:
168
+ """Get diff stats for staged changes.
169
+
170
+ Args:
171
+ repo_path: Path to the git repository.
172
+
173
+ Returns:
174
+ Diff stat summary string, or empty string if clean.
175
+ """
176
+ return self._run_git(["diff", "--cached", "--stat"], repo_path)
177
+
178
+ def modified_files(self, repo_path: Path) -> list[str]:
179
+ """List files modified or untracked in the working directory.
180
+
181
+ Args:
182
+ repo_path: Path to the git repository.
183
+
184
+ Returns:
185
+ List of relative file paths.
186
+ """
187
+ output = self._run_git(
188
+ ["status", "--porcelain", "--untracked-files=all"],
189
+ repo_path,
190
+ )
191
+ if not output:
192
+ return []
193
+
194
+ files: list[str] = []
195
+ for line in output.splitlines():
196
+ # porcelain format: "XY filename" where XY is 2 status chars + 1 space
197
+ if len(line) >= 4:
198
+ path = line[3:]
199
+ if " -> " in path:
200
+ path = path.split(" -> ", 1)[1]
201
+ files.append(path)
202
+ return files
203
+
204
+ def remote_origin(self, repo_path: Path) -> str | None:
205
+ """Extract org/repo from the origin remote URL.
206
+
207
+ Handles both HTTPS and SSH remote formats.
208
+
209
+ Args:
210
+ repo_path: Path to the git repository.
211
+
212
+ Returns:
213
+ 'org/repo' string, or None if no origin remote.
214
+ """
215
+ output = self._run_git(["remote", "get-url", "origin"], repo_path)
216
+ if not output:
217
+ return None
218
+ return _parse_remote_url(output)
219
+
220
+ def worktree_add(self, repo_path: Path, path: Path, branch: str) -> Path:
221
+ """Create a new git worktree with a new branch.
222
+
223
+ Runs `git worktree add -b <branch> <path>`. The branch is created
224
+ from the current HEAD of repo_path.
225
+
226
+ Args:
227
+ repo_path: Path to the source git repository.
228
+ path: Filesystem path for the new worktree.
229
+ branch: Name of the new branch to create.
230
+
231
+ Returns:
232
+ Resolved Path to the created worktree directory.
233
+
234
+ Raises:
235
+ ServiceParseError: If git worktree add fails (path exists,
236
+ branch already checked out, etc.).
237
+ RepositoryContextError: If repo_path is not a git repo.
238
+ """
239
+ resolved = path.resolve()
240
+ result = subprocess.run(
241
+ ["git", "worktree", "add", "-b", branch, str(resolved)],
242
+ cwd=str(repo_path),
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=_TIMEOUT,
246
+ )
247
+ if result.returncode != 0:
248
+ stderr = result.stderr.strip()
249
+ if "not a git repository" in stderr.lower():
250
+ raise RepositoryContextError(f"Not a Git repository: {repo_path}")
251
+ raise ServiceParseError(
252
+ f"git worktree add failed: {stderr or 'unknown error'}"
253
+ )
254
+ return resolved
255
+
256
+ def worktree_list(self, repo_path: Path) -> list[Path]:
257
+ """List all worktree paths for the repository.
258
+
259
+ Parses `git worktree list --porcelain` output where each worktree
260
+ block starts with a `worktree <path>` line.
261
+
262
+ Args:
263
+ repo_path: Path to any worktree or the main repo.
264
+
265
+ Returns:
266
+ List of resolved Paths for all worktrees (including main).
267
+ """
268
+ output = self._run_git(["worktree", "list", "--porcelain"], repo_path)
269
+ paths: list[Path] = []
270
+ for line in output.splitlines():
271
+ if line.startswith("worktree "):
272
+ wt_path = line[len("worktree "):]
273
+ paths.append(Path(wt_path).resolve())
274
+ return paths
275
+
276
+ def is_worktree(self, repo_path: Path) -> bool:
277
+ """Check if the repo path is a git worktree (not the main repo).
278
+
279
+ Args:
280
+ repo_path: Path to check.
281
+
282
+ Returns:
283
+ True if the path is a worktree.
284
+ """
285
+ git_path = repo_path / ".git"
286
+ return git_path.is_file()
287
+
288
+ def commit_patch(self, repo_path: Path, sha: str) -> str:
289
+ """Get the unified diff patch for a single commit.
290
+
291
+ Uses `git show --format= <sha>` to get the patch. For merge commits,
292
+ uses first-parent diff (--first-parent) to avoid combined diff noise.
293
+
294
+ Args:
295
+ repo_path: Path to the git repository.
296
+ sha: Commit SHA (short or full).
297
+
298
+ Returns:
299
+ Unified diff text, or empty string if commit not found.
300
+ """
301
+ args = ["show", "--format=", "--first-parent", "-p", sha]
302
+ return self._run_git(args, repo_path)
303
+
304
+ def expand_short_sha(self, repo_path: Path, short_sha: str) -> str | None:
305
+ """Expand a short SHA to the full 40-character SHA.
306
+
307
+ Uses `git rev-parse <sha>` to resolve the full SHA.
308
+
309
+ Args:
310
+ repo_path: Path to the git repository.
311
+ short_sha: Short or full commit SHA.
312
+
313
+ Returns:
314
+ Full 40-character SHA, or None if not found or ambiguous.
315
+
316
+ Raises:
317
+ RepositoryContextError: If repo_path is not a git repo.
318
+ """
319
+ output = self._run_git(["rev-parse", short_sha], repo_path)
320
+ if not output or len(output) != 40:
321
+ return None
322
+ if not all(c in "0123456789abcdef" for c in output.lower()):
323
+ return None
324
+ return output
325
+
326
+
327
+ def _parse_remote_url(url: str) -> str | None:
328
+ """Extract org/repo from a git remote URL.
329
+
330
+ Supports:
331
+ - https://github.com/org/repo.git
332
+ - git@github.com:org/repo.git
333
+ - https://github.com/org/repo
334
+
335
+ Args:
336
+ url: Remote URL string.
337
+
338
+ Returns:
339
+ 'org/repo' string or None if unparseable.
340
+ """
341
+ # SSH format: git@github.com:org/repo.git
342
+ ssh_match = re.match(r"git@[^:]+:(.+?)(?:\.git)?$", url)
343
+ if ssh_match:
344
+ return ssh_match.group(1)
345
+
346
+ # HTTPS format: https://github.com/org/repo.git
347
+ https_match = re.match(r"https?://[^/]+/(.+?)(?:\.git)?$", url)
348
+ if https_match:
349
+ return https_match.group(1)
350
+
351
+ return None