sanityops-cli 0.1.3__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 (53) hide show
  1. sanityops_cli/__init__.py +16 -0
  2. sanityops_cli/agents/__init__.py +14 -0
  3. sanityops_cli/agents/repair_agent/__init__.py +19 -0
  4. sanityops_cli/agents/repair_agent/agent.py +230 -0
  5. sanityops_cli/agents/repair_agent/prompts.py +100 -0
  6. sanityops_cli/agents/repair_agent/tools/__init__.py +19 -0
  7. sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +144 -0
  8. sanityops_cli/agents/scanner_agent/agent.py +332 -0
  9. sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +152 -0
  10. sanityops_cli/agents/scanner_agent/models/finding.py +120 -0
  11. sanityops_cli/agents/scanner_agent/prompts.py +316 -0
  12. sanityops_cli/agents/scanner_agent/tools/grep_tool.py +667 -0
  13. sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +88 -0
  14. sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +804 -0
  15. sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +178 -0
  16. sanityops_cli/api/__init__.py +16 -0
  17. sanityops_cli/api/client.py +403 -0
  18. sanityops_cli/commands/__init__.py +14 -0
  19. sanityops_cli/commands/config.py +312 -0
  20. sanityops_cli/commands/init.py +132 -0
  21. sanityops_cli/commands/inspect.py +646 -0
  22. sanityops_cli/constants/__init__.py +14 -0
  23. sanityops_cli/constants/config_defaults.py +22 -0
  24. sanityops_cli/constants/exit_codes.py +19 -0
  25. sanityops_cli/defect_checker/__init__.py +16 -0
  26. sanityops_cli/defect_checker/checker.py +100 -0
  27. sanityops_cli/defect_checker/llm_config.py +112 -0
  28. sanityops_cli/defect_checker/markdown_reporter.py +249 -0
  29. sanityops_cli/defect_checker/renderer.py +203 -0
  30. sanityops_cli/exceptions/__init__.py +14 -0
  31. sanityops_cli/exceptions/api_exceptions.py +60 -0
  32. sanityops_cli/exceptions/base_exceptions.py +25 -0
  33. sanityops_cli/help_panel.py +49 -0
  34. sanityops_cli/logging/__init__.py +18 -0
  35. sanityops_cli/logging/logger.py +108 -0
  36. sanityops_cli/main.py +123 -0
  37. sanityops_cli/progress/__init__.py +18 -0
  38. sanityops_cli/progress/tracker.py +159 -0
  39. sanityops_cli/renderers/__init__.py +14 -0
  40. sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +87 -0
  41. sanityops_cli/templates/__init__.py +14 -0
  42. sanityops_cli/templates/inspect_config.yaml +55 -0
  43. sanityops_cli/utils/__init__.py +14 -0
  44. sanityops_cli/utils/artifact_packer.py +407 -0
  45. sanityops_cli/utils/config_loader.py +296 -0
  46. sanityops_cli/utils/config_resolver.py +358 -0
  47. sanityops_cli/utils/validators.py +117 -0
  48. sanityops_cli-0.1.3.dist-info/METADATA +213 -0
  49. sanityops_cli-0.1.3.dist-info/RECORD +53 -0
  50. sanityops_cli-0.1.3.dist-info/WHEEL +4 -0
  51. sanityops_cli-0.1.3.dist-info/entry_points.txt +2 -0
  52. sanityops_cli-0.1.3.dist-info/licenses/LICENSE +201 -0
  53. sanityops_cli-0.1.3.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,16 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+
16
+ __version__ = "0.1.3"
@@ -0,0 +1,14 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
@@ -0,0 +1,19 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Repair agent for generating fixed artifact content from inspection reports."""
16
+
17
+ from .agent import RepairAgent
18
+
19
+ __all__ = ["RepairAgent"]
@@ -0,0 +1,230 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Repair agent: generate repaired artifact content from a local defect report."""
16
+
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING
19
+
20
+ import anyio
21
+ from rich.console import Console
22
+
23
+ from sanityops_cli.exceptions.base_exceptions import ValidationError
24
+
25
+ if TYPE_CHECKING:
26
+ from sanityops_cli.logging.logger import Logger
27
+
28
+ # ============================================================================
29
+ # RepairAgent Class
30
+ # ============================================================================
31
+
32
+
33
+ class RepairAgent:
34
+ """Runs an LLM agent that rewrites defective artifacts using the report.
35
+
36
+ Mirrors ScannerAgent: builds a sanityops_agent parent agent with a tool
37
+ registry (read + store_repair), feeds it the inspection report plus the
38
+ artifact paths, and collects repairs via the shared-memory
39
+ StoreRepairsTool.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ provider,
45
+ max_loops: int = 30,
46
+ timeout: int = 300,
47
+ token_budget: int | None = None,
48
+ llm_retries: int = 3,
49
+ retry_delay: float = 65.0,
50
+ verbose: bool = False,
51
+ console: Console | None = None,
52
+ logger: "Logger | None" = None,
53
+ ):
54
+ self.provider = provider
55
+ self.max_loops = max_loops
56
+ self.timeout = timeout
57
+ self.token_budget = token_budget
58
+ self.verbose = verbose
59
+ self.console = console or Console()
60
+ self.logger = logger
61
+ # Rate-limit resilience: the server-side LLM proxy may cap requests
62
+ # far below what one repair run needs (e.g. rpm_limit=1). The SDK's
63
+ # own retry backs off only 1-2s, so we retry the whole run here with
64
+ # a delay long enough to clear a per-minute quota.
65
+ self.llm_retries = llm_retries
66
+ self.retry_delay = retry_delay
67
+
68
+ async def repair(
69
+ self,
70
+ report_path: str,
71
+ artifacts: dict[str, list[str]],
72
+ project_root: str,
73
+ ) -> list[dict]:
74
+ """Repair artifacts listed in the report.
75
+
76
+ Retries the whole run on rate-limit errors (nothing to lose: repairs
77
+ only materialize after store_repair, and a rate-limited run usually
78
+ dies before storing anything).
79
+ """
80
+ last_error: Exception | None = None
81
+ for attempt in range(self.llm_retries + 1):
82
+ try:
83
+ return await self._run_once(report_path, artifacts, project_root)
84
+ except ValidationError as e:
85
+ last_error = e
86
+ if attempt >= self.llm_retries or not _is_rate_limit_error(e):
87
+ raise
88
+ self.console.print(
89
+ f" [yellow]⚠[/] LLM rate limited — waiting {self.retry_delay:.0f}s "
90
+ f"before retry {attempt + 1}/{self.llm_retries}..."
91
+ )
92
+ await anyio.sleep(self.retry_delay)
93
+ assert last_error is not None
94
+ raise last_error
95
+
96
+ async def _run_once(
97
+ self,
98
+ report_path: str,
99
+ artifacts: dict[str, list[str]],
100
+ project_root: str,
101
+ ) -> list[dict]:
102
+ """Single repair attempt (see repair() for the retry wrapper)."""
103
+ from sanityops_agent.agents import AgentFactory
104
+ from sanityops_agent.core.agent import AgentConfig, TerminationReason
105
+ from sanityops_agent.hooks.base import HookExecutor
106
+ from sanityops_agent.tools import ToolRegistry
107
+
108
+ from sanityops_cli.agents.repair_agent.prompts import REPAIR_AGENT_PROMPT
109
+ from sanityops_cli.agents.repair_agent.tools.store_repairs_tool import StoreRepairsTool
110
+ from sanityops_cli.agents.scanner_agent.tools.readfile_tool import FileReadTool
111
+
112
+ report = Path(report_path).resolve()
113
+ if not report.is_file():
114
+ raise ValidationError(f"Report not found: {report_path}")
115
+
116
+ all_paths = (
117
+ artifacts.get("prompts", []) + artifacts.get("tools", []) + artifacts.get("skills", [])
118
+ )
119
+ if not all_paths:
120
+ raise ValidationError("No artifacts configured to repair")
121
+
122
+ for p in all_paths:
123
+ path = Path(p)
124
+ if not path.is_absolute():
125
+ raise ValidationError(f"Artifact path must be absolute: {p}")
126
+ if not path.exists():
127
+ raise ValidationError(f"Artifact path does not exist: {p}")
128
+
129
+ # Deliberately NO TaskTool: repair must follow the artifact list
130
+ # exactly, not spawn sub-agents to explore the project. Registering it
131
+ # led agents to wander (listing directories, reading bogus paths) and
132
+ # burn the whole token budget without storing a single repair.
133
+ registry = ToolRegistry()
134
+ registry.register(FileReadTool())
135
+ registry.register(StoreRepairsTool())
136
+ StoreRepairsTool.clear()
137
+
138
+ artifact_lines: list[str] = []
139
+ for kind, key in (("prompt", "prompts"), ("tool", "tools"), ("skill", "skills")):
140
+ for p in artifacts.get(key) or []:
141
+ artifact_lines.append(f"- type: {kind}, path: {p}")
142
+
143
+ system_prompt = REPAIR_AGENT_PROMPT.format(
144
+ report_path=str(report),
145
+ project_root=project_root,
146
+ artifact_list="\n".join(artifact_lines),
147
+ )
148
+
149
+ config_kwargs = {
150
+ "max_loops": self.max_loops,
151
+ "total_timeout": self.timeout,
152
+ "system_prompt": system_prompt,
153
+ }
154
+ if self.token_budget:
155
+ config_kwargs["token_budget"] = self.token_budget
156
+
157
+ config = AgentConfig(**config_kwargs)
158
+
159
+ hook_executor = HookExecutor()
160
+ if self.verbose and self.logger:
161
+ from sanityops_cli.agents.scanner_agent.hooks.progress_hook import ProgressHook
162
+ hook_executor.register(
163
+ ProgressHook(self.console, verbose=self.verbose, logger=self.logger)
164
+ )
165
+
166
+ factory = AgentFactory(
167
+ provider=self.provider,
168
+ config=config,
169
+ tool_registry=registry,
170
+ hook_executor=hook_executor,
171
+ )
172
+ agent = factory.create_parent_agent(system_prompt=system_prompt)
173
+
174
+ if self.verbose:
175
+ self.console.print()
176
+
177
+ result = await agent.run(f"Repair artifacts listed in report {report}")
178
+
179
+ if self.verbose:
180
+ self.console.print()
181
+
182
+ repairs = StoreRepairsTool.get_repairs()
183
+ if result.termination_reason != TerminationReason.END_TURN:
184
+ # Any termination after repairs were already stored is a partial
185
+ # success: return collected repairs with a warning instead of
186
+ # discarding them (budget/loop exhaustion or a mid-run LLM error).
187
+ if repairs:
188
+ if self.verbose:
189
+ self.console.print(
190
+ f" [yellow]⚠[/] Agent stopped ({result.termination_reason.value}) "
191
+ f"after {len(repairs)} repair(s) were collected"
192
+ )
193
+ return repairs
194
+ error_msg = result.error or f"Agent terminated: {result.termination_reason.value}"
195
+ if result.error_detail:
196
+ error_detail_str = "\n".join(
197
+ f" - {e.error_type}: {e.message}" for e in result.error_detail
198
+ )
199
+ error_msg += f"\nError Detail:\n{error_detail_str}"
200
+ raise ValidationError(f"Agent execution failed: {error_msg}")
201
+
202
+ return repairs
203
+
204
+ def repair_sync(
205
+ self,
206
+ report_path: str,
207
+ artifacts: dict[str, list[str]],
208
+ project_root: str,
209
+ ) -> list[dict]:
210
+ """Synchronous wrapper around repair()."""
211
+ return anyio.run(self.repair, report_path, artifacts, project_root)
212
+
213
+
214
+ def _is_rate_limit_error(exc: BaseException) -> bool:
215
+ """Best-effort detection of a server-side rate limit / throttling error.
216
+
217
+ The LLM proxy (litellm) surfaces these as 429 with `throttling_error`, or
218
+ as 5xx wrappers whose message still mentions the rate limit. Matching on
219
+ the text markers keeps this working across provider SDKs.
220
+ """
221
+ text = str(exc).lower()
222
+ markers = (
223
+ "429",
224
+ "rate limit",
225
+ "ratelimiterror",
226
+ "throttling_error",
227
+ "too many requests",
228
+ "no deployments available",
229
+ )
230
+ return any(marker in text for marker in markers)
@@ -0,0 +1,100 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """System prompt for the repair agent."""
16
+
17
+ REPAIR_AGENT_PROMPT = """\
18
+ You are an artifact repair agent. Your job is to fix the defects reported in an
19
+ inspection report by rewriting the defective artifacts.
20
+
21
+ ## Inputs
22
+
23
+ - `{report_path}`: Absolute path to the inspection report (markdown). It lists
24
+ every defect found in the artifacts, grouped per artifact, with severity
25
+ (P0/P1/P2), description, location, impact, and a suggested fix.
26
+ - Artifacts live under the project root `{project_root}`. Paths referenced in
27
+ the report (or listed below) are relative to it unless absolute.
28
+
29
+ ## Artifacts Under Repair
30
+
31
+ The following artifacts were inspected and may require repair:
32
+ {artifact_list}
33
+
34
+ ## Workflow (follow strictly)
35
+
36
+ 1. Read the inspection report at `{report_path}` using the `read` tool.
37
+ 2. Identify every artifact that has at least one defect in the report.
38
+ Artifacts whose section says "No defects found" must be skipped entirely.
39
+ 3. For each defective artifact, in this order:
40
+ a. Read the artifact source file with the `read` tool — use the EXACT
41
+ absolute path from the list above. Never guess, never explore the
42
+ project, never read paths you have not been given.
43
+ b. Rewrite the FULL artifact content so that all reported defects in that
44
+ artifact are resolved. You must not introduce new defects; keep the
45
+ original structure, language, and unrelated content intact. Apply the
46
+ report's suggested fixes unless they conflict with other content, in
47
+ which case use your judgment to keep the artifact consistent.
48
+ c. Call `store_repair` immediately with:
49
+ - artifact_type: "prompt" | "tool" | "skill"
50
+ - artifact_path: absolute path of the artifact file you read
51
+ - repaired_content: the complete rewritten file content
52
+ - summary: one short paragraph listing which defect IDs you addressed
53
+ 4. Cross-module defects (IDs starting with QD-PT / QD-ST, shown in the
54
+ "Cross" section) describe inconsistencies BETWEEN artifacts. Resolve them
55
+ by editing the artifact(s) the fix suggestion targets; a single cross
56
+ defect may require touching two artifacts — store each repaired artifact
57
+ separately.
58
+
59
+ ## Output Language
60
+
61
+ - All summaries and repair descriptions must be written in English.
62
+ - Preserve the original language of the artifact content itself (Chinese stays
63
+ Chinese, English stays English). Comments and explanations inside the
64
+ repaired content should be minimal.
65
+
66
+ ## Scope discipline (IMPORTANT)
67
+
68
+ You have exactly two tools and one job. Do NOT:
69
+ - explore the project tree, list directories, or glob for other files;
70
+ - read the report more than once;
71
+ - read any path that is not in the Artifacts list above (a failed read
72
+ wastes a full turn and the token budget);
73
+ - rewrite artifacts that have no defects.
74
+
75
+ Every wasted turn can exhaust the budget before a single repair is stored.
76
+
77
+ ## Rules
78
+
79
+ - repaired_content MUST be the complete file content, never a diff or snippet.
80
+ - Never delete functionality to make a defect disappear unless the report
81
+ explicitly says so; prefer completing or correcting definitions.
82
+ - If the report contains no defects at all, store nothing and report that.
83
+ - Process artifacts one at a time: read -> repair -> store_repair -> next.
84
+ - Token budget is limited. Be economical: do NOT re-read files you have
85
+ already read; do NOT re-store an artifact you already stored (unless you
86
+ are correcting it); think through the repair BEFORE producing output and
87
+ emit the repaired content in one shot — avoid iterating on drafts.
88
+
89
+ ## Available Tools
90
+
91
+ - `read(file_path)`: read a file from disk. `file_path` must be an absolute path.
92
+ - `store_repair(...)`: REQUIRED. Store each repaired artifact exactly as
93
+ specified above.
94
+
95
+ ## Termination
96
+
97
+ Finish when every defective artifact has been stored via `store_repair`.
98
+ Then reply with a one-line summary: how many artifacts repaired, how many
99
+ defect IDs addressed.
100
+ """
@@ -0,0 +1,19 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Repair agent tools."""
16
+
17
+ from .store_repairs_tool import StoreRepairsTool
18
+
19
+ __all__ = ["StoreRepairsTool"]
@@ -0,0 +1,144 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Shared-memory store for repaired artifacts produced by the repair agent.
16
+
17
+ The agent calls `store_repair` once per defective artifact with the fully
18
+ rewritten content; the CLI flushes everything into a single markdown
19
+ report under `.sanityops/repairs/`.
20
+ """
21
+
22
+ import json
23
+ from pathlib import Path
24
+ from typing import ClassVar
25
+
26
+ from sanityops_agent.tools.base import Tool, ToolResult
27
+
28
+
29
+ class StoreRepairsTool(Tool):
30
+ """Shared-memory store for repaired artifacts produced by the repair agent.
31
+
32
+ The agent calls `store_repair` once per defective artifact with the fully
33
+ rewritten content; the CLI flushes everything into a single markdown
34
+ report under `.sanityops/repairs/`.
35
+ """
36
+
37
+ name = "store_repair"
38
+ description = "store the repaired content of one artifact"
39
+ parameters = {
40
+ "type": "object",
41
+ "properties": {
42
+ "artifact_type": {
43
+ "type": "string",
44
+ "enum": ["skill", "tool", "prompt"],
45
+ "description": "which kind of artifact was repaired",
46
+ },
47
+ "artifact_path": {
48
+ "type": "string",
49
+ "description": "absolute path of the source artifact file that was repaired",
50
+ },
51
+ "repaired_content": {
52
+ "type": "string",
53
+ "description": "the complete repaired artifact content (full file, not a diff)",
54
+ },
55
+ "summary": {
56
+ "type": "string",
57
+ "description": "one-paragraph summary of the defects addressed",
58
+ },
59
+ },
60
+ "required": ["artifact_type", "artifact_path", "repaired_content", "summary"],
61
+ }
62
+ tags = ["memory", "repair"]
63
+
64
+ _repairs: ClassVar[list[dict]] = []
65
+
66
+ async def execute(
67
+ self,
68
+ artifact_type: str,
69
+ artifact_path: str,
70
+ repaired_content: str,
71
+ summary: str,
72
+ ) -> ToolResult:
73
+ """Execute the store_repair operation."""
74
+ return self._add_repair(artifact_type, artifact_path, repaired_content, summary)
75
+
76
+ def _add_repair(
77
+ self,
78
+ artifact_type: str,
79
+ artifact_path: str,
80
+ repaired_content: str,
81
+ summary: str,
82
+ ) -> ToolResult:
83
+ """Add a repair entry to the shared store."""
84
+ # Validate artifact_type
85
+ if artifact_type not in ("skill", "tool", "prompt"):
86
+ return ToolResult(
87
+ content="",
88
+ success=False,
89
+ error=f"invalid artifact_type: {artifact_type}",
90
+ )
91
+
92
+ # Validate absolute path
93
+ path = Path(artifact_path)
94
+ if not path.is_absolute():
95
+ return ToolResult(
96
+ content="",
97
+ success=False,
98
+ error=f"artifact_path must be absolute: {artifact_path}",
99
+ )
100
+
101
+ # Validate non-empty content
102
+ if not repaired_content or not repaired_content.strip():
103
+ return ToolResult(
104
+ content="",
105
+ success=False,
106
+ error="repaired_content is empty",
107
+ )
108
+
109
+ # Create entry
110
+ entry = {
111
+ "artifact_type": artifact_type,
112
+ "artifact_path": str(path),
113
+ "repaired_content": repaired_content,
114
+ "summary": summary,
115
+ }
116
+
117
+ # Update existing entry for same artifact, or add new
118
+ for index, existing in enumerate(self._repairs):
119
+ if existing["artifact_path"] == entry["artifact_path"]:
120
+ self._repairs[index] = entry
121
+ action = "updated"
122
+ break
123
+ else:
124
+ self._repairs.append(entry)
125
+ action = "added"
126
+
127
+ return ToolResult(
128
+ content=json.dumps({
129
+ "success": True,
130
+ "message": f"{action} repair for {artifact_type}: {path.name}",
131
+ "count": len(self._repairs),
132
+ }),
133
+ success=True,
134
+ )
135
+
136
+ @classmethod
137
+ def get_repairs(cls) -> list[dict]:
138
+ """Return all stored repairs."""
139
+ return list(cls._repairs)
140
+
141
+ @classmethod
142
+ def clear(cls) -> None:
143
+ """Clear all stored repairs."""
144
+ cls._repairs.clear()