ai-dev-cli-tools 0.5.0a1__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 (60) hide show
  1. ai_dev_cli_tools-0.5.0a1.dist-info/METADATA +240 -0
  2. ai_dev_cli_tools-0.5.0a1.dist-info/RECORD +60 -0
  3. ai_dev_cli_tools-0.5.0a1.dist-info/WHEEL +4 -0
  4. ai_dev_cli_tools-0.5.0a1.dist-info/entry_points.txt +2 -0
  5. ai_dev_cli_tools-0.5.0a1.dist-info/licenses/LICENSE +21 -0
  6. ai_dev_tools/__init__.py +3 -0
  7. ai_dev_tools/cache/__init__.py +11 -0
  8. ai_dev_tools/cache/graph.py +136 -0
  9. ai_dev_tools/cache/repository.py +169 -0
  10. ai_dev_tools/cache/validation.py +154 -0
  11. ai_dev_tools/cli.py +387 -0
  12. ai_dev_tools/completion.py +72 -0
  13. ai_dev_tools/config.py +223 -0
  14. ai_dev_tools/context/__init__.py +5 -0
  15. ai_dev_tools/context/builder.py +506 -0
  16. ai_dev_tools/context/incremental.py +107 -0
  17. ai_dev_tools/context/models.py +59 -0
  18. ai_dev_tools/context/profiles.py +49 -0
  19. ai_dev_tools/context/selection.py +270 -0
  20. ai_dev_tools/context/symbols.py +178 -0
  21. ai_dev_tools/detectors/__init__.py +1 -0
  22. ai_dev_tools/detectors/environment.py +125 -0
  23. ai_dev_tools/detectors/project.py +189 -0
  24. ai_dev_tools/detectors/repository_map.py +129 -0
  25. ai_dev_tools/detectors/runtime.py +190 -0
  26. ai_dev_tools/detectors/workspaces.py +228 -0
  27. ai_dev_tools/git/__init__.py +1 -0
  28. ai_dev_tools/git/inspect.py +219 -0
  29. ai_dev_tools/models/__init__.py +1 -0
  30. ai_dev_tools/models/report.py +95 -0
  31. ai_dev_tools/models/workspace.py +48 -0
  32. ai_dev_tools/parsers/__init__.py +1 -0
  33. ai_dev_tools/parsers/logs.py +372 -0
  34. ai_dev_tools/parsers/registry.py +60 -0
  35. ai_dev_tools/reporters/__init__.py +1 -0
  36. ai_dev_tools/reporters/progressive.py +161 -0
  37. ai_dev_tools/reporters/writer.py +74 -0
  38. ai_dev_tools/runners/__init__.py +1 -0
  39. ai_dev_tools/runners/baseline.py +190 -0
  40. ai_dev_tools/runners/bootstrap.py +191 -0
  41. ai_dev_tools/runners/bootstrap_models.py +64 -0
  42. ai_dev_tools/runners/bootstrap_strategies.py +444 -0
  43. ai_dev_tools/runners/cache.py +23 -0
  44. ai_dev_tools/runners/check.py +509 -0
  45. ai_dev_tools/runners/check_checkpoint.py +50 -0
  46. ai_dev_tools/runners/check_models.py +51 -0
  47. ai_dev_tools/runners/check_scheduler.py +94 -0
  48. ai_dev_tools/runners/check_selection.py +267 -0
  49. ai_dev_tools/runners/diagnostics.py +96 -0
  50. ai_dev_tools/runners/feedback.py +193 -0
  51. ai_dev_tools/runners/finish.py +105 -0
  52. ai_dev_tools/runners/focused.py +37 -0
  53. ai_dev_tools/runners/index.py +44 -0
  54. ai_dev_tools/runtime/__init__.py +3 -0
  55. ai_dev_tools/runtime/runner.py +380 -0
  56. ai_dev_tools/runtime/supervisor.py +145 -0
  57. ai_dev_tools/security/__init__.py +1 -0
  58. ai_dev_tools/security/secrets.py +58 -0
  59. ai_dev_tools/utils/__init__.py +1 -0
  60. ai_dev_tools/utils/subprocess.py +74 -0
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from dataclasses import dataclass
6
+ from time import monotonic
7
+
8
+ from ai_dev_tools.runners.check_models import CheckTask
9
+ from ai_dev_tools.utils.subprocess import CommandResult
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ScheduleResult:
14
+ tasks: list[CheckTask]
15
+ results: list[CommandResult]
16
+ cancelled: list[CheckTask]
17
+ wall_seconds: float
18
+ aggregate_seconds: float
19
+ time_to_first_failure_seconds: float | None
20
+
21
+
22
+ def schedule_checks(
23
+ tasks: list[CheckTask],
24
+ jobs: int,
25
+ policy: str,
26
+ execute: Callable[[CheckTask], CommandResult],
27
+ ) -> ScheduleResult:
28
+ started = monotonic()
29
+ completed_tasks: list[CheckTask] = []
30
+ results: list[CommandResult] = []
31
+ cancelled: list[CheckTask] = []
32
+ first_failure_at: float | None = None
33
+ groups = [tasks] if policy == "complete" else _feedback_groups(tasks)
34
+ for group_index, group in enumerate(groups):
35
+ group_results = _execute_group(group, jobs, execute)
36
+ completed_tasks.extend(group)
37
+ results.extend(group_results)
38
+ if first_failure_at is None and any(result.exit_code != 0 for result in group_results):
39
+ first_failure_at = monotonic() - started
40
+ blocking = any(
41
+ task.required and result.exit_code != 0
42
+ for task, result in zip(group, group_results, strict=True)
43
+ )
44
+ if policy == "feedback-first" and blocking:
45
+ cancelled = [task for remaining in groups[group_index + 1 :] for task in remaining]
46
+ break
47
+ wall = monotonic() - started
48
+ return ScheduleResult(
49
+ completed_tasks,
50
+ results,
51
+ cancelled,
52
+ round(wall, 3),
53
+ round(sum(result.duration_seconds for result in results), 3),
54
+ round(first_failure_at, 3) if first_failure_at is not None else None,
55
+ )
56
+
57
+
58
+ def schedule_graph(tasks: list[CheckTask], policy: str) -> dict[str, object]:
59
+ waves = _feedback_groups(tasks) if policy == "feedback-first" else [tasks]
60
+ return {
61
+ "policy": policy,
62
+ "waves": [
63
+ {
64
+ "index": index,
65
+ "cost": wave[0].cost if wave else "none",
66
+ "checks": [task.name for task in wave],
67
+ "workspaces": sorted({task.workspace for task in wave}),
68
+ }
69
+ for index, wave in enumerate(waves)
70
+ ],
71
+ "fail_fast": policy == "feedback-first",
72
+ "deterministic_order": True,
73
+ }
74
+
75
+
76
+ def _feedback_groups(tasks: list[CheckTask]) -> list[list[CheckTask]]:
77
+ costs = ("fast", "medium", "slow")
78
+ return [
79
+ [task for task in tasks if task.cost == cost]
80
+ for cost in costs
81
+ if any(task.cost == cost for task in tasks)
82
+ ]
83
+
84
+
85
+ def _execute_group(
86
+ tasks: list[CheckTask],
87
+ jobs: int,
88
+ execute: Callable[[CheckTask], CommandResult],
89
+ ) -> list[CommandResult]:
90
+ workers = max(1, min(jobs, len(tasks)))
91
+ if workers == 1:
92
+ return [execute(task) for task in tasks]
93
+ with ThreadPoolExecutor(max_workers=workers) as executor:
94
+ return list(executor.map(execute, tasks))
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import sys
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ from ai_dev_tools.cache.graph import related_tests as graph_related_tests
9
+ from ai_dev_tools.cache.repository import read_repository_index
10
+ from ai_dev_tools.config import Settings
11
+ from ai_dev_tools.runners.check_models import ChangedSelection, ChangedStrategy, CheckTask
12
+ from ai_dev_tools.utils.subprocess import CommandResult, run_command
13
+
14
+
15
+ def select_changed_checks(
16
+ settings: Settings,
17
+ plan: list[CheckTask],
18
+ collector: Callable[[Path], list[str]] | None = None,
19
+ ) -> ChangedSelection:
20
+ changed_files = (collector or collect_changed_files)(settings.project_root)
21
+ if not changed_files:
22
+ return ChangedSelection("no_changes", "high", [], [], [], "No changed files detected.")
23
+ config_reason = _configuration_change_reason(changed_files)
24
+ if config_reason:
25
+ return ChangedSelection(
26
+ "configuration_change",
27
+ "low",
28
+ changed_files,
29
+ [],
30
+ [],
31
+ config_reason,
32
+ )
33
+ direct_changed_tests = [path for path in changed_files if _is_test_path(Path(path))]
34
+ if direct_changed_tests:
35
+ return ChangedSelection(
36
+ "changed_test_direct",
37
+ "high",
38
+ changed_files,
39
+ sorted(direct_changed_tests),
40
+ _commands_for_selected_tests(plan, sorted(direct_changed_tests)),
41
+ None,
42
+ )
43
+ configured_tests = _configured_changed_tests(settings, changed_files)
44
+ if configured_tests:
45
+ return ChangedSelection(
46
+ "configured_mapping",
47
+ "high",
48
+ changed_files,
49
+ configured_tests,
50
+ _commands_for_selected_tests(plan, configured_tests),
51
+ None,
52
+ )
53
+ selected_tests = infer_tests_for_changed_files(settings.project_root, changed_files)
54
+ if selected_tests:
55
+ strategy: ChangedStrategy = "direct_test_match"
56
+ return ChangedSelection(
57
+ strategy,
58
+ "medium",
59
+ changed_files,
60
+ selected_tests,
61
+ _commands_for_selected_tests(plan, selected_tests),
62
+ None,
63
+ )
64
+ return ChangedSelection(
65
+ "broad_fallback",
66
+ "low",
67
+ changed_files,
68
+ [],
69
+ [],
70
+ "Changed files were detected, but no reliable test dependency map is available yet.",
71
+ )
72
+
73
+
74
+ def collect_changed_files(
75
+ root: Path,
76
+ command_runner: Callable[[list[str], Path, int], CommandResult] = run_command,
77
+ ) -> list[str]:
78
+ seen: dict[str, None] = {}
79
+ commands = [
80
+ ["git", "diff", "--name-only", "-z"],
81
+ ["git", "diff", "--cached", "--name-only", "-z"],
82
+ ["git", "ls-files", "--others", "--exclude-standard", "-z"],
83
+ ]
84
+ upstream = command_runner(
85
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root, 30
86
+ )
87
+ if upstream.exit_code == 0 and upstream.stdout.strip():
88
+ commands.append(["git", "diff", "--name-only", "-z", upstream.stdout.strip() + "...HEAD"])
89
+ for command in commands:
90
+ result = command_runner(command, root, 30)
91
+ if result.exit_code != 0:
92
+ continue
93
+ for item in _split_nul(result.stdout):
94
+ if item:
95
+ seen[item] = None
96
+ return sorted(seen)
97
+
98
+
99
+ def infer_tests_for_changed_files(root: Path, changed_files: list[str]) -> list[str]:
100
+ selected: dict[str, None] = {}
101
+ index = read_repository_index(root)
102
+ for rel in graph_related_tests(index.get("graph"), changed_files):
103
+ _add_existing(root, selected, rel)
104
+ for rel in changed_files:
105
+ path = Path(rel)
106
+ if _is_test_path(path):
107
+ _add_existing(root, selected, rel)
108
+ continue
109
+ candidates = _candidate_tests_for_source(path)
110
+ for candidate in candidates:
111
+ _add_existing(root, selected, candidate)
112
+ for importer in _python_importing_tests(root, path):
113
+ selected[importer] = None
114
+ return sorted(selected)
115
+
116
+
117
+ def _configuration_change_reason(changed_files: list[str]) -> str | None:
118
+ broad_files = {
119
+ "pyproject.toml",
120
+ "pytest.ini",
121
+ "tox.ini",
122
+ "noxfile.py",
123
+ "package.json",
124
+ "pnpm-workspace.yaml",
125
+ "jest.config.js",
126
+ "jest.config.ts",
127
+ "vitest.config.js",
128
+ "vitest.config.ts",
129
+ "pom.xml",
130
+ "build.gradle",
131
+ "build.gradle.kts",
132
+ "settings.gradle",
133
+ "settings.gradle.kts",
134
+ "Cargo.toml",
135
+ "Cargo.lock",
136
+ "composer.json",
137
+ "phpunit.xml",
138
+ }
139
+ for rel in changed_files:
140
+ path = Path(rel)
141
+ normalized = path.as_posix()
142
+ if path.name == "conftest.py" or "fixtures" in path.parts:
143
+ return f"Shared test fixture changed: {rel}"
144
+ if normalized in broad_files or path.name in broad_files:
145
+ return f"Project or test configuration changed: {rel}"
146
+ return None
147
+
148
+
149
+ def _candidate_tests_for_source(path: Path) -> list[str]:
150
+ stem = path.stem
151
+ suffix = path.suffix.lower()
152
+ parts = list(path.parts)
153
+ candidates: list[str] = []
154
+ if suffix == ".py":
155
+ relative_parts = parts[1:] if parts and parts[0] in {"src", "lib"} else parts
156
+ if relative_parts:
157
+ relative_parts[-1] = f"test_{stem}.py"
158
+ candidates.append(str(Path("tests", *relative_parts)))
159
+ candidates.append(str(Path("tests", f"test_{stem}.py")))
160
+ if suffix in {".js", ".jsx", ".ts", ".tsx"}:
161
+ candidates.extend(
162
+ [
163
+ str(path.with_name(f"{stem}.test{suffix}")),
164
+ str(path.with_name(f"{stem}.spec{suffix}")),
165
+ str(Path("tests", f"{stem}.test{suffix}")),
166
+ str(Path("tests", f"{stem}.spec{suffix}")),
167
+ ]
168
+ )
169
+ if suffix == ".java" and parts[:3] == ["src", "main", "java"]:
170
+ test_parts = ["src", "test", "java", *parts[3:]]
171
+ test_parts[-1] = f"{stem}Test.java"
172
+ candidates.append(str(Path(*test_parts)))
173
+ if suffix == ".php":
174
+ rel_parts = parts[1:] if parts and parts[0] == "src" else parts
175
+ if rel_parts:
176
+ rel_parts[-1] = f"{stem}Test.php"
177
+ candidates.append(str(Path("tests", *rel_parts)))
178
+ return candidates
179
+
180
+
181
+ def _python_importing_tests(root: Path, source_path: Path) -> list[str]:
182
+ if source_path.suffix.lower() != ".py" or not (root / "tests").exists():
183
+ return []
184
+ module = ".".join(source_path.with_suffix("").parts)
185
+ if module.startswith("src."):
186
+ module = module[4:]
187
+ needle_options = {
188
+ f"import {module}",
189
+ f"from {module} import",
190
+ f"from {module.rsplit('.', 1)[0]} import" if "." in module else "",
191
+ }
192
+ matches: list[str] = []
193
+ for test_path in (root / "tests").rglob("test_*.py"):
194
+ text = test_path.read_text(encoding="utf-8", errors="replace")
195
+ if any(needle and needle in text for needle in needle_options):
196
+ matches.append(str(test_path.relative_to(root)))
197
+ return matches
198
+
199
+
200
+ def _configured_changed_tests(settings: Settings, changed_files: list[str]) -> list[str]:
201
+ selected: dict[str, None] = {}
202
+ for source_pattern, test_patterns in settings.changed_tests.items():
203
+ if not any(
204
+ fnmatch.fnmatch(path.replace("\\", "/"), source_pattern) for path in changed_files
205
+ ):
206
+ continue
207
+ for pattern in test_patterns:
208
+ for match in _matching_test_files(settings.project_root, pattern):
209
+ selected[str(match.relative_to(settings.project_root))] = None
210
+ return sorted(selected)
211
+
212
+
213
+ def _matching_test_files(root: Path, pattern: str) -> list[Path]:
214
+ files: dict[Path, None] = {}
215
+ for match in root.glob(pattern):
216
+ if match.is_file():
217
+ files[match] = None
218
+ elif match.is_dir():
219
+ for child in match.rglob("*"):
220
+ if child.is_file():
221
+ files[child] = None
222
+ return sorted(files)
223
+
224
+
225
+ def _commands_for_selected_tests(
226
+ plan: list[CheckTask], selected_tests: list[str]
227
+ ) -> list[list[str]]:
228
+ commands: list[list[str]] = []
229
+ if not selected_tests:
230
+ return commands
231
+ if any(path.endswith(".py") for path in selected_tests):
232
+ commands.append([sys.executable, "-m", "pytest", *selected_tests])
233
+ if any(path.endswith((".js", ".jsx", ".ts", ".tsx")) for path in selected_tests):
234
+ npm_test = next((task.command for task in plan if task.name == "npm test"), ["npm", "test"])
235
+ commands.append(npm_test)
236
+ if any(path.endswith(".java") for path in selected_tests):
237
+ maven = next((task.command for task in plan if task.name == "maven test"), None)
238
+ gradle = next((task.command for task in plan if task.name == "gradle test"), None)
239
+ commands.append(maven or gradle or ["mvn", "test"])
240
+ if any(path.endswith(".php") for path in selected_tests):
241
+ commands.append(
242
+ next(
243
+ (task.command for task in plan if task.name == "composer test"),
244
+ ["composer", "test"],
245
+ )
246
+ )
247
+ return commands
248
+
249
+
250
+ def _is_test_path(path: Path) -> bool:
251
+ text = path.as_posix().lower()
252
+ return (
253
+ "/tests/" in f"/{text}"
254
+ or ".test." in text
255
+ or ".spec." in text
256
+ or path.name.startswith("test_")
257
+ )
258
+
259
+
260
+ def _add_existing(root: Path, selected: dict[str, None], rel: str) -> None:
261
+ path = root / rel
262
+ if path.exists() and path.is_file():
263
+ selected[str(path.relative_to(root))] = None
264
+
265
+
266
+ def _split_nul(output: str) -> list[str]:
267
+ return [item for item in output.split("\0") if item]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ from pathlib import Path
6
+
7
+ from ai_dev_tools.cache.repository import read_repository_index
8
+ from ai_dev_tools.cache.validation import validation_cache_stats
9
+ from ai_dev_tools.config import load_settings
10
+ from ai_dev_tools.models.report import Report
11
+
12
+
13
+ def run_diagnostics(project_root: Path) -> Report:
14
+ settings = load_settings(project_root)
15
+ root = settings.project_root
16
+ report = Report(command="diagnostics", project_root=root)
17
+ config_path = root / ".ai-dev-tools.toml"
18
+ report.summary = {
19
+ "cache": validation_cache_stats(root),
20
+ "repository_index": _index_summary(read_repository_index(root)),
21
+ "reports": _directory_stats(settings.reports_directory),
22
+ "logs": _directory_stats(settings.logs_directory),
23
+ "configuration": {
24
+ "source": str(config_path) if config_path.exists() else "built-in defaults",
25
+ "warnings": settings.warnings,
26
+ "reports_directory": str(settings.reports_directory),
27
+ "logs_directory": str(settings.logs_directory),
28
+ },
29
+ "efficiency_metrics": _efficiency_metrics(root),
30
+ "privacy": "local-only; no repository contents or metrics are transmitted",
31
+ }
32
+ return report
33
+
34
+
35
+ def _directory_stats(directory: Path) -> dict[str, object]:
36
+ files = [path for path in directory.rglob("*") if path.is_file()] if directory.exists() else []
37
+ sizes = [_size(path) for path in files]
38
+ ordered = sorted(files, key=_mtime)
39
+ return {
40
+ "files": len(files),
41
+ "bytes": sum(sizes),
42
+ "oldest": str(ordered[0]) if ordered else None,
43
+ "newest": str(ordered[-1]) if ordered else None,
44
+ }
45
+
46
+
47
+ def _index_summary(index: dict[str, object]) -> dict[str, object]:
48
+ summary = index.get("summary")
49
+ return {
50
+ "available": bool(index),
51
+ "schema_version": index.get("schema_version"),
52
+ **(summary if isinstance(summary, dict) else {}),
53
+ }
54
+
55
+
56
+ def _efficiency_metrics(root: Path) -> dict[str, int]:
57
+ context_path = root / ".ai" / "context" / "context-latest.json"
58
+ try:
59
+ context = json.loads(context_path.read_text(encoding="utf-8"))
60
+ except (OSError, json.JSONDecodeError):
61
+ context = {}
62
+ summary = context.get("summary", {}) if isinstance(context, dict) else {}
63
+ incremental = summary.get("incremental", {}) if isinstance(summary, dict) else {}
64
+ reused = incremental.get("reused_files", []) if isinstance(incremental, dict) else []
65
+ reused_paths = {str(item) for item in reused} if isinstance(reused, list) else set()
66
+ index = read_repository_index(root)
67
+ entries = index.get("entries", [])
68
+ reused_bytes = 0
69
+ if isinstance(entries, list):
70
+ for entry in entries:
71
+ if isinstance(entry, dict) and entry.get("path") in reused_paths:
72
+ size = entry.get("size")
73
+ reused_bytes += size if isinstance(size, int) else 0
74
+ budget = summary.get("budget", {}) if isinstance(summary, dict) else {}
75
+ used_chars = budget.get("used_chars", 0) if isinstance(budget, dict) else 0
76
+ chars = used_chars if isinstance(used_chars, int) else 0
77
+ return {
78
+ "latest_context_chars": chars,
79
+ "latest_context_token_estimate": math.ceil(chars / 4),
80
+ "reused_context_bytes": reused_bytes,
81
+ "estimated_tokens_avoided": math.ceil(reused_bytes / 4),
82
+ }
83
+
84
+
85
+ def _size(path: Path) -> int:
86
+ try:
87
+ return path.stat().st_size
88
+ except OSError:
89
+ return 0
90
+
91
+
92
+ def _mtime(path: Path) -> int:
93
+ try:
94
+ return path.stat().st_mtime_ns
95
+ except OSError:
96
+ return 0
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ai_dev_tools.config import load_settings
10
+ from ai_dev_tools.context import ContextOptions, build_context
11
+ from ai_dev_tools.git.inspect import inspect_git
12
+ from ai_dev_tools.models.report import Artifact, Report
13
+ from ai_dev_tools.runners.check import run_check
14
+ from ai_dev_tools.security.secrets import mask_text
15
+
16
+ SESSION_SCHEMA_VERSION = "1"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class FeedbackOptions:
21
+ task: str = ""
22
+ explain: bool = False
23
+ jobs: int = 4
24
+
25
+
26
+ def run_feedback(project_root: Path, options: FeedbackOptions) -> Report:
27
+ settings = load_settings(project_root)
28
+ root = settings.project_root
29
+ report = Report(command="feedback", project_root=root)
30
+ timings: dict[str, float] = {}
31
+
32
+ started = time.monotonic()
33
+ git_report = inspect_git(root, detailed=True)
34
+ timings["git_inspect"] = _elapsed(started)
35
+
36
+ started = time.monotonic()
37
+ check_report = run_check(
38
+ root,
39
+ mode="changed",
40
+ explain=options.explain,
41
+ jobs=options.jobs,
42
+ policy="feedback-first",
43
+ resume=True,
44
+ )
45
+ timings["validation"] = _elapsed(started)
46
+
47
+ started = time.monotonic()
48
+ context_report = build_context(
49
+ root,
50
+ ContextOptions(
51
+ task=options.task,
52
+ profile="minimal",
53
+ incremental=not options.explain,
54
+ explain=options.explain,
55
+ ),
56
+ )
57
+ timings["context"] = _elapsed(started)
58
+
59
+ changed = [str(item) for item in git_report.summary.get("changed_files", [])]
60
+ failures = _failure_signatures(check_report)
61
+ blocking_codes = [
62
+ str(issue.code or "VALIDATION_FAILED")
63
+ for issue in check_report.issues
64
+ if issue.severity in {"error", "critical"}
65
+ ]
66
+ if check_report.status == "failed" and not blocking_codes:
67
+ blocking_codes.append("VALIDATION_FAILED")
68
+ ready = check_report.status in {"success", "partial"} and not blocking_codes
69
+ report.status = "success" if ready else "failed"
70
+ report.summary = {
71
+ "agent_protocol_version": "1",
72
+ "decision": {
73
+ "ready": ready,
74
+ "status": report.status,
75
+ "confidence": _confidence(check_report),
76
+ "blocking_reason_codes": sorted(set(blocking_codes)),
77
+ },
78
+ "changes": {
79
+ "files": changed,
80
+ "count": len(changed),
81
+ "git_states": git_report.summary.get("states", []),
82
+ },
83
+ "validation": {
84
+ "status": check_report.status,
85
+ "checks_total": check_report.summary.get("checks_total", 0),
86
+ "checks_failed": check_report.summary.get("checks_failed", 0),
87
+ "first_failure": check_report.summary.get("first_failure"),
88
+ "failure_signatures": failures,
89
+ "results": check_report.summary.get("results", []),
90
+ "execution": check_report.summary.get("execution", {}),
91
+ },
92
+ "context": {
93
+ "status": context_report.status,
94
+ "selected_files": context_report.summary.get("selected_files", []),
95
+ "incremental": context_report.summary.get("incremental", {}),
96
+ "budget": context_report.summary.get("budget", {}),
97
+ },
98
+ "performance": {
99
+ "stages_seconds": timings,
100
+ "total_seconds": round(sum(timings.values()), 3),
101
+ },
102
+ "expand": [
103
+ {"kind": artifact.kind, "path": artifact.path}
104
+ for artifact in [*check_report.artifacts, *context_report.artifacts]
105
+ ],
106
+ }
107
+ session_path = _write_session(
108
+ root,
109
+ {
110
+ "schema_version": SESSION_SCHEMA_VERSION,
111
+ "task": options.task,
112
+ "changed_files": changed,
113
+ "validation_status": check_report.status,
114
+ "failure_signatures": failures,
115
+ "context_incremental": context_report.summary.get("incremental", {}),
116
+ "performance": timings,
117
+ },
118
+ )
119
+ report.artifacts.append(Artifact(str(session_path), "session", "Local agent session state"))
120
+ report.artifacts.extend(_unique_artifacts([*check_report.artifacts, *context_report.artifacts]))
121
+ return report
122
+
123
+
124
+ def run_session_status(project_root: Path) -> Report:
125
+ root = project_root.resolve()
126
+ report = Report(command="session status", project_root=root)
127
+ try:
128
+ payload = json.loads(_session_path(root).read_text(encoding="utf-8"))
129
+ except (OSError, json.JSONDecodeError):
130
+ report.status = "partial"
131
+ report.summary = {
132
+ "message": "No valid local session state exists.",
133
+ "reason_code": "SESSION_MISSING",
134
+ }
135
+ return report
136
+ if not isinstance(payload, dict) or payload.get("schema_version") != SESSION_SCHEMA_VERSION:
137
+ report.status = "partial"
138
+ report.summary = {
139
+ "message": "Local session state uses an unsupported schema.",
140
+ "reason_code": "SESSION_SCHEMA_UNSUPPORTED",
141
+ }
142
+ return report
143
+ report.summary = payload
144
+ report.artifacts.append(
145
+ Artifact(str(_session_path(root)), "session", "Local agent session state")
146
+ )
147
+ return report
148
+
149
+
150
+ def _failure_signatures(report: Report) -> list[str]:
151
+ results = report.summary.get("results")
152
+ if not isinstance(results, list):
153
+ return []
154
+ return sorted(
155
+ {
156
+ str(item["failure_signature"])
157
+ for item in results
158
+ if isinstance(item, dict) and item.get("failure_signature")
159
+ }
160
+ )
161
+
162
+
163
+ def _confidence(report: Report) -> str:
164
+ changed = report.summary.get("changed_analysis")
165
+ if isinstance(changed, dict) and isinstance(changed.get("confidence"), str):
166
+ return str(changed["confidence"])
167
+ return "medium" if report.status == "partial" else "high"
168
+
169
+
170
+ def _write_session(root: Path, payload: dict[str, object]) -> Path:
171
+ path = _session_path(root)
172
+ path.parent.mkdir(parents=True, exist_ok=True)
173
+ temporary = path.with_suffix(".tmp")
174
+ temporary.write_text(
175
+ mask_text(json.dumps(payload, indent=2, sort_keys=True) + "\n"), encoding="utf-8"
176
+ )
177
+ os.replace(temporary, path)
178
+ return path
179
+
180
+
181
+ def _session_path(root: Path) -> Path:
182
+ return root / ".ai" / "cache" / "session.json"
183
+
184
+
185
+ def _elapsed(started: float) -> float:
186
+ return round(time.monotonic() - started, 3)
187
+
188
+
189
+ def _unique_artifacts(artifacts: list[Artifact]) -> list[Artifact]:
190
+ unique: dict[tuple[str, str], Artifact] = {}
191
+ for artifact in artifacts:
192
+ unique[(artifact.path, artifact.kind)] = artifact
193
+ return list(unique.values())