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,52 @@
|
|
|
1
|
+
"""Hardened subprocess + input-sanitization helpers shared by all subsystems."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT = 600 # seconds — never run unbounded
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SafetyError(RuntimeError):
|
|
13
|
+
"""Raised when a safety invariant is violated (timeout, unsafe cmd shape, …)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_checked(
|
|
17
|
+
cmd: Sequence[str],
|
|
18
|
+
cwd: Optional[Path] = None,
|
|
19
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
20
|
+
allow_shell: bool = False,
|
|
21
|
+
) -> subprocess.CompletedProcess:
|
|
22
|
+
"""Run a subprocess with a mandatory timeout and no shell by default."""
|
|
23
|
+
if not allow_shell and not isinstance(cmd, (list, tuple)):
|
|
24
|
+
raise SafetyError("cmd must be a list/tuple unless allow_shell=True")
|
|
25
|
+
try:
|
|
26
|
+
return subprocess.run(
|
|
27
|
+
cmd,
|
|
28
|
+
cwd=str(cwd) if cwd else None,
|
|
29
|
+
shell=allow_shell,
|
|
30
|
+
timeout=timeout,
|
|
31
|
+
check=False,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
)
|
|
35
|
+
except subprocess.TimeoutExpired as exc:
|
|
36
|
+
raise SafetyError(f"command timed out after {timeout}s: {cmd}") from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sanitize_prompt(
|
|
40
|
+
text: str, max_len: int = 8000
|
|
41
|
+
) -> str: # 8000 chars ≈ 2K tokens; fits LLM context without flooding
|
|
42
|
+
"""Neutralize obvious prompt-injection markers in untrusted task text."""
|
|
43
|
+
if len(text) > max_len:
|
|
44
|
+
text = text[:max_len] + "\n...[truncated]"
|
|
45
|
+
safe_lines = []
|
|
46
|
+
for line in text.splitlines():
|
|
47
|
+
low = line.strip().lower()
|
|
48
|
+
if low.startswith(("system:", "developer:", "ignore previous", "you are now")):
|
|
49
|
+
safe_lines.append("[redacted suspicious instruction]")
|
|
50
|
+
else:
|
|
51
|
+
safe_lines.append(line)
|
|
52
|
+
return "\n".join(safe_lines)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Purpose: One-call session context primer.
|
|
2
|
+
|
|
3
|
+
Docs: session_warmup.doc.md
|
|
4
|
+
|
|
5
|
+
Returns a snapshot of the current repository: branch, git state, CoDocs
|
|
6
|
+
coverage, ceo-audit grade (cached), top risks, and a session-level
|
|
7
|
+
recommendation ("ready to code" vs "fix first"). Designed to be the first
|
|
8
|
+
call an agent makes at the start of a session.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
# Hard-coded fallback for the dev-machine layout (AGENTS.md).
|
|
21
|
+
_CEO_AUDIT_FALLBACK = "/Users/jeremy/.local/bin/sin"
|
|
22
|
+
_ROLLBACK_FALLBACK = "/Users/jeremy/Library/Python/3.14/bin/sin-honcho-rollback"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _human_age(seconds: int) -> str:
|
|
26
|
+
"""Format a duration in seconds as a short human-readable string.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> _human_age(45)
|
|
30
|
+
'45s'
|
|
31
|
+
>>> _human_age(3700)
|
|
32
|
+
'1h 1m'
|
|
33
|
+
>>> _human_age(90000)
|
|
34
|
+
'1d 1h'
|
|
35
|
+
"""
|
|
36
|
+
if seconds < 0:
|
|
37
|
+
return "0s"
|
|
38
|
+
if seconds < 60:
|
|
39
|
+
return f"{seconds}s"
|
|
40
|
+
minutes, sec = divmod(seconds, 60)
|
|
41
|
+
if minutes < 60:
|
|
42
|
+
return f"{minutes}m {sec}s"
|
|
43
|
+
hours, m = divmod(minutes, 60)
|
|
44
|
+
if hours < 24:
|
|
45
|
+
return f"{hours}h {m}m"
|
|
46
|
+
days, h = divmod(hours, 24)
|
|
47
|
+
return f"{days}d {h}h"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionWarmup:
|
|
51
|
+
"""Assemble session context in a single call.
|
|
52
|
+
|
|
53
|
+
Runs 5 independent signals (git state, CoDocs coverage, ceo-audit grade,
|
|
54
|
+
top-risk file scan, last commit time) and returns a structured summary
|
|
55
|
+
with a single ``session_recommendation`` field — so the agent can decide
|
|
56
|
+
"ready" vs "fix first" in one read.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, repo_root: Optional[Path] = None) -> None:
|
|
60
|
+
self.repo_root = Path(repo_root) if repo_root else Path.cwd()
|
|
61
|
+
|
|
62
|
+
def warmup(self) -> Dict[str, Any]:
|
|
63
|
+
"""Gather all session signals.
|
|
64
|
+
|
|
65
|
+
Returns a dict with the following keys (always present, default
|
|
66
|
+
values on failure): ``branch``, ``git_state``, ``git_changes_count``,
|
|
67
|
+
``last_commit_age``, ``codocs_coverage``, ``ceo_audit_grade``,
|
|
68
|
+
``top_risks``, ``session_recommendation``, ``signals``.
|
|
69
|
+
"""
|
|
70
|
+
signals: Dict[str, Any] = {}
|
|
71
|
+
|
|
72
|
+
# ── 1. Git state ─────────────────────────────────────────────
|
|
73
|
+
signals["git"] = self._git_state()
|
|
74
|
+
|
|
75
|
+
# ── 2. CoDocs coverage ───────────────────────────────────────
|
|
76
|
+
signals["codocs"] = self._codocs_coverage()
|
|
77
|
+
|
|
78
|
+
# ── 3. ceo-audit (best-effort, no cache here — caller's job) ─
|
|
79
|
+
signals["ceo_audit"] = self._ceo_audit_quick()
|
|
80
|
+
|
|
81
|
+
# ── 4. Top risks (file-level complexity heuristic) ───────────
|
|
82
|
+
signals["top_risks"] = self._top_risks()
|
|
83
|
+
|
|
84
|
+
# ── 5. Last commit time ──────────────────────────────────────
|
|
85
|
+
signals["last_commit"] = self._last_commit_age()
|
|
86
|
+
|
|
87
|
+
# ── Compose final summary ────────────────────────────────────
|
|
88
|
+
branch = signals["git"].get("branch") or "unknown"
|
|
89
|
+
git_clean = signals["git"].get("clean", False)
|
|
90
|
+
docs_ok = signals["codocs"].get("ok", True)
|
|
91
|
+
audit_grade = signals["ceo_audit"].get("grade") or "UNKNOWN"
|
|
92
|
+
top_risks = signals["top_risks"]
|
|
93
|
+
|
|
94
|
+
verdict = self._verdict(
|
|
95
|
+
git_clean=git_clean,
|
|
96
|
+
docs_ok=docs_ok,
|
|
97
|
+
audit_grade=audit_grade,
|
|
98
|
+
top_risks=top_risks,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"branch": branch,
|
|
103
|
+
"git_state": "clean" if git_clean else "dirty",
|
|
104
|
+
"git_changes_count": signals["git"].get("changes_count", 0),
|
|
105
|
+
"last_commit_age": signals["last_commit"].get("age_human"),
|
|
106
|
+
"codocs_coverage": {
|
|
107
|
+
"ok": docs_ok,
|
|
108
|
+
"broken": signals["codocs"].get("broken", 0),
|
|
109
|
+
"checked": signals["codocs"].get("checked", 0),
|
|
110
|
+
},
|
|
111
|
+
"ceo_audit_grade": audit_grade,
|
|
112
|
+
"ceo_audit_path": signals["ceo_audit"].get("report_path"),
|
|
113
|
+
"top_risks": top_risks[:5],
|
|
114
|
+
"session_recommendation": verdict,
|
|
115
|
+
"signals": signals,
|
|
116
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# ── helpers ─────────────────────────────────────────────────────
|
|
120
|
+
def _git_state(self) -> Dict[str, Any]:
|
|
121
|
+
try:
|
|
122
|
+
branch = (
|
|
123
|
+
subprocess.run(
|
|
124
|
+
["git", "branch", "--show-current"],
|
|
125
|
+
cwd=self.repo_root,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
timeout=5,
|
|
129
|
+
).stdout.strip()
|
|
130
|
+
or "(detached)"
|
|
131
|
+
)
|
|
132
|
+
status = subprocess.run(
|
|
133
|
+
["git", "status", "--porcelain"],
|
|
134
|
+
cwd=self.repo_root,
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
timeout=5,
|
|
138
|
+
).stdout.strip()
|
|
139
|
+
changes = status.splitlines() if status else []
|
|
140
|
+
return {
|
|
141
|
+
"ok": True,
|
|
142
|
+
"branch": branch,
|
|
143
|
+
"clean": not bool(changes),
|
|
144
|
+
"changes_count": len(changes),
|
|
145
|
+
}
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
return {"ok": False, "error": str(exc), "branch": None, "clean": False}
|
|
148
|
+
|
|
149
|
+
def _codocs_coverage(self) -> Dict[str, Any]:
|
|
150
|
+
try:
|
|
151
|
+
from . import codocs
|
|
152
|
+
|
|
153
|
+
broken = codocs.find_broken(str(self.repo_root))
|
|
154
|
+
return {"ok": not bool(broken), "broken": len(broken), "checked": "auto"}
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
return {"ok": True, "broken": 0, "error": str(exc)}
|
|
157
|
+
|
|
158
|
+
def _ceo_audit_quick(self) -> Dict[str, Any]:
|
|
159
|
+
try:
|
|
160
|
+
sin_bin = shutil.which("sin") or _CEO_AUDIT_FALLBACK
|
|
161
|
+
if not Path(sin_bin).exists():
|
|
162
|
+
return {"ok": False, "grade": None, "error": "sin CLI not installed"}
|
|
163
|
+
proc = subprocess.run(
|
|
164
|
+
[sin_bin, "ceo-audit", "run", str(self.repo_root), "--profile=QUICK", "--json"],
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
timeout=180, # 3min ceiling; QUICK profile is normally < 1min
|
|
168
|
+
)
|
|
169
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
170
|
+
data = json.loads(proc.stdout)
|
|
171
|
+
return {
|
|
172
|
+
"ok": True,
|
|
173
|
+
"grade": data.get("grade"),
|
|
174
|
+
"report_path": data.get("report_path"),
|
|
175
|
+
}
|
|
176
|
+
return {"ok": False, "grade": None, "error": proc.stderr[-300:]}
|
|
177
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
|
178
|
+
return {"ok": False, "grade": None, "error": str(exc)}
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
return {"ok": False, "grade": None, "error": str(exc)}
|
|
181
|
+
|
|
182
|
+
def _top_risks(self) -> List[Dict[str, Any]]:
|
|
183
|
+
"""Heuristic: largest 5 Python files by line count are 'top risks'.
|
|
184
|
+
|
|
185
|
+
Cheap proxy for "where could go wrong". Returns up to 5 entries with
|
|
186
|
+
``path`` and ``lines``. No external tool required.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
files: List[Dict[str, Any]] = []
|
|
190
|
+
for p in self.repo_root.rglob("*.py"):
|
|
191
|
+
rel = p.relative_to(self.repo_root)
|
|
192
|
+
if any(
|
|
193
|
+
part.startswith(".") or part in {"__pycache__", "node_modules", "venv", ".venv"}
|
|
194
|
+
for part in rel.parts
|
|
195
|
+
):
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
n = sum(1 for _ in p.open("r", encoding="utf-8", errors="ignore"))
|
|
199
|
+
except Exception:
|
|
200
|
+
continue
|
|
201
|
+
if n > 200: # Only flag meaningfully large files
|
|
202
|
+
files.append({"path": str(rel), "lines": n})
|
|
203
|
+
files.sort(key=lambda x: x["lines"], reverse=True)
|
|
204
|
+
return files[:5]
|
|
205
|
+
except Exception:
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
def _last_commit_age(self) -> Dict[str, Any]:
|
|
209
|
+
try:
|
|
210
|
+
proc = subprocess.run(
|
|
211
|
+
["git", "log", "-1", "--format=%ct"],
|
|
212
|
+
cwd=self.repo_root,
|
|
213
|
+
capture_output=True,
|
|
214
|
+
text=True,
|
|
215
|
+
timeout=5,
|
|
216
|
+
)
|
|
217
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
218
|
+
ts = int(proc.stdout.strip())
|
|
219
|
+
age_sec = int(datetime.now(timezone.utc).timestamp()) - ts
|
|
220
|
+
return {"ok": True, "age_sec": age_sec, "age_human": _human_age(age_sec)}
|
|
221
|
+
return {"ok": False, "age_human": "unknown"}
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
return {"ok": False, "error": str(exc), "age_human": "unknown"}
|
|
224
|
+
|
|
225
|
+
def _verdict(
|
|
226
|
+
self,
|
|
227
|
+
git_clean: bool,
|
|
228
|
+
docs_ok: bool,
|
|
229
|
+
audit_grade: str,
|
|
230
|
+
top_risks: List[Dict[str, Any]],
|
|
231
|
+
) -> str:
|
|
232
|
+
"""Single-line recommendation for the agent.
|
|
233
|
+
|
|
234
|
+
Logic, in order:
|
|
235
|
+
1. ceo-audit F → "BLOCK — fix critical issues first"
|
|
236
|
+
2. ceo-audit D or many broken docs → "FIX — improve before coding"
|
|
237
|
+
3. Dirty tree with > 20 changes → "STASH or COMMIT first"
|
|
238
|
+
4. Everything else → "READY — proceed with coding"
|
|
239
|
+
"""
|
|
240
|
+
grade = (audit_grade or "UNKNOWN").upper()
|
|
241
|
+
if grade == "F":
|
|
242
|
+
return "BLOCK — ceo-audit grade F. Fix critical issues first."
|
|
243
|
+
if grade == "D" or (not docs_ok and len(top_risks) > 0):
|
|
244
|
+
return "FIX — improve docs/quality before coding"
|
|
245
|
+
if not git_clean:
|
|
246
|
+
return "STASH or COMMIT first — working tree dirty"
|
|
247
|
+
return "READY — proceed with coding"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Compile portable SIN skills into each agent's native command/skill format.
|
|
3
|
+
|
|
4
|
+
One source of truth: `skills/*.md` with YAML frontmatter (name, description,
|
|
5
|
+
arguments) + a prompt body. `compile_skills()` renders them into:
|
|
6
|
+
|
|
7
|
+
- opencode -> .opencode/command/<name>.md (frontmatter: description, agent)
|
|
8
|
+
- codex -> ~/.codex/prompts/<name>.md (plain prompt, $N positional args)
|
|
9
|
+
- claude -> .claude/skills/<name>/SKILL.md (frontmatter: name, description)
|
|
10
|
+
|
|
11
|
+
This mirrors how cross-agent tools (Ulis/Nexel) keep a single prompt library in
|
|
12
|
+
sync across CLIs.
|
|
13
|
+
|
|
14
|
+
Docs: skills.doc.md
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import yaml
|
|
26
|
+
except ImportError: # pragma: no cover
|
|
27
|
+
# pyyaml is optional at import time so that ``import sin_code_bundle.skills``
|
|
28
|
+
# never fails. Parsing functions raise a clear error instead. This lets the
|
|
29
|
+
# module be loaded in slim CI environments that don't need YAML support.
|
|
30
|
+
yaml = None # type: ignore
|
|
31
|
+
|
|
32
|
+
# ── Targets & Schemas ────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
# Supported target agents. Adding a new target requires:
|
|
35
|
+
# 1. extend the ``Target`` literal
|
|
36
|
+
# 2. add a branch in :func:`render_skill`
|
|
37
|
+
# 3. decide where it writes (per-user, per-repo, etc.)
|
|
38
|
+
Target = Literal["opencode", "codex", "claude"]
|
|
39
|
+
SUPPORTED_TARGETS: tuple[Target, ...] = ("opencode", "codex", "claude")
|
|
40
|
+
|
|
41
|
+
# Matches the standard YAML frontmatter used by our skills/*.md sources.
|
|
42
|
+
# Captures (yaml_meta, body). The trailing ``.*$`` with DOTALL lets the body
|
|
43
|
+
# span newlines so multi-paragraph prompts round-trip cleanly.
|
|
44
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Skill Model ──────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Skill:
|
|
52
|
+
"""A parsed source skill (one ``skills/*.md`` file).
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
name: Stable identifier — used as the filename in every target's
|
|
56
|
+
output. Falls back to the source filename stem if the frontmatter
|
|
57
|
+
omits it.
|
|
58
|
+
description: One-line summary shown in command pickers.
|
|
59
|
+
body: Prompt body, with YAML frontmatter stripped.
|
|
60
|
+
arguments: List of argument descriptors (each a dict with at least a
|
|
61
|
+
``"name"`` key). Used to template ``{{name}}`` placeholders when
|
|
62
|
+
rendering to codex (which expects positional ``$N``).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
description: str
|
|
67
|
+
body: str
|
|
68
|
+
arguments: list[dict] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def parse(cls, path: Path) -> "Skill":
|
|
72
|
+
"""Parse a single ``skills/*.md`` file into a :class:`Skill`.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: Filesystem path to a markdown file with YAML frontmatter
|
|
76
|
+
delimited by ``---`` fences.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The parsed :class:`Skill`.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: If the file is missing the YAML frontmatter block.
|
|
83
|
+
RuntimeError: If PyYAML is not installed in the environment.
|
|
84
|
+
"""
|
|
85
|
+
text = path.read_text(encoding="utf-8")
|
|
86
|
+
m = _FRONTMATTER_RE.match(text)
|
|
87
|
+
if not m:
|
|
88
|
+
raise ValueError(f"{path} is missing YAML frontmatter")
|
|
89
|
+
if yaml is None:
|
|
90
|
+
raise RuntimeError("pyyaml is required to parse skills")
|
|
91
|
+
meta = yaml.safe_load(m.group(1)) or {}
|
|
92
|
+
return cls(
|
|
93
|
+
name=meta.get("name", path.stem),
|
|
94
|
+
description=meta.get("description", ""),
|
|
95
|
+
body=m.group(2).strip(),
|
|
96
|
+
arguments=meta.get("arguments", []) or [],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Rendering ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _body_for_codex(skill: Skill) -> str:
|
|
104
|
+
"""Codex prompts use positional $1, $2 ... — map {{arg}} -> $N."""
|
|
105
|
+
body = skill.body
|
|
106
|
+
for i, arg in enumerate(skill.arguments, start=1):
|
|
107
|
+
body = body.replace("{{" + arg["name"] + "}}", f"${i}")
|
|
108
|
+
return body
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_skill(skill: Skill, target: Target) -> tuple[str, str]:
|
|
112
|
+
"""Return (relative_output_path, file_content) for a target agent.
|
|
113
|
+
|
|
114
|
+
The relative output path is interpreted relative to the base directory
|
|
115
|
+
chosen by :func:`compile_skills` (per-user for codex, per-repo for the
|
|
116
|
+
others). The path includes a trailing filename and parent directory so
|
|
117
|
+
callers only have to ``open`` it for writing.
|
|
118
|
+
"""
|
|
119
|
+
if target == "opencode":
|
|
120
|
+
# ``agent: build`` is the default opencode sub-agent that runs custom
|
|
121
|
+
# commands; we hard-code it here because all our skills target it.
|
|
122
|
+
fm = f"---\ndescription: {skill.description}\nagent: build\n---\n\n"
|
|
123
|
+
return f".opencode/command/{skill.name}.md", fm + skill.body + "\n"
|
|
124
|
+
|
|
125
|
+
if target == "codex":
|
|
126
|
+
# codex uses ``prompts/`` (not dot-prefixed) and a flat layout under
|
|
127
|
+
# ~/.codex — no frontmatter, just the templated body.
|
|
128
|
+
return f"prompts/{skill.name}.md", _body_for_codex(skill) + "\n"
|
|
129
|
+
|
|
130
|
+
if target == "claude":
|
|
131
|
+
# claude skills live in a per-skill subdirectory with a fixed
|
|
132
|
+
# ``SKILL.md`` name — that's the convention their loader expects.
|
|
133
|
+
fm = f"---\nname: {skill.name}\ndescription: {skill.description}\n---\n\n"
|
|
134
|
+
return f".claude/skills/{skill.name}/SKILL.md", fm + skill.body + "\n"
|
|
135
|
+
|
|
136
|
+
raise ValueError(f"unknown target: {target}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Discovery & Compilation ──────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_skills(source_dir: Path = Path("skills")) -> list[Skill]:
|
|
143
|
+
"""Load every ``*.md`` file under ``source_dir`` as a :class:`Skill`.
|
|
144
|
+
|
|
145
|
+
Returns an empty list if ``source_dir`` does not exist (e.g. running
|
|
146
|
+
in a clone that hasn't checked out the skills library). The result is
|
|
147
|
+
sorted by filename for deterministic, diff-friendly output ordering.
|
|
148
|
+
"""
|
|
149
|
+
if not source_dir.exists():
|
|
150
|
+
return []
|
|
151
|
+
return [Skill.parse(p) for p in sorted(source_dir.glob("*.md"))]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def compile_skills(
|
|
155
|
+
target: Target,
|
|
156
|
+
source_dir: Path = Path("skills"),
|
|
157
|
+
out_root: Path = Path("."),
|
|
158
|
+
dry_run: bool = False,
|
|
159
|
+
) -> list[Path]:
|
|
160
|
+
"""Compile every source skill into `target`'s native format.
|
|
161
|
+
|
|
162
|
+
For codex, paths are written under the user's ~/.codex/; for opencode and
|
|
163
|
+
claude they are written relative to the repo (out_root).
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
target: Which agent to render for.
|
|
167
|
+
source_dir: Directory containing ``*.md`` source skills.
|
|
168
|
+
out_root: Base directory for opencode/claude outputs (ignored for
|
|
169
|
+
codex, which always lands under ``~/.codex``).
|
|
170
|
+
dry_run: If True, return the would-be destination paths without
|
|
171
|
+
writing files or creating parent directories.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A list of destination paths in the same order as the source skills.
|
|
175
|
+
"""
|
|
176
|
+
written: list[Path] = []
|
|
177
|
+
# codex installs prompts per-user (other agents are per-repo), so its
|
|
178
|
+
# base dir is fixed to ~/.codex regardless of out_root.
|
|
179
|
+
base = Path.home() / ".codex" if target == "codex" else out_root
|
|
180
|
+
|
|
181
|
+
for skill in load_skills(source_dir):
|
|
182
|
+
rel, content = render_skill(skill, target)
|
|
183
|
+
dest = base / rel
|
|
184
|
+
written.append(dest)
|
|
185
|
+
if not dry_run:
|
|
186
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
dest.write_text(content, encoding="utf-8")
|
|
188
|
+
return written
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Purpose: Unified code archaeology — graph + cross-source context in 1 call.
|
|
2
|
+
# Docs: symbol_resolve.doc.md
|
|
3
|
+
"""Consolidates gitnexus_query + gitnexus_context + gitnexus_impact +
|
|
4
|
+
gitnexus_detect_changes. Also integrates sin-context-bridge for cross-source
|
|
5
|
+
context (memory, code knowledge graph).
|
|
6
|
+
|
|
7
|
+
Docs: symbol_resolve.doc.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
# Default binary names we look up on PATH. Hard-coded fallback paths exist
|
|
19
|
+
# for the dev machine layout documented in AGENTS.md so the tool keeps
|
|
20
|
+
# working when PATH is restricted (e.g. inside the MCP stdio process).
|
|
21
|
+
_GITNEXUS_FALLBACK = "/Users/jeremy/Library/Python/3.14/bin/gitnexus"
|
|
22
|
+
_CONTEXT_BRIDGE_FALLBACK = "/Users/jeremy/Library/Python/3.14/bin/sin-context-bridge"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SymbolResolver:
|
|
26
|
+
"""One-call code archaeology for any symbol.
|
|
27
|
+
|
|
28
|
+
Fans out to gitnexus primitives + sin-context-bridge. Each source
|
|
29
|
+
degrades independently — missing CLI or failing command leaves the
|
|
30
|
+
result empty but does not raise.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, repo_root: Optional[Path] = None) -> None:
|
|
34
|
+
self.repo_root = Path(repo_root) if repo_root else Path.cwd()
|
|
35
|
+
|
|
36
|
+
def resolve(
|
|
37
|
+
self,
|
|
38
|
+
name: str,
|
|
39
|
+
depth: int = 2,
|
|
40
|
+
include: Optional[List[str]] = None,
|
|
41
|
+
) -> Dict[str, Any]:
|
|
42
|
+
"""Resolve a symbol via multiple graph queries.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name: function, class, or module name.
|
|
46
|
+
depth: 1-3 levels of graph traversal.
|
|
47
|
+
include: subset of {callers, callees, blast, recent, cross}.
|
|
48
|
+
Defaults to all except ``cross``.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Dict with per-source slices and a ``sources_queried`` list
|
|
52
|
+
showing which CLIs responded successfully.
|
|
53
|
+
"""
|
|
54
|
+
if include is None:
|
|
55
|
+
include = ["callers", "callees", "blast", "recent"]
|
|
56
|
+
|
|
57
|
+
# ── Resolve binaries ────────────────────────────────────────────
|
|
58
|
+
# shutil.which is the canonical way to find a CLI on PATH; the
|
|
59
|
+
# hard-coded fallbacks cover the dev-machine layout from AGENTS.md.
|
|
60
|
+
gitnexus_bin = shutil.which("gitnexus") or _GITNEXUS_FALLBACK
|
|
61
|
+
context_bridge_bin = shutil.which("sin-context-bridge") or _CONTEXT_BRIDGE_FALLBACK
|
|
62
|
+
|
|
63
|
+
result: Dict[str, Any] = {
|
|
64
|
+
"symbol": name,
|
|
65
|
+
"depth": depth,
|
|
66
|
+
"include": include,
|
|
67
|
+
"callers": [],
|
|
68
|
+
"callees": [],
|
|
69
|
+
"blast_radius": {},
|
|
70
|
+
"recent_changes": [],
|
|
71
|
+
"cross_source": {},
|
|
72
|
+
"sources_queried": [],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# ── 1. Callers + callees (gitnexus context) ─────────────────────
|
|
76
|
+
# One CLI call feeds both slices — same JSON payload.
|
|
77
|
+
if ("callers" in include or "callees" in include) and Path(gitnexus_bin).exists():
|
|
78
|
+
try:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
[gitnexus_bin, "context", name, "--json"],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=10,
|
|
84
|
+
)
|
|
85
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
86
|
+
data = json.loads(proc.stdout)
|
|
87
|
+
if "callers" in include:
|
|
88
|
+
result["callers"] = data.get("callers", [])
|
|
89
|
+
if "callees" in include:
|
|
90
|
+
result["callees"] = data.get("callees", [])
|
|
91
|
+
result["sources_queried"].append("gitnexus:context")
|
|
92
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
|
|
93
|
+
# Graceful degradation — leave the slice empty.
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# ── 2. Blast radius (gitnexus impact) ───────────────────────────
|
|
97
|
+
# Groups affected symbols by traversal depth for risk assessment.
|
|
98
|
+
if "blast" in include and Path(gitnexus_bin).exists():
|
|
99
|
+
try:
|
|
100
|
+
proc = subprocess.run(
|
|
101
|
+
[gitnexus_bin, "impact", json.dumps({"target": name})],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
timeout=15,
|
|
105
|
+
)
|
|
106
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
107
|
+
data = json.loads(proc.stdout)
|
|
108
|
+
affected = data.get("affected", [])
|
|
109
|
+
for item in affected:
|
|
110
|
+
d = item.get("depth", 1)
|
|
111
|
+
result["blast_radius"].setdefault(f"d{d}", []).append(item)
|
|
112
|
+
result["sources_queried"].append("gitnexus:impact")
|
|
113
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# ── 3. Recent changes (gitnexus detect-changes) ─────────────────
|
|
117
|
+
# Filters to changes mentioning the symbol — useful for "is this
|
|
118
|
+
# currently being modified?" answers.
|
|
119
|
+
if "recent" in include and Path(gitnexus_bin).exists():
|
|
120
|
+
try:
|
|
121
|
+
proc = subprocess.run(
|
|
122
|
+
[gitnexus_bin, "detect-changes", "--json"],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
timeout=10,
|
|
126
|
+
)
|
|
127
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
128
|
+
data = json.loads(proc.stdout)
|
|
129
|
+
all_changes = data.get("changes", [])
|
|
130
|
+
# Filter cheaply by checking each change dict's JSON
|
|
131
|
+
# representation; cheaper than traversing every field.
|
|
132
|
+
result["recent_changes"] = [
|
|
133
|
+
c for c in all_changes if name.lower() in json.dumps(c).lower()
|
|
134
|
+
]
|
|
135
|
+
result["sources_queried"].append("gitnexus:detect-changes")
|
|
136
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
# ── 4. Cross-source (sin-context-bridge) ────────────────────────
|
|
140
|
+
# Bridges local SCKG + remote sin-brain + local memory into one view.
|
|
141
|
+
if "cross" in include and Path(context_bridge_bin).exists():
|
|
142
|
+
try:
|
|
143
|
+
proc = subprocess.run(
|
|
144
|
+
[
|
|
145
|
+
context_bridge_bin,
|
|
146
|
+
"query",
|
|
147
|
+
name,
|
|
148
|
+
"--sources",
|
|
149
|
+
"sckg,sin_brain,local",
|
|
150
|
+
],
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
timeout=15,
|
|
154
|
+
)
|
|
155
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
156
|
+
data = json.loads(proc.stdout)
|
|
157
|
+
result["cross_source"] = {
|
|
158
|
+
"query": name,
|
|
159
|
+
"chunks": data.get("chunks", []),
|
|
160
|
+
"sources_queried": data.get("sources_queried", []),
|
|
161
|
+
}
|
|
162
|
+
result["sources_queried"].append("sin-context-bridge")
|
|
163
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
return result
|