fix-cli 0.4.0__tar.gz

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 (37) hide show
  1. fix_cli-0.4.0/LICENSE +21 -0
  2. fix_cli-0.4.0/PKG-INFO +108 -0
  3. fix_cli-0.4.0/README.md +92 -0
  4. fix_cli-0.4.0/agent.py +232 -0
  5. fix_cli-0.4.0/client.py +160 -0
  6. fix_cli-0.4.0/contract.py +293 -0
  7. fix_cli-0.4.0/crypto.py +64 -0
  8. fix_cli-0.4.0/fix.py +2596 -0
  9. fix_cli-0.4.0/fix_cli.egg-info/PKG-INFO +108 -0
  10. fix_cli-0.4.0/fix_cli.egg-info/SOURCES.txt +35 -0
  11. fix_cli-0.4.0/fix_cli.egg-info/dependency_links.txt +1 -0
  12. fix_cli-0.4.0/fix_cli.egg-info/entry_points.txt +2 -0
  13. fix_cli-0.4.0/fix_cli.egg-info/requires.txt +6 -0
  14. fix_cli-0.4.0/fix_cli.egg-info/top_level.txt +8 -0
  15. fix_cli-0.4.0/protocol.py +142 -0
  16. fix_cli-0.4.0/pyproject.toml +35 -0
  17. fix_cli-0.4.0/scrubber.py +355 -0
  18. fix_cli-0.4.0/server/__init__.py +8 -0
  19. fix_cli-0.4.0/server/app.py +1142 -0
  20. fix_cli-0.4.0/server/escrow.py +391 -0
  21. fix_cli-0.4.0/server/judge.py +269 -0
  22. fix_cli-0.4.0/server/nano.py +164 -0
  23. fix_cli-0.4.0/server/reputation.py +142 -0
  24. fix_cli-0.4.0/server/store.py +154 -0
  25. fix_cli-0.4.0/setup.cfg +4 -0
  26. fix_cli-0.4.0/tests/test_agent.py +199 -0
  27. fix_cli-0.4.0/tests/test_app.py +899 -0
  28. fix_cli-0.4.0/tests/test_cli.py +54 -0
  29. fix_cli-0.4.0/tests/test_client.py +214 -0
  30. fix_cli-0.4.0/tests/test_contract.py +253 -0
  31. fix_cli-0.4.0/tests/test_crypto.py +114 -0
  32. fix_cli-0.4.0/tests/test_escrow.py +342 -0
  33. fix_cli-0.4.0/tests/test_judge.py +188 -0
  34. fix_cli-0.4.0/tests/test_nano.py +231 -0
  35. fix_cli-0.4.0/tests/test_reputation.py +189 -0
  36. fix_cli-0.4.0/tests/test_scrubber.py +409 -0
  37. fix_cli-0.4.0/tests/test_server.py +336 -0
fix_cli-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Karan Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
fix_cli-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: fix-cli
3
+ Version: 0.4.0
4
+ Summary: AI-powered command fixer with contract-based dispute resolution
5
+ Author-email: Karan Sharma <karans4@protonmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: httpx>=0.24
11
+ Provides-Extra: server
12
+ Requires-Dist: fastapi>=0.100; extra == "server"
13
+ Requires-Dist: uvicorn>=0.20; extra == "server"
14
+ Requires-Dist: starlette>=0.27; extra == "server"
15
+ Dynamic: license-file
16
+
17
+ # fix
18
+
19
+ AI-powered command fixer. A command fails, an LLM diagnoses it, proposes a fix, and a contract system tracks the whole thing. Disputes go to an AI judge.
20
+
21
+ ## Quick start
22
+
23
+ ```sh
24
+ pip install git+https://github.com/karans4/fix.git
25
+ ```
26
+
27
+ ### Local mode (you need an API key)
28
+
29
+ ```sh
30
+ export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, or run Ollama
31
+
32
+ fix "gcc foo.c" # run command, fix if it fails
33
+ fix it # fix the last failed command
34
+ fix --explain "make" # just explain the error
35
+ fix --dry-run "make" # show fix without running
36
+ fix --local "make" # force Ollama (free, local)
37
+ ```
38
+
39
+ ### Remote mode (free platform agent)
40
+
41
+ Post a contract to the platform. A free AI agent picks it up and proposes a fix.
42
+
43
+ ```sh
44
+ fix --remote "gcc foo.c"
45
+ ```
46
+
47
+ Platform: `https://fix.notruefireman.org` (free during testing)
48
+
49
+ Configure in `~/.fix/config.py`:
50
+ ```python
51
+ platform_url = "https://fix.notruefireman.org"
52
+ remote = True # default to remote mode
53
+ ```
54
+
55
+ ## Shell integration
56
+
57
+ For `fix it` / `fix !!` to work, add to your shell config:
58
+
59
+ ```sh
60
+ # bash/zsh
61
+ eval "$(fix shell)"
62
+
63
+ # fish
64
+ fix shell fish | source
65
+
66
+ # or auto-install
67
+ fix shell --install
68
+ ```
69
+
70
+ ## Safe mode (sandbox)
71
+
72
+ Default on Linux. Runs fixes in OverlayFS -- changes only committed if verification passes.
73
+
74
+ ```sh
75
+ fix "make build" # sandbox on Linux by default
76
+ fix --no-safe "make" # skip sandbox
77
+ fix --safe "make" # force sandbox
78
+ ```
79
+
80
+ ## Verification
81
+
82
+ ```sh
83
+ fix "gcc foo.c" # default: re-run, exit 0 = success
84
+ fix --verify=human "python3 render.py" # human judges
85
+ fix --verify="pytest tests/" "pip install x" # custom command
86
+ ```
87
+
88
+ ## How it works
89
+
90
+ 1. Command fails, stderr captured
91
+ 2. Contract built (task, environment, verification terms, escrow)
92
+ 3. Agent investigates (read-only commands), then proposes fix
93
+ 4. Fix applied, verified mechanically
94
+ 5. Multi-attempt: up to 3 tries, feeding failures back as context
95
+ 6. Disputes go to an AI judge who reviews the full transcript
96
+
97
+ ## Architecture
98
+
99
+ - `fix` -- CLI entry point
100
+ - `server/` -- FastAPI platform (contracts, escrow, reputation, judge)
101
+ - `protocol.py` -- state machine, constants
102
+ - `scrubber.py` -- redacts secrets from error output before sending to LLM
103
+ - `contract.py` -- builds structured contracts
104
+ - `client.py` / `agent.py` -- remote mode client and agent
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,92 @@
1
+ # fix
2
+
3
+ AI-powered command fixer. A command fails, an LLM diagnoses it, proposes a fix, and a contract system tracks the whole thing. Disputes go to an AI judge.
4
+
5
+ ## Quick start
6
+
7
+ ```sh
8
+ pip install git+https://github.com/karans4/fix.git
9
+ ```
10
+
11
+ ### Local mode (you need an API key)
12
+
13
+ ```sh
14
+ export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, or run Ollama
15
+
16
+ fix "gcc foo.c" # run command, fix if it fails
17
+ fix it # fix the last failed command
18
+ fix --explain "make" # just explain the error
19
+ fix --dry-run "make" # show fix without running
20
+ fix --local "make" # force Ollama (free, local)
21
+ ```
22
+
23
+ ### Remote mode (free platform agent)
24
+
25
+ Post a contract to the platform. A free AI agent picks it up and proposes a fix.
26
+
27
+ ```sh
28
+ fix --remote "gcc foo.c"
29
+ ```
30
+
31
+ Platform: `https://fix.notruefireman.org` (free during testing)
32
+
33
+ Configure in `~/.fix/config.py`:
34
+ ```python
35
+ platform_url = "https://fix.notruefireman.org"
36
+ remote = True # default to remote mode
37
+ ```
38
+
39
+ ## Shell integration
40
+
41
+ For `fix it` / `fix !!` to work, add to your shell config:
42
+
43
+ ```sh
44
+ # bash/zsh
45
+ eval "$(fix shell)"
46
+
47
+ # fish
48
+ fix shell fish | source
49
+
50
+ # or auto-install
51
+ fix shell --install
52
+ ```
53
+
54
+ ## Safe mode (sandbox)
55
+
56
+ Default on Linux. Runs fixes in OverlayFS -- changes only committed if verification passes.
57
+
58
+ ```sh
59
+ fix "make build" # sandbox on Linux by default
60
+ fix --no-safe "make" # skip sandbox
61
+ fix --safe "make" # force sandbox
62
+ ```
63
+
64
+ ## Verification
65
+
66
+ ```sh
67
+ fix "gcc foo.c" # default: re-run, exit 0 = success
68
+ fix --verify=human "python3 render.py" # human judges
69
+ fix --verify="pytest tests/" "pip install x" # custom command
70
+ ```
71
+
72
+ ## How it works
73
+
74
+ 1. Command fails, stderr captured
75
+ 2. Contract built (task, environment, verification terms, escrow)
76
+ 3. Agent investigates (read-only commands), then proposes fix
77
+ 4. Fix applied, verified mechanically
78
+ 5. Multi-attempt: up to 3 tries, feeding failures back as context
79
+ 6. Disputes go to an AI judge who reviews the full transcript
80
+
81
+ ## Architecture
82
+
83
+ - `fix` -- CLI entry point
84
+ - `server/` -- FastAPI platform (contracts, escrow, reputation, judge)
85
+ - `protocol.py` -- state machine, constants
86
+ - `scrubber.py` -- redacts secrets from error output before sending to LLM
87
+ - `contract.py` -- builds structured contracts
88
+ - `client.py` / `agent.py` -- remote mode client and agent
89
+
90
+ ## License
91
+
92
+ MIT
fix_cli-0.4.0/agent.py ADDED
@@ -0,0 +1,232 @@
1
+ """Agent mode for fix v3 — polls platform for contracts, investigates, proposes fixes.
2
+
3
+ Runs via `fix serve`. Connects to the platform via FixClient,
4
+ polls for open contracts, accepts compatible ones, runs LLM-driven
5
+ investigation loop, and proposes fixes.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import re
11
+ import sys
12
+ import os
13
+ from decimal import Decimal
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
16
+
17
+ from protocol import MAX_INVESTIGATION_ROUNDS
18
+ from client import FixClient
19
+ from contract import validate_contract, contract_for_prompt
20
+
21
+
22
+ # --- Helper functions ---
23
+
24
+ def parse_llm_investigation(response: str) -> list[str]:
25
+ """Extract INVESTIGATE: commands from LLM response."""
26
+ commands = []
27
+ for line in response.splitlines():
28
+ stripped = line.strip()
29
+ m = re.match(r'^INVESTIGATE:\s*(.+)$', stripped, re.IGNORECASE)
30
+ if m:
31
+ cmd = m.group(1).strip()
32
+ if cmd:
33
+ commands.append(cmd)
34
+ return commands
35
+
36
+
37
+ def build_agent_prompt(contract: dict, investigation_results: list[dict]) -> str:
38
+ """Build the prompt for the LLM with contract and investigation context."""
39
+ parts = []
40
+ parts.append("You are a fix agent. Your job is to diagnose and fix the problem described in this contract.\n")
41
+ parts.append("## Contract\n")
42
+ parts.append(json.dumps(contract, indent=2))
43
+ parts.append("")
44
+
45
+ if investigation_results:
46
+ parts.append("## Investigation Results So Far\n")
47
+ for i, r in enumerate(investigation_results, 1):
48
+ parts.append(f"### Round {i}: `{r['command']}`")
49
+ parts.append(f"```\n{r['output']}\n```")
50
+ parts.append("")
51
+
52
+ parts.append("## Instructions\n")
53
+ parts.append(
54
+ "If you need more information, output one or more lines starting with "
55
+ "'INVESTIGATE: <command>' where <command> is a shell command to run on "
56
+ "the principal's machine.\n"
57
+ )
58
+ parts.append(
59
+ "When you have enough information to propose a fix, output a JSON block:\n"
60
+ "```json\n"
61
+ '{"fix": "<shell command or script>", "explanation": "<why this fixes it>"}\n'
62
+ "```\n"
63
+ )
64
+ return "\n".join(parts)
65
+
66
+
67
+ def capabilities_match(agent_caps: dict, contract: dict) -> tuple[bool, str]:
68
+ """Check if agent capabilities satisfy contract requirements."""
69
+ contract_caps = contract.get("capabilities", {})
70
+ if not contract_caps:
71
+ return True, ""
72
+
73
+ for cap_name, cap_spec in contract_caps.items():
74
+ if not isinstance(cap_spec, dict):
75
+ continue
76
+ required = cap_spec.get("available", False)
77
+ if not required:
78
+ continue
79
+ agent_cap = agent_caps.get(cap_name, {})
80
+ if not isinstance(agent_cap, dict):
81
+ return False, f"missing capability: {cap_name}"
82
+ if not agent_cap.get("available", False):
83
+ return False, f"capability not available: {cap_name}"
84
+
85
+ return True, ""
86
+
87
+
88
+ class FixAgent:
89
+ """Agent that polls the fix platform for contracts and processes them."""
90
+
91
+ def __init__(self, config: dict):
92
+ """
93
+ config keys:
94
+ - platform_url: str (URL of the fix platform, default http://localhost:8000)
95
+ - api_key: str (platform API key)
96
+ - pubkey: str (agent's public key identifier)
97
+ - capabilities: dict
98
+ - min_bounty: str (minimum bounty to accept, default "0")
99
+ - llm_call: async callable(prompt) -> str
100
+ - poll_interval: float (seconds between polls, default 5.0)
101
+ """
102
+ self.platform_url = config.get("platform_url", "https://fix.notruefireman.org")
103
+ self.api_key = config.get("api_key", "")
104
+ self.pubkey = config.get("pubkey", "agent-default")
105
+ self.capabilities = config.get("capabilities", {})
106
+ self.min_bounty = Decimal(config.get("min_bounty", "0"))
107
+ self._llm_call = config.get("llm_call")
108
+ self.poll_interval = config.get("poll_interval", 5.0)
109
+ self._running = False
110
+ self.client = FixClient(base_url=self.platform_url)
111
+
112
+ async def serve(self):
113
+ """Main loop: poll platform for open contracts, process them."""
114
+ self._running = True
115
+ while self._running:
116
+ try:
117
+ contracts = await self.client.list_contracts(status="open")
118
+ for entry in contracts:
119
+ contract = entry.get("contract", {})
120
+ contract_id = entry.get("id", "")
121
+ valid, errors = validate_contract(contract)
122
+ if not valid:
123
+ continue
124
+ can, reason = self.can_handle(contract)
125
+ if not can:
126
+ continue
127
+ await self.handle_contract(contract_id, contract)
128
+ except Exception:
129
+ pass # Log and continue
130
+ await asyncio.sleep(self.poll_interval)
131
+
132
+ def stop(self):
133
+ self._running = False
134
+
135
+ async def handle_contract(self, contract_id: str, contract: dict):
136
+ """Process a single contract: accept, investigate, propose fix."""
137
+ # 1. Accept
138
+ await self.client.accept_contract(contract_id, self.pubkey)
139
+
140
+ # 2. Investigation loop
141
+ investigation_results = []
142
+ prompt_contract = contract_for_prompt(contract)
143
+ max_rounds = contract.get("execution", {}).get(
144
+ "investigation_rounds", MAX_INVESTIGATION_ROUNDS
145
+ )
146
+
147
+ fix_data = None
148
+ for _round in range(max_rounds):
149
+ prompt = build_agent_prompt(prompt_contract, investigation_results)
150
+ llm_response = await self._call_llm(prompt)
151
+
152
+ fix_data = self._extract_fix_proposal(llm_response)
153
+ if fix_data:
154
+ break
155
+
156
+ commands = parse_llm_investigation(llm_response)
157
+ if not commands:
158
+ break
159
+
160
+ for cmd in commands:
161
+ await self.client.request_investigation(contract_id, cmd, self.pubkey)
162
+ # Poll for result
163
+ result_output = await self._wait_for_result(contract_id, cmd)
164
+ if result_output is not None:
165
+ investigation_results.append({"command": cmd, "output": result_output})
166
+ else:
167
+ # Exhausted rounds — one final LLM call
168
+ prompt = build_agent_prompt(prompt_contract, investigation_results)
169
+ llm_response = await self._call_llm(prompt)
170
+ fix_data = self._extract_fix_proposal(llm_response)
171
+
172
+ # 3. Submit fix
173
+ if not fix_data:
174
+ fix_data = {"fix": "", "explanation": "Unable to determine a fix within investigation rounds."}
175
+
176
+ await self.client.submit_fix(
177
+ contract_id,
178
+ fix_data["fix"],
179
+ fix_data.get("explanation", ""),
180
+ self.pubkey,
181
+ )
182
+
183
+ def can_handle(self, contract: dict) -> tuple[bool, str]:
184
+ """Check if this agent can handle the contract."""
185
+ escrow = contract.get("escrow", {})
186
+ if escrow:
187
+ bounty = Decimal(escrow.get("bounty", "0"))
188
+ if bounty < self.min_bounty:
189
+ return False, f"bounty {bounty} below minimum {self.min_bounty}"
190
+ match, reason = capabilities_match(self.capabilities, contract)
191
+ if not match:
192
+ return False, reason
193
+ return True, ""
194
+
195
+ async def _call_llm(self, prompt: str) -> str:
196
+ if self._llm_call:
197
+ return await self._llm_call(prompt)
198
+ raise NotImplementedError("LLM backend not configured. Inject llm_call in config.")
199
+
200
+ @staticmethod
201
+ def _extract_fix_proposal(llm_response: str) -> dict | None:
202
+ """Try to extract a JSON fix proposal from LLM response."""
203
+ m = re.search(r'```(?:json)?\s*\n?({.*?})\s*\n?```', llm_response, re.DOTALL)
204
+ if m:
205
+ try:
206
+ data = json.loads(m.group(1))
207
+ if "fix" in data and "explanation" in data:
208
+ return data
209
+ except json.JSONDecodeError:
210
+ pass
211
+ m = re.search(r'(\{[^{}]*"fix"[^{}]*"explanation"[^{}]*\})', llm_response, re.DOTALL)
212
+ if m:
213
+ try:
214
+ data = json.loads(m.group(1))
215
+ if "fix" in data and "explanation" in data:
216
+ return data
217
+ except json.JSONDecodeError:
218
+ pass
219
+ return None
220
+
221
+ async def _wait_for_result(self, contract_id: str, command: str, timeout: float = 60.0, poll: float = 1.0) -> str | None:
222
+ """Poll for investigation result from principal."""
223
+ import time
224
+ deadline = time.time() + timeout
225
+ while time.time() < deadline:
226
+ data = await self.client.get_contract(contract_id)
227
+ if data:
228
+ for msg in reversed(data.get("transcript", [])):
229
+ if msg.get("type") == "result" and msg.get("command") == command:
230
+ return msg.get("output", "")
231
+ await asyncio.sleep(poll)
232
+ return None
@@ -0,0 +1,160 @@
1
+ """Platform API client for fix protocol.
2
+
3
+ Thin HTTP client with a pluggable transport interface.
4
+ Default transport: HTTP to our centralized platform.
5
+ """
6
+
7
+ import json
8
+ from abc import ABC, abstractmethod
9
+
10
+ import httpx
11
+
12
+
13
+ class Transport(ABC):
14
+ """Override this to use Nostr, gRPC, pigeons, whatever."""
15
+
16
+ @abstractmethod
17
+ async def post(self, path: str, data: dict) -> dict:
18
+ ...
19
+
20
+ @abstractmethod
21
+ async def get(self, path: str, params: dict | None = None) -> dict:
22
+ ...
23
+
24
+
25
+ class HTTPTransport(Transport):
26
+ """Default. Talks to our centralized platform over HTTP."""
27
+
28
+ def __init__(self, base_url: str = "http://localhost:8000", api_key: str = ""):
29
+ self.base_url = base_url.rstrip("/")
30
+ self.api_key = api_key
31
+
32
+ def _headers(self) -> dict:
33
+ h = {"Content-Type": "application/json"}
34
+ if self.api_key:
35
+ h["Authorization"] = f"Bearer {self.api_key}"
36
+ return h
37
+
38
+ async def post(self, path: str, data: dict) -> dict:
39
+ async with httpx.AsyncClient() as client:
40
+ resp = await client.post(
41
+ f"{self.base_url}{path}",
42
+ json=data,
43
+ headers=self._headers(),
44
+ timeout=30.0,
45
+ )
46
+ resp.raise_for_status()
47
+ return resp.json()
48
+
49
+ async def get(self, path: str, params: dict | None = None) -> dict:
50
+ async with httpx.AsyncClient() as client:
51
+ resp = await client.get(
52
+ f"{self.base_url}{path}",
53
+ params=params,
54
+ headers=self._headers(),
55
+ timeout=30.0,
56
+ )
57
+ resp.raise_for_status()
58
+ return resp.json()
59
+
60
+
61
+ class FixClient:
62
+ """High-level client for the fix platform."""
63
+
64
+ def __init__(self, transport: Transport | None = None, base_url: str = "http://localhost:8000"):
65
+ self.transport = transport or HTTPTransport(base_url)
66
+
67
+ async def post_contract(self, contract: dict, principal_pubkey: str = "") -> str:
68
+ """Post a contract. Returns contract_id."""
69
+ resp = await self.transport.post("/contracts", {
70
+ "contract": contract,
71
+ "principal_pubkey": principal_pubkey,
72
+ })
73
+ return resp["contract_id"]
74
+
75
+ async def list_contracts(self, status: str = "open", limit: int = 50) -> list[dict]:
76
+ """List contracts by status."""
77
+ resp = await self.transport.get("/contracts", {"status": status, "limit": limit})
78
+ return resp["contracts"]
79
+
80
+ async def get_contract(self, contract_id: str) -> dict:
81
+ """Get contract details."""
82
+ return await self.transport.get(f"/contracts/{contract_id}")
83
+
84
+ async def accept_contract(self, contract_id: str, agent_pubkey: str) -> dict:
85
+ """Agent accepts a contract."""
86
+ return await self.transport.post(f"/contracts/{contract_id}/accept", {
87
+ "agent_pubkey": agent_pubkey,
88
+ })
89
+
90
+ async def request_investigation(self, contract_id: str, command: str, agent_pubkey: str = "") -> dict:
91
+ """Agent requests investigation."""
92
+ return await self.transport.post(f"/contracts/{contract_id}/investigate", {
93
+ "command": command,
94
+ "agent_pubkey": agent_pubkey,
95
+ })
96
+
97
+ async def submit_investigation_result(self, contract_id: str, command: str, output: str) -> dict:
98
+ """Principal submits investigation result."""
99
+ return await self.transport.post(f"/contracts/{contract_id}/result", {
100
+ "command": command,
101
+ "output": output,
102
+ })
103
+
104
+ async def submit_fix(self, contract_id: str, fix: str, explanation: str = "", agent_pubkey: str = "") -> dict:
105
+ """Agent submits a fix."""
106
+ return await self.transport.post(f"/contracts/{contract_id}/fix", {
107
+ "fix": fix,
108
+ "explanation": explanation,
109
+ "agent_pubkey": agent_pubkey,
110
+ })
111
+
112
+ async def verify(self, contract_id: str, success: bool, explanation: str = "") -> dict:
113
+ """Principal reports verification result."""
114
+ return await self.transport.post(f"/contracts/{contract_id}/verify", {
115
+ "success": success,
116
+ "explanation": explanation,
117
+ })
118
+
119
+ async def dispute(self, contract_id: str, argument: str, side: str = "principal") -> dict:
120
+ """File a dispute. Returns awaiting_response status with deadline.
121
+ Other side has 30s to respond before judge rules in absentia."""
122
+ return await self.transport.post(f"/contracts/{contract_id}/dispute", {
123
+ "argument": argument,
124
+ "side": side,
125
+ })
126
+
127
+ async def respond(self, contract_id: str, argument: str, side: str) -> dict:
128
+ """Respond to a pending dispute. Triggers judge ruling with both arguments."""
129
+ return await self.transport.post(f"/contracts/{contract_id}/respond", {
130
+ "argument": argument,
131
+ "side": side,
132
+ })
133
+
134
+ async def dispute_status(self, contract_id: str) -> dict:
135
+ """Check status of a pending dispute."""
136
+ return await self.transport.get(f"/contracts/{contract_id}/dispute_status")
137
+
138
+ async def chat(self, contract_id: str, message: str, from_side: str = "principal",
139
+ msg_type: str = "message") -> dict:
140
+ """Send a chat message."""
141
+ return await self.transport.post(f"/contracts/{contract_id}/chat", {
142
+ "message": message,
143
+ "from_side": from_side,
144
+ "msg_type": msg_type,
145
+ })
146
+
147
+ async def halt(self, contract_id: str, reason: str, principal_pubkey: str = "") -> dict:
148
+ """Emergency halt -- freeze contract and escalate to judge."""
149
+ return await self.transport.post(f"/contracts/{contract_id}/halt", {
150
+ "reason": reason,
151
+ "principal_pubkey": principal_pubkey,
152
+ })
153
+
154
+ async def get_ruling(self, contract_id: str) -> dict | None:
155
+ """Get ruling for a contract."""
156
+ return await self.transport.get(f"/contracts/{contract_id}/ruling")
157
+
158
+ async def get_reputation(self, pubkey: str) -> dict:
159
+ """Get reputation stats."""
160
+ return await self.transport.get(f"/reputation/{pubkey}")