sin-code-bundle 0.9.2__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.
- sin_code_bundle/__init__.py +6 -0
- sin_code_bundle/agents_md.py +245 -0
- sin_code_bundle/ast_edit.py +323 -0
- sin_code_bundle/bench.py +506 -0
- sin_code_bundle/budget.py +51 -0
- sin_code_bundle/cache.py +131 -0
- sin_code_bundle/checkpoint.py +230 -0
- sin_code_bundle/cli.py +1943 -0
- sin_code_bundle/codocs.py +328 -0
- sin_code_bundle/dap_bridge.py +135 -0
- sin_code_bundle/data/codocs/SKILL.md +280 -0
- sin_code_bundle/gitnexus.py +368 -0
- sin_code_bundle/hashline.py +216 -0
- sin_code_bundle/hooks.py +249 -0
- sin_code_bundle/immortal_commit.py +288 -0
- sin_code_bundle/interceptor.py +119 -0
- sin_code_bundle/lsp_backend.py +303 -0
- sin_code_bundle/lsp_bootstrap.py +85 -0
- sin_code_bundle/markitdown.py +254 -0
- sin_code_bundle/mcp_config.py +455 -0
- sin_code_bundle/mcp_server.py +963 -0
- sin_code_bundle/memory.py +208 -0
- sin_code_bundle/merge_safety.py +313 -0
- sin_code_bundle/orchestration_worktrees.py +102 -0
- sin_code_bundle/policy.py +224 -0
- sin_code_bundle/preflight.py +152 -0
- sin_code_bundle/programming_workflow.py +541 -0
- sin_code_bundle/rtk.py +154 -0
- sin_code_bundle/safety.py +52 -0
- sin_code_bundle/session_warmup.py +247 -0
- sin_code_bundle/skills.py +188 -0
- sin_code_bundle/symbol_resolve.py +166 -0
- sin_code_bundle/tools/__init__.py +4 -0
- sin_code_bundle/tools/pypi_setup.py +289 -0
- sin_code_bundle/vfs.py +264 -0
- sin_code_bundle-0.9.2.dist-info/METADATA +470 -0
- sin_code_bundle-0.9.2.dist-info/RECORD +41 -0
- sin_code_bundle-0.9.2.dist-info/WHEEL +5 -0
- sin_code_bundle-0.9.2.dist-info/entry_points.txt +4 -0
- sin_code_bundle-0.9.2.dist-info/licenses/LICENSE +21 -0
- sin_code_bundle-0.9.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Purpose: One-call orchestration of common programming workflows.
|
|
2
|
+
|
|
3
|
+
Docs: programming_workflow.doc.md
|
|
4
|
+
|
|
5
|
+
This is the meta-tool that replaces 5+ separate `sin_*` calls with a
|
|
6
|
+
single action string. The agent picks the action, the tool fans out
|
|
7
|
+
to the right combination of underlying tools, and returns a single
|
|
8
|
+
structured verdict.
|
|
9
|
+
|
|
10
|
+
Actions:
|
|
11
|
+
- pre_write : sin_symbol_resolve + sin_read + sin_preflight
|
|
12
|
+
- write : sin_preflight + sin_write + sin_hashline_validate
|
|
13
|
+
- post_write : sin_preflight + codocs_check + pytest --collect-only
|
|
14
|
+
- pre_commit : sin_checkpoint + git status + codocs + ceo-audit (cached)
|
|
15
|
+
- refactor : sin_checkpoint + gitnexus_impact + gitnexus_detect_changes
|
|
16
|
+
- session_warmup : sin_session_warmup (full snapshot)
|
|
17
|
+
|
|
18
|
+
Each action returns a dict with:
|
|
19
|
+
- action : the action name
|
|
20
|
+
- steps : list of per-step results
|
|
21
|
+
- verdict : "READY", "FIX_FIRST", "BLOCK", or "PROCEED"
|
|
22
|
+
- suggested_message (pre_commit only): suggested Conventional Commits message
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import time
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
# Hard-coded fallback for the dev-machine layout.
|
|
37
|
+
_CEO_AUDIT_FALLBACK = "/Users/jeremy/.local/bin/sin"
|
|
38
|
+
|
|
39
|
+
# 5min — ceo-audit is the slow part of pre_commit.
|
|
40
|
+
_CEO_AUDIT_CACHE_TTL = 300
|
|
41
|
+
|
|
42
|
+
# Conventional Commits pattern (for `suggested_message` heuristic).
|
|
43
|
+
_CC_TYPES = ("feat", "fix", "docs", "chore", "refactor", "test", "perf")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProgrammingWorkflow:
|
|
47
|
+
"""Orchestrate the common agent workflows behind a single tool.
|
|
48
|
+
|
|
49
|
+
The class is intentionally stateful: ``pre_commit`` results are
|
|
50
|
+
cached in-process so a back-to-back call (e.g. once to dry-run,
|
|
51
|
+
once to actually commit) doesn't re-run ceo-audit.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, repo_root: Optional[Path] = None) -> None:
|
|
55
|
+
self.repo_root = Path(repo_root) if repo_root else Path.cwd()
|
|
56
|
+
# In-process cache: (action, key) → (timestamp, result).
|
|
57
|
+
self._cache: Dict[Tuple[str, str], Tuple[float, Dict[str, Any]]] = {}
|
|
58
|
+
|
|
59
|
+
# ── public dispatch ────────────────────────────────────────────
|
|
60
|
+
def run(
|
|
61
|
+
self,
|
|
62
|
+
action: str,
|
|
63
|
+
target: str = "",
|
|
64
|
+
content: str = "",
|
|
65
|
+
message: str = "",
|
|
66
|
+
checkpoint_name: str = "",
|
|
67
|
+
base: str = "main",
|
|
68
|
+
head: str = "HEAD",
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Dispatch to the right action handler.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
action: one of pre_write | write | post_write | pre_commit |
|
|
74
|
+
refactor | session_warmup.
|
|
75
|
+
target: file path (for pre_write / write / post_write) or
|
|
76
|
+
symbol name (for refactor).
|
|
77
|
+
content: file content (write only).
|
|
78
|
+
message: commit message (pre_commit only).
|
|
79
|
+
checkpoint_name: snapshot name (pre_commit / refactor).
|
|
80
|
+
base: base ref (pre_commit / session_warmup).
|
|
81
|
+
head: head ref (pre_commit / session_warmup).
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Dict with ``action``, ``steps``, ``verdict``, plus action-
|
|
85
|
+
specific extras (e.g. ``suggested_message`` for pre_commit).
|
|
86
|
+
"""
|
|
87
|
+
handler = {
|
|
88
|
+
"pre_write": self._action_pre_write,
|
|
89
|
+
"write": self._action_write,
|
|
90
|
+
"post_write": self._action_post_write,
|
|
91
|
+
"pre_commit": self._action_pre_commit,
|
|
92
|
+
"refactor": self._action_refactor,
|
|
93
|
+
"session_warmup": self._action_session_warmup,
|
|
94
|
+
}.get(action)
|
|
95
|
+
|
|
96
|
+
if handler is None:
|
|
97
|
+
return {
|
|
98
|
+
"action": action,
|
|
99
|
+
"verdict": "ERROR",
|
|
100
|
+
"error": (
|
|
101
|
+
f"Unknown action: {action!r}. "
|
|
102
|
+
"Valid: pre_write, write, post_write, pre_commit, refactor, session_warmup."
|
|
103
|
+
),
|
|
104
|
+
"steps": [],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
return handler(
|
|
109
|
+
target=target,
|
|
110
|
+
content=content,
|
|
111
|
+
message=message,
|
|
112
|
+
checkpoint_name=checkpoint_name,
|
|
113
|
+
base=base,
|
|
114
|
+
head=head,
|
|
115
|
+
)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
return {
|
|
118
|
+
"action": action,
|
|
119
|
+
"verdict": "ERROR",
|
|
120
|
+
"error": str(exc),
|
|
121
|
+
"steps": [],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# ── action handlers ─────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def _action_pre_write(self, target: str, **_: Any) -> Dict[str, Any]:
|
|
127
|
+
steps: List[Dict[str, Any]] = []
|
|
128
|
+
steps.append(self._safe_call("sin_read", lambda: _read(self.repo_root, target)))
|
|
129
|
+
steps.append(
|
|
130
|
+
self._safe_call(
|
|
131
|
+
"sin_preflight",
|
|
132
|
+
lambda: _preflight(self.repo_root, "sin_write", {"path": target}),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
verdict = "READY" if all(s.get("ok") for s in steps) else "FIX_FIRST"
|
|
137
|
+
return {"action": "pre_write", "target": target, "steps": steps, "verdict": verdict}
|
|
138
|
+
|
|
139
|
+
def _action_write(self, target: str, content: str, **_: Any) -> Dict[str, Any]:
|
|
140
|
+
steps: List[Dict[str, Any]] = []
|
|
141
|
+
steps.append(
|
|
142
|
+
self._safe_call(
|
|
143
|
+
"sin_preflight",
|
|
144
|
+
lambda: _preflight(self.repo_root, "sin_write", {"path": target}),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
steps.append(
|
|
148
|
+
self._safe_call(
|
|
149
|
+
"sin_write",
|
|
150
|
+
lambda: _write_file(self.repo_root, target, content),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
steps.append(
|
|
154
|
+
self._safe_call(
|
|
155
|
+
"sin_hashline_validate",
|
|
156
|
+
lambda: {"ok": True, "note": "no patch supplied; skipped"},
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
verdict = "PROCEED" if steps[1].get("ok") else "BLOCK"
|
|
161
|
+
return {
|
|
162
|
+
"action": "write",
|
|
163
|
+
"target": target,
|
|
164
|
+
"steps": steps,
|
|
165
|
+
"verdict": verdict,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def _action_post_write(self, target: str, **_: Any) -> Dict[str, Any]:
|
|
169
|
+
steps: List[Dict[str, Any]] = []
|
|
170
|
+
steps.append(
|
|
171
|
+
self._safe_call(
|
|
172
|
+
"sin_preflight",
|
|
173
|
+
lambda: _preflight(self.repo_root, "sin_write", {"path": target}),
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
steps.append(self._safe_call("codocs_check", lambda: _codocs_check(self.repo_root)))
|
|
177
|
+
steps.append(self._safe_call("pytest_collect", lambda: _pytest_collect(self.repo_root)))
|
|
178
|
+
|
|
179
|
+
verdict = "READY" if all(s.get("ok") for s in steps) else "FIX_FIRST"
|
|
180
|
+
return {"action": "post_write", "target": target, "steps": steps, "verdict": verdict}
|
|
181
|
+
|
|
182
|
+
def _action_pre_commit(
|
|
183
|
+
self,
|
|
184
|
+
message: str = "",
|
|
185
|
+
checkpoint_name: str = "",
|
|
186
|
+
base: str = "main",
|
|
187
|
+
head: str = "HEAD",
|
|
188
|
+
**_: Any,
|
|
189
|
+
) -> Dict[str, Any]:
|
|
190
|
+
steps: List[Dict[str, Any]] = []
|
|
191
|
+
|
|
192
|
+
# 1. checkpoint
|
|
193
|
+
name = checkpoint_name or f"pre-commit-{_now_compact()}"
|
|
194
|
+
steps.append(self._safe_call("sin_checkpoint", lambda: _checkpoint(self.repo_root, name)))
|
|
195
|
+
|
|
196
|
+
# 2. git status
|
|
197
|
+
steps.append(self._safe_call("git_status", lambda: _git_status(self.repo_root)))
|
|
198
|
+
|
|
199
|
+
# 3. codocs check
|
|
200
|
+
steps.append(self._safe_call("codocs_check", lambda: _codocs_check(self.repo_root)))
|
|
201
|
+
|
|
202
|
+
# 4. ceo-audit (cached 5 min)
|
|
203
|
+
audit = self._cached_ceo_audit("QUICK", base, head)
|
|
204
|
+
steps.append({"name": "ceo_audit", **audit})
|
|
205
|
+
|
|
206
|
+
# Suggested message
|
|
207
|
+
suggested = message or _suggest_commit_message(self.repo_root)
|
|
208
|
+
|
|
209
|
+
blockers = []
|
|
210
|
+
if not audit.get("ok") or (audit.get("grade") or "").upper() == "F":
|
|
211
|
+
blockers.append("ceo-audit grade F — fix critical issues first")
|
|
212
|
+
codocs_step = next((s for s in steps if s.get("name") == "codocs_check"), None)
|
|
213
|
+
if codocs_step and codocs_step.get("broken", 0) > 0:
|
|
214
|
+
blockers.append(f"codocs: {codocs_step['broken']} broken .doc.md reference(s)")
|
|
215
|
+
|
|
216
|
+
verdict = "READY_TO_COMMIT" if not blockers else "FIX_FIRST"
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"action": "pre_commit",
|
|
220
|
+
"steps": steps,
|
|
221
|
+
"verdict": verdict,
|
|
222
|
+
"suggested_message": suggested,
|
|
223
|
+
"blockers": blockers,
|
|
224
|
+
"base": base,
|
|
225
|
+
"head": head,
|
|
226
|
+
"timestamp": _now_iso(),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def _action_refactor(
|
|
230
|
+
self,
|
|
231
|
+
target: str,
|
|
232
|
+
checkpoint_name: str = "",
|
|
233
|
+
**_: Any,
|
|
234
|
+
) -> Dict[str, Any]:
|
|
235
|
+
steps: List[Dict[str, Any]] = []
|
|
236
|
+
|
|
237
|
+
name = checkpoint_name or f"pre-refactor-{_now_compact()}"
|
|
238
|
+
steps.append(self._safe_call("sin_checkpoint", lambda: _checkpoint(self.repo_root, name)))
|
|
239
|
+
steps.append(
|
|
240
|
+
self._safe_call(
|
|
241
|
+
"gitnexus_impact",
|
|
242
|
+
lambda: _gitnexus_impact(self.repo_root, target),
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
steps.append(
|
|
246
|
+
self._safe_call(
|
|
247
|
+
"gitnexus_detect_changes",
|
|
248
|
+
lambda: _gitnexus_detect_changes(self.repo_root),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
impact_step = next((s for s in steps if s.get("name") == "gitnexus_impact"), None)
|
|
253
|
+
risk = impact_step.get("risk") if impact_step else None
|
|
254
|
+
if risk in ("HIGH", "CRITICAL"):
|
|
255
|
+
verdict = "FIX_FIRST"
|
|
256
|
+
elif risk == "MEDIUM":
|
|
257
|
+
verdict = "REVIEW"
|
|
258
|
+
else:
|
|
259
|
+
verdict = "PROCEED"
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
"action": "refactor",
|
|
263
|
+
"target": target,
|
|
264
|
+
"steps": steps,
|
|
265
|
+
"verdict": verdict,
|
|
266
|
+
"checkpoint_name": name,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def _action_session_warmup(self, **_: Any) -> Dict[str, Any]:
|
|
270
|
+
steps: List[Dict[str, Any]] = []
|
|
271
|
+
steps.append(self._safe_call("sin_session_warmup", lambda: _session_warmup(self.repo_root)))
|
|
272
|
+
warm = steps[0] if steps else {}
|
|
273
|
+
verdict = warm.get("session_recommendation", "READY — proceed with coding")
|
|
274
|
+
return {
|
|
275
|
+
"action": "session_warmup",
|
|
276
|
+
"steps": steps,
|
|
277
|
+
"verdict": verdict,
|
|
278
|
+
"branch": warm.get("branch"),
|
|
279
|
+
"ceo_audit_grade": warm.get("ceo_audit_grade"),
|
|
280
|
+
"top_risks": warm.get("top_risks", []),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# ── helpers ─────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
def _cached_ceo_audit(self, profile: str, base: str, head: str) -> Dict[str, Any]:
|
|
286
|
+
"""Run ceo-audit, caching the result for 5 minutes per triple."""
|
|
287
|
+
key = (profile, base, head)
|
|
288
|
+
now = time.time()
|
|
289
|
+
if key in self._cache:
|
|
290
|
+
ts, data = self._cache[key]
|
|
291
|
+
if (now - ts) < _CEO_AUDIT_CACHE_TTL:
|
|
292
|
+
return {**data, "cache_hit": True}
|
|
293
|
+
data = _ceo_audit_quick(self.repo_root, profile)
|
|
294
|
+
self._cache[key] = (now, data)
|
|
295
|
+
return data
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _safe_call(name: str, fn: Any) -> Dict[str, Any]:
|
|
299
|
+
try:
|
|
300
|
+
return {"name": name, "ok": True, **(fn() or {})}
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
return {"name": name, "ok": False, "error": str(exc)}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ── module-level helpers (call into the consolidated modules) ────────
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _read(repo_root: Path, target: str) -> Dict[str, Any]:
|
|
309
|
+
if not target:
|
|
310
|
+
return {"ok": False, "error": "no target"}
|
|
311
|
+
from . import (
|
|
312
|
+
preflight, # noqa: F401 (import keeps relative namespace hot)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
"ok": True,
|
|
317
|
+
"resolved": str(target),
|
|
318
|
+
"note": "delegated to sin_read; see mcp_server.sin_read",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _preflight(repo_root: Path, tool_name: str, tool_input: Dict[str, Any]) -> Dict[str, Any]:
|
|
323
|
+
from .preflight import PreflightChecker
|
|
324
|
+
|
|
325
|
+
return PreflightChecker(repo_root=repo_root).check(tool_name, tool_input)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _write_file(repo_root: Path, target: str, content: str) -> Dict[str, Any]:
|
|
329
|
+
|
|
330
|
+
# Note: calling the MCP tool directly is a circular dep risk; we just
|
|
331
|
+
# do an atomic file write with the same logic for the workflow use case.
|
|
332
|
+
p = repo_root / target
|
|
333
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
p.write_text(content, encoding="utf-8")
|
|
335
|
+
return {"ok": True, "path": str(p), "chars": len(content)}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _codocs_check(repo_root: Path) -> Dict[str, Any]:
|
|
339
|
+
from . import codocs
|
|
340
|
+
|
|
341
|
+
broken = codocs.find_broken(str(repo_root))
|
|
342
|
+
return {
|
|
343
|
+
"ok": not bool(broken),
|
|
344
|
+
"broken": len(broken),
|
|
345
|
+
"items": [b.to_dict() for b in broken][:10],
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _pytest_collect(repo_root: Path) -> Dict[str, Any]:
|
|
350
|
+
if not (repo_root / "tests").exists() and not (repo_root / "test").exists():
|
|
351
|
+
return {"ok": True, "skipped": True, "note": "no tests/ dir"}
|
|
352
|
+
try:
|
|
353
|
+
proc = subprocess.run(
|
|
354
|
+
["python3", "-m", "pytest", "--collect-only", "-q"],
|
|
355
|
+
cwd=repo_root,
|
|
356
|
+
capture_output=True,
|
|
357
|
+
text=True,
|
|
358
|
+
timeout=15,
|
|
359
|
+
)
|
|
360
|
+
return {
|
|
361
|
+
"ok": proc.returncode == 0,
|
|
362
|
+
"returncode": proc.returncode,
|
|
363
|
+
"stdout_tail": proc.stdout[-500:],
|
|
364
|
+
}
|
|
365
|
+
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
366
|
+
return {"ok": True, "skipped": True, "error": str(exc)}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _checkpoint(repo_root: Path, name: str) -> Dict[str, Any]:
|
|
370
|
+
from .checkpoint import Checkpointer
|
|
371
|
+
|
|
372
|
+
return Checkpointer(repo_root=repo_root).create(name)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _git_status(repo_root: Path) -> Dict[str, Any]:
|
|
376
|
+
try:
|
|
377
|
+
proc = subprocess.run(
|
|
378
|
+
["git", "status", "--porcelain"],
|
|
379
|
+
cwd=repo_root,
|
|
380
|
+
capture_output=True,
|
|
381
|
+
text=True,
|
|
382
|
+
timeout=5,
|
|
383
|
+
)
|
|
384
|
+
changes = proc.stdout.strip().splitlines() if proc.stdout.strip() else []
|
|
385
|
+
return {"ok": True, "clean": not changes, "changes_count": len(changes)}
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
return {"ok": False, "error": str(exc)}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _ceo_audit_quick(repo_root: Path, profile: str) -> Dict[str, Any]:
|
|
391
|
+
try:
|
|
392
|
+
sin_bin = shutil.which("sin") or _CEO_AUDIT_FALLBACK
|
|
393
|
+
if not Path(sin_bin).exists():
|
|
394
|
+
return {"ok": False, "error": "sin CLI not installed"}
|
|
395
|
+
proc = subprocess.run(
|
|
396
|
+
[sin_bin, "ceo-audit", "run", str(repo_root), f"--profile={profile}", "--json"],
|
|
397
|
+
capture_output=True,
|
|
398
|
+
text=True,
|
|
399
|
+
timeout=180,
|
|
400
|
+
)
|
|
401
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
402
|
+
data = json.loads(proc.stdout)
|
|
403
|
+
return {"ok": True, "grade": data.get("grade"), "report_path": data.get("report_path")}
|
|
404
|
+
return {"ok": False, "error": proc.stderr[-300:]}
|
|
405
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
|
406
|
+
return {"ok": False, "error": str(exc)}
|
|
407
|
+
except Exception as exc:
|
|
408
|
+
return {"ok": False, "error": str(exc)}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _gitnexus_impact(repo_root: Path, symbol: str) -> Dict[str, Any]:
|
|
412
|
+
"""Best-effort gitnexus_impact. Returns empty dict on missing CLI."""
|
|
413
|
+
if not symbol:
|
|
414
|
+
return {"ok": False, "error": "no target"}
|
|
415
|
+
try:
|
|
416
|
+
# Use the gitnexus Python wrapper if available, else shell out.
|
|
417
|
+
from sin_code_bundle import gitnexus # type: ignore
|
|
418
|
+
|
|
419
|
+
data = gitnexus.get_impact(symbol)
|
|
420
|
+
return {
|
|
421
|
+
"ok": True,
|
|
422
|
+
"risk": data.get("risk"),
|
|
423
|
+
"affected_count": len(data.get("affected", [])),
|
|
424
|
+
}
|
|
425
|
+
except ImportError:
|
|
426
|
+
pass
|
|
427
|
+
except Exception as exc:
|
|
428
|
+
return {"ok": False, "error": str(exc)}
|
|
429
|
+
|
|
430
|
+
bin_path = shutil.which("gitnexus")
|
|
431
|
+
if not bin_path:
|
|
432
|
+
return {"ok": False, "error": "gitnexus not installed"}
|
|
433
|
+
try:
|
|
434
|
+
proc = subprocess.run(
|
|
435
|
+
[bin_path, "impact", json.dumps({"target": symbol})],
|
|
436
|
+
cwd=repo_root,
|
|
437
|
+
capture_output=True,
|
|
438
|
+
text=True,
|
|
439
|
+
timeout=15,
|
|
440
|
+
)
|
|
441
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
442
|
+
data = json.loads(proc.stdout)
|
|
443
|
+
return {
|
|
444
|
+
"ok": True,
|
|
445
|
+
"risk": data.get("risk"),
|
|
446
|
+
"affected_count": len(data.get("affected", [])),
|
|
447
|
+
}
|
|
448
|
+
return {"ok": False, "error": proc.stderr[-200:]}
|
|
449
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
|
450
|
+
return {"ok": False, "error": str(exc)}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _gitnexus_detect_changes(repo_root: Path) -> Dict[str, Any]:
|
|
454
|
+
try:
|
|
455
|
+
from sin_code_bundle import gitnexus # type: ignore
|
|
456
|
+
|
|
457
|
+
data = gitnexus.get_detect_changes()
|
|
458
|
+
return {"ok": True, "changes_count": len(data.get("changes", []))}
|
|
459
|
+
except ImportError:
|
|
460
|
+
pass
|
|
461
|
+
except Exception as exc:
|
|
462
|
+
return {"ok": False, "error": str(exc)}
|
|
463
|
+
|
|
464
|
+
bin_path = shutil.which("gitnexus")
|
|
465
|
+
if not bin_path:
|
|
466
|
+
return {"ok": False, "error": "gitnexus not installed"}
|
|
467
|
+
try:
|
|
468
|
+
proc = subprocess.run(
|
|
469
|
+
[bin_path, "detect-changes", "--json"],
|
|
470
|
+
cwd=repo_root,
|
|
471
|
+
capture_output=True,
|
|
472
|
+
text=True,
|
|
473
|
+
timeout=10,
|
|
474
|
+
)
|
|
475
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
476
|
+
data = json.loads(proc.stdout)
|
|
477
|
+
return {"ok": True, "changes_count": len(data.get("changes", []))}
|
|
478
|
+
return {"ok": False, "error": proc.stderr[-200:]}
|
|
479
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
|
480
|
+
return {"ok": False, "error": str(exc)}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _session_warmup(repo_root: Path) -> Dict[str, Any]:
|
|
484
|
+
from .session_warmup import SessionWarmup
|
|
485
|
+
|
|
486
|
+
return SessionWarmup(repo_root=repo_root).warmup()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── helpers for suggested commit message ────────────────────────────
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _suggest_commit_message(repo_root: Path) -> str:
|
|
493
|
+
"""Best-effort Conventional Commits message from `git diff --stat`."""
|
|
494
|
+
try:
|
|
495
|
+
proc = subprocess.run(
|
|
496
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
497
|
+
cwd=repo_root,
|
|
498
|
+
capture_output=True,
|
|
499
|
+
text=True,
|
|
500
|
+
timeout=5,
|
|
501
|
+
)
|
|
502
|
+
files = [f for f in proc.stdout.strip().splitlines() if f]
|
|
503
|
+
except Exception:
|
|
504
|
+
files = []
|
|
505
|
+
|
|
506
|
+
if not files:
|
|
507
|
+
return "chore: empty commit"
|
|
508
|
+
|
|
509
|
+
# Heuristics for the type
|
|
510
|
+
test_only = all(_is_test_file(f) for f in files)
|
|
511
|
+
docs_only = all(_is_doc_file(f) for f in files)
|
|
512
|
+
new_file = any(f.startswith("+") or "/new_" in f for f in files)
|
|
513
|
+
|
|
514
|
+
if test_only:
|
|
515
|
+
return f"test: update tests for {files[0]}"
|
|
516
|
+
if docs_only:
|
|
517
|
+
return f"docs: update {files[0]}"
|
|
518
|
+
if new_file:
|
|
519
|
+
return f"feat: add {Path(files[0]).name}"
|
|
520
|
+
return f"chore: update {len(files)} file(s)"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
_CC_TYPES_PATTERN = re.compile(r"^(feat|fix|docs|chore|style|test|refactor|perf|ci|build)")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _is_test_file(path: str) -> bool:
|
|
527
|
+
p = path.lower()
|
|
528
|
+
return "/tests/" in p or "/test/" in p or p.startswith("test_") or p.endswith("_test.py")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _is_doc_file(path: str) -> bool:
|
|
532
|
+
p = path.lower()
|
|
533
|
+
return p.endswith(".md") or p.endswith(".rst") or p.endswith(".txt") or "/docs/" in p
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _now_compact() -> str:
|
|
537
|
+
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _now_iso() -> str:
|
|
541
|
+
return datetime.now(timezone.utc).isoformat()
|
sin_code_bundle/rtk.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""RTK bridge.
|
|
3
|
+
|
|
4
|
+
RTK (https://github.com/rtk-ai/rtk) is an *upstream* tool distributed as an
|
|
5
|
+
Apache-2.0 single Rust binary. It is a CLI proxy that filters and compresses
|
|
6
|
+
command output (ls, grep, git, test runners, ...) before it reaches an LLM,
|
|
7
|
+
cutting token consumption by 60-90%.
|
|
8
|
+
|
|
9
|
+
Unlike GitNexus or MarkItDown, RTK is **not** an MCP server: it integrates with
|
|
10
|
+
each coder agent through that agent's own hook / plugin mechanism, installed by
|
|
11
|
+
RTK's native ``rtk init`` command. We therefore never vendor RTK; the bridge
|
|
12
|
+
simply discovers the upstream ``rtk`` binary and drives ``rtk init`` for each
|
|
13
|
+
agent so the whole SIN-Code coder fleet benefits from the same token savings.
|
|
14
|
+
|
|
15
|
+
Docs: rtk.doc.md
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
# ── RTK Bridge: Token-Saving Proxy ────────────────────────────────────
|
|
26
|
+
# RTK is a single Rust binary from https://github.com/rtk-ai/rtk. It is
|
|
27
|
+
# a *proxy*: it sits in front of noisy shell commands (ls, grep, git,
|
|
28
|
+
# cargo, pytest, etc.) and rewrites/compacts their output before an LLM
|
|
29
|
+
# ever sees it, claiming 60-90% token reduction. Because each supported
|
|
30
|
+
# agent (OpenCode, Codex, Hermes) installs RTK via its own hook/plugin
|
|
31
|
+
# mechanism, our job is to:
|
|
32
|
+
# 1. detect the `rtk` binary on PATH,
|
|
33
|
+
# 2. invoke `rtk init` with the right flag for each agent,
|
|
34
|
+
# 3. expose `gain()` for token-savings diagnostics.
|
|
35
|
+
# We do NOT shell-wrap individual commands — that's RTK's job once it
|
|
36
|
+
# has injected itself.
|
|
37
|
+
|
|
38
|
+
RTK_BINARY = "rtk"
|
|
39
|
+
|
|
40
|
+
# How RTK wires itself into each supported coder agent. Mirrors the upstream
|
|
41
|
+
# `rtk init` matrix (see RTK README "Supported AI Tools").
|
|
42
|
+
_INIT_ARGS: dict[str, list[str]] = {
|
|
43
|
+
"opencode": ["init", "-g", "--opencode"],
|
|
44
|
+
"codex": ["init", "-g", "--codex"],
|
|
45
|
+
"hermes": ["init", "--agent", "hermes"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
AGENTS = tuple(_INIT_ARGS.keys())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RtkError(RuntimeError):
|
|
52
|
+
"""Raised when RTK is unavailable or an init command fails."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class RtkEnv:
|
|
57
|
+
"""Resolved runtime environment for invoking RTK."""
|
|
58
|
+
|
|
59
|
+
rtk: str | None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def available(self) -> bool:
|
|
63
|
+
"""True iff an ``rtk`` binary was found on PATH."""
|
|
64
|
+
return bool(self.rtk)
|
|
65
|
+
|
|
66
|
+
def base_cmd(self) -> str:
|
|
67
|
+
"""Return the absolute path of the ``rtk`` binary, or raise RtkError.
|
|
68
|
+
|
|
69
|
+
This is the single gate every RTK invocation in the bundle flows
|
|
70
|
+
through, so the install hint is raised once and in one place.
|
|
71
|
+
"""
|
|
72
|
+
if not self.rtk:
|
|
73
|
+
raise RtkError(
|
|
74
|
+
"`rtk` not found on PATH. Install it with `brew install rtk`, "
|
|
75
|
+
"`cargo install --git https://github.com/rtk-ai/rtk`, or the "
|
|
76
|
+
"install script at https://github.com/rtk-ai/rtk. The bundle "
|
|
77
|
+
"does not vendor RTK."
|
|
78
|
+
)
|
|
79
|
+
return self.rtk
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect_env() -> RtkEnv:
|
|
83
|
+
"""Probe PATH for the ``rtk`` binary (no other I/O)."""
|
|
84
|
+
return RtkEnv(rtk=shutil.which(RTK_BINARY))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def init_args(agent: str) -> list[str]:
|
|
88
|
+
"""Return the upstream ``rtk init`` arguments for an agent."""
|
|
89
|
+
try:
|
|
90
|
+
return list(_INIT_ARGS[agent])
|
|
91
|
+
except KeyError:
|
|
92
|
+
raise RtkError(f"Unknown agent: {agent!r}. Known: {', '.join(AGENTS)}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _run(
|
|
96
|
+
cmd: list[str], timeout: int = 120
|
|
97
|
+
) -> str: # 120s = 2min; rtk init/rewrites are sub-second, 2min leaves headroom for slow CI disks
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
100
|
+
except FileNotFoundError as exc: # pragma: no cover - guarded by detect_env
|
|
101
|
+
raise RtkError(f"Failed to execute {cmd[0]!r}: {exc}") from exc
|
|
102
|
+
except subprocess.TimeoutExpired as exc: # pragma: no cover - timing dependent
|
|
103
|
+
raise RtkError(f"rtk timed out after {timeout}s") from exc
|
|
104
|
+
if proc.returncode != 0:
|
|
105
|
+
raise RtkError(f"`{' '.join(cmd)}` failed ({proc.returncode}): {proc.stderr.strip()}")
|
|
106
|
+
return proc.stdout.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── Setup & Diagnostics: install + measure token savings ──────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def setup_agents(
|
|
113
|
+
agents: list[str] | None = None,
|
|
114
|
+
env: RtkEnv | None = None,
|
|
115
|
+
) -> dict[str, str]:
|
|
116
|
+
"""Run ``rtk init`` for each agent so it intercepts/compacts their commands.
|
|
117
|
+
|
|
118
|
+
Returns a mapping of agent -> the rtk command that was executed.
|
|
119
|
+
"""
|
|
120
|
+
env = env or detect_env()
|
|
121
|
+
rtk = env.base_cmd()
|
|
122
|
+
chosen = agents or list(AGENTS)
|
|
123
|
+
done: dict[str, str] = {}
|
|
124
|
+
for agent in chosen:
|
|
125
|
+
cmd = [rtk, *init_args(agent)]
|
|
126
|
+
_run(cmd)
|
|
127
|
+
done[agent] = " ".join(cmd)
|
|
128
|
+
return done
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def gain(env: RtkEnv | None = None) -> dict[str, Any]:
|
|
132
|
+
"""Return RTK's token-savings stats as JSON (best-effort)."""
|
|
133
|
+
env = env or detect_env()
|
|
134
|
+
rtk = env.base_cmd()
|
|
135
|
+
out = _run([rtk, "gain", "--all", "--format", "json"])
|
|
136
|
+
try:
|
|
137
|
+
import json # local import keeps the top of the file dependency-free
|
|
138
|
+
|
|
139
|
+
return json.loads(out or "{}")
|
|
140
|
+
except (ValueError, TypeError):
|
|
141
|
+
# Fallback for older RTK builds that don't speak --format json yet.
|
|
142
|
+
# We still return *something* so callers can show the raw output
|
|
143
|
+
# instead of an opaque exception.
|
|
144
|
+
return {"raw": out}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def doctor() -> dict[str, Any]:
|
|
148
|
+
"""Report RTK availability for diagnostics."""
|
|
149
|
+
env = detect_env()
|
|
150
|
+
return {
|
|
151
|
+
"available": env.available,
|
|
152
|
+
"binary": env.rtk,
|
|
153
|
+
"agents": list(AGENTS),
|
|
154
|
+
}
|