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,208 @@
|
|
|
1
|
+
"""SIN-Brain memory adapter (BR-1, Issue #14).
|
|
2
|
+
|
|
3
|
+
Thin, defensive bridge to the external ``sin_brain`` package. The bundle holds
|
|
4
|
+
**no** memory logic itself (that lives in SIN-Brain); this module only:
|
|
5
|
+
|
|
6
|
+
- detects whether ``sin_brain`` is importable and reports tier sizes for
|
|
7
|
+
``sin status`` (:func:`detect_env`), and
|
|
8
|
+
- exposes the five memory operations (:func:`recall`, :func:`remember`,
|
|
9
|
+
:func:`forget`, :func:`pin`, :func:`link_evidence`) as thin pass-throughs that
|
|
10
|
+
the MCP ``serve`` command registers as tools.
|
|
11
|
+
|
|
12
|
+
Every entry point degrades gracefully: if ``sin_brain`` is absent, detection
|
|
13
|
+
reports ``available=False`` and the operations raise :class:`MemoryUnavailable`,
|
|
14
|
+
which the caller turns into a clean tool-level error instead of crashing the
|
|
15
|
+
server.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
import importlib.util
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
PACKAGE = "sin_brain"
|
|
27
|
+
|
|
28
|
+
# Canonical enums (kept in lock-step with the plan + AGENTS.md guidance).
|
|
29
|
+
RECALL_SCOPES = ("recall", "archival", "graph")
|
|
30
|
+
REMEMBER_KINDS = ("decision", "convention", "fix", "pitfall", "preference")
|
|
31
|
+
REMEMBER_SCOPES = ("repo", "user")
|
|
32
|
+
EVIDENCE_SOURCES = ("oracle", "poc", "ibd", "sckg", "adw")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MemoryUnavailable(RuntimeError):
|
|
36
|
+
"""Raised when a memory operation is attempted without ``sin_brain``."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class MemoryEnv:
|
|
41
|
+
"""Runtime availability snapshot for ``sin status``."""
|
|
42
|
+
|
|
43
|
+
available: bool
|
|
44
|
+
db_path: str | None = None
|
|
45
|
+
tiers: dict[str, int] = field(default_factory=dict)
|
|
46
|
+
detail: str = ""
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"available": self.available,
|
|
51
|
+
"db_path": self.db_path,
|
|
52
|
+
"tiers": self.tiers,
|
|
53
|
+
"detail": self.detail,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tools_module():
|
|
58
|
+
"""Import ``sin_brain.mcp_tools`` or raise :class:`MemoryUnavailable`."""
|
|
59
|
+
try:
|
|
60
|
+
return importlib.import_module(f"{PACKAGE}.mcp_tools")
|
|
61
|
+
except ImportError as exc: # pragma: no cover - exercised via detect_env
|
|
62
|
+
raise MemoryUnavailable(
|
|
63
|
+
"sin-brain not installed. Install with: pip install sin-brain"
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_env() -> MemoryEnv:
|
|
68
|
+
"""Report whether SIN-Brain is installed and, if so, its tier sizes."""
|
|
69
|
+
if importlib.util.find_spec(PACKAGE) is None:
|
|
70
|
+
return MemoryEnv(available=False, detail="sin_brain package not importable")
|
|
71
|
+
try:
|
|
72
|
+
mod = importlib.import_module(PACKAGE)
|
|
73
|
+
except ImportError as exc: # pragma: no cover
|
|
74
|
+
return MemoryEnv(available=False, detail=f"import error: {exc}")
|
|
75
|
+
|
|
76
|
+
db_path = None
|
|
77
|
+
tiers: dict[str, int] = {}
|
|
78
|
+
# SIN-Brain exposes an optional, cheap introspection hook. Treat any failure
|
|
79
|
+
# as "available but stats unknown" rather than unavailable.
|
|
80
|
+
stats = getattr(mod, "stats", None)
|
|
81
|
+
if callable(stats):
|
|
82
|
+
try:
|
|
83
|
+
data = stats()
|
|
84
|
+
db_path = data.get("db_path")
|
|
85
|
+
tiers = data.get("tiers", {}) or {}
|
|
86
|
+
except Exception as exc: # noqa: BLE001 - never let stats break status
|
|
87
|
+
return MemoryEnv(available=True, detail=f"stats unavailable: {exc}")
|
|
88
|
+
return MemoryEnv(available=True, db_path=db_path, tiers=tiers, detail="ok")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Operations — thin pass-throughs to sin_brain.mcp_tools (JSON-string results) ──
|
|
92
|
+
def recall(query: str, scope: str = "recall", k: int = 5) -> str:
|
|
93
|
+
"""Tiered memory search. Returns JSON: ids + snippets (not full docs)."""
|
|
94
|
+
if scope not in RECALL_SCOPES:
|
|
95
|
+
raise ValueError(f"scope must be one of {RECALL_SCOPES}")
|
|
96
|
+
result = _tools_module().recall(query=query, scope=scope, k=k)
|
|
97
|
+
return result if isinstance(result, str) else json.dumps(result)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def remember(content: str, kind: str, ttl_days: int | None = None, scope: str = "repo") -> str:
|
|
101
|
+
"""Self-editing memory write. Returns JSON with the new entry id."""
|
|
102
|
+
if kind not in REMEMBER_KINDS:
|
|
103
|
+
raise ValueError(f"kind must be one of {REMEMBER_KINDS}")
|
|
104
|
+
if scope not in REMEMBER_SCOPES:
|
|
105
|
+
raise ValueError(f"scope must be one of {REMEMBER_SCOPES}")
|
|
106
|
+
result = _tools_module().remember(content=content, kind=kind, ttl_days=ttl_days, scope=scope)
|
|
107
|
+
return result if isinstance(result, str) else json.dumps(result)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def forget(id: str) -> str:
|
|
111
|
+
"""Remove a memory entry. Returns JSON status."""
|
|
112
|
+
result = _tools_module().forget(id=id)
|
|
113
|
+
return result if isinstance(result, str) else json.dumps(result)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def pin(id: str) -> str:
|
|
117
|
+
"""Pin a memory entry so it is never evicted. Returns JSON status."""
|
|
118
|
+
result = _tools_module().pin(id=id)
|
|
119
|
+
return result if isinstance(result, str) else json.dumps(result)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def link_evidence(entity: str, verdict: str, source: str) -> str:
|
|
123
|
+
"""Attach a subsystem verdict to a code entity in the evidence graph."""
|
|
124
|
+
if source not in EVIDENCE_SOURCES:
|
|
125
|
+
raise ValueError(f"source must be one of {EVIDENCE_SOURCES}")
|
|
126
|
+
result = _tools_module().link_evidence(entity=entity, verdict=verdict, source=source)
|
|
127
|
+
return result if isinstance(result, str) else json.dumps(result)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def inject() -> str:
|
|
131
|
+
"""Return SIN-Brain's AGENTS.md inject block (SB-4), or '' if unavailable.
|
|
132
|
+
|
|
133
|
+
Used by `sin agents-md` to embed the project's compiled memory context. The
|
|
134
|
+
bundle owns no formatting here — SIN-Brain returns ready-to-embed Markdown.
|
|
135
|
+
"""
|
|
136
|
+
if importlib.util.find_spec(PACKAGE) is None:
|
|
137
|
+
return ""
|
|
138
|
+
try:
|
|
139
|
+
mod = importlib.import_module(PACKAGE)
|
|
140
|
+
except ImportError:
|
|
141
|
+
return ""
|
|
142
|
+
fn = getattr(mod, "inject", None)
|
|
143
|
+
if not callable(fn):
|
|
144
|
+
return ""
|
|
145
|
+
try:
|
|
146
|
+
out = fn()
|
|
147
|
+
except Exception: # noqa: BLE001 - inject must never break callers
|
|
148
|
+
return ""
|
|
149
|
+
return out if isinstance(out, str) else ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── MCP Registration (called by `sin serve`) ───────────────────────────────
|
|
153
|
+
# Kept here (not in cli.py) so the wiring is unit-testable with a fake MCP
|
|
154
|
+
# object and no `mcp` dependency.
|
|
155
|
+
TOOL_NAMES = ("recall", "remember", "forget", "pin", "link_evidence")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register_tools(mcp: Any) -> list[str]:
|
|
159
|
+
"""Register the five memory tools on ``mcp`` if SIN-Brain is available.
|
|
160
|
+
|
|
161
|
+
Returns the names registered (empty when sin-brain is absent) so callers and
|
|
162
|
+
tests can assert on the wiring. Never raises on a missing package — graceful
|
|
163
|
+
degradation is the contract.
|
|
164
|
+
"""
|
|
165
|
+
if not detect_env().available:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
@mcp.tool()
|
|
169
|
+
def recall_tool(query: str, scope: str = "recall", k: int = 5) -> str:
|
|
170
|
+
"""Search memory tiers (recall|archival|graph). Returns ids+snippets."""
|
|
171
|
+
try:
|
|
172
|
+
return recall(query, scope=scope, k=k)
|
|
173
|
+
except (MemoryUnavailable, ValueError) as exc:
|
|
174
|
+
return json.dumps({"error": str(exc)})
|
|
175
|
+
|
|
176
|
+
@mcp.tool()
|
|
177
|
+
def remember_tool(content: str, kind: str, ttl_days: int = 0, scope: str = "repo") -> str:
|
|
178
|
+
"""Persist a memory. kind: decision|convention|fix|pitfall|preference."""
|
|
179
|
+
try:
|
|
180
|
+
return remember(content, kind, ttl_days=ttl_days or None, scope=scope)
|
|
181
|
+
except (MemoryUnavailable, ValueError) as exc:
|
|
182
|
+
return json.dumps({"error": str(exc)})
|
|
183
|
+
|
|
184
|
+
@mcp.tool()
|
|
185
|
+
def forget_tool(id: str) -> str:
|
|
186
|
+
"""Delete a memory entry by id."""
|
|
187
|
+
try:
|
|
188
|
+
return forget(id)
|
|
189
|
+
except MemoryUnavailable as exc:
|
|
190
|
+
return json.dumps({"error": str(exc)})
|
|
191
|
+
|
|
192
|
+
@mcp.tool()
|
|
193
|
+
def pin_tool(id: str) -> str:
|
|
194
|
+
"""Pin a memory entry so it is never evicted."""
|
|
195
|
+
try:
|
|
196
|
+
return pin(id)
|
|
197
|
+
except MemoryUnavailable as exc:
|
|
198
|
+
return json.dumps({"error": str(exc)})
|
|
199
|
+
|
|
200
|
+
@mcp.tool()
|
|
201
|
+
def link_evidence_tool(entity: str, verdict: str, source: str) -> str:
|
|
202
|
+
"""Attach a subsystem verdict (oracle|poc|ibd|sckg|adw) to a code entity."""
|
|
203
|
+
try:
|
|
204
|
+
return link_evidence(entity, verdict, source)
|
|
205
|
+
except (MemoryUnavailable, ValueError) as exc:
|
|
206
|
+
return json.dumps({"error": str(exc)})
|
|
207
|
+
|
|
208
|
+
return list(TOOL_NAMES)
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Purpose: Pre-merge / pre-PR safety gate.
|
|
2
|
+
|
|
3
|
+
Docs: merge_safety.doc.md
|
|
4
|
+
|
|
5
|
+
Runs a battery of checks before allowing a merge or PR open:
|
|
6
|
+
- CoDocs coverage (broken .doc.md references)
|
|
7
|
+
- ceo-audit grade (cached 5 minutes per profile)
|
|
8
|
+
- git diff stat (large diffs flagged)
|
|
9
|
+
- secret scan (cheap substring heuristic)
|
|
10
|
+
|
|
11
|
+
Returns a single ``pass: bool`` plus a list of human-readable ``blockers``
|
|
12
|
+
and ``warnings``. The agent can use the verdict to decide whether to
|
|
13
|
+
proceed with the merge or fix issues first.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
# Coarse secret pattern: keys/tokens/passwords in added lines.
|
|
27
|
+
_SECRET_LINE_PATTERN = re.compile(
|
|
28
|
+
r"(?i)(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key)\s*[:=]\s*['\"]?[A-Za-z0-9_\-\.]{16,}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Substrings that should NEVER appear in a diff (would be a leaked secret).
|
|
32
|
+
_SECRET_HINTS = (
|
|
33
|
+
"BEGIN RSA PRIVATE KEY",
|
|
34
|
+
"BEGIN OPENSSH PRIVATE KEY",
|
|
35
|
+
"BEGIN PRIVATE KEY",
|
|
36
|
+
"sk-", # OpenAI / many SaaS keys
|
|
37
|
+
"ghp_", # GitHub PAT
|
|
38
|
+
"github_pat_", # GitHub fine-grained PAT
|
|
39
|
+
"xoxb-",
|
|
40
|
+
"xoxp-", # Slack tokens
|
|
41
|
+
"AIza", # Google API keys
|
|
42
|
+
"AKIA", # AWS access key
|
|
43
|
+
"ASIA",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Hard-coded fallback for the dev-machine layout.
|
|
47
|
+
_CEO_AUDIT_FALLBACK = "/Users/jeremy/.local/bin/sin"
|
|
48
|
+
|
|
49
|
+
# Max lines changed before we flag a "large diff" warning.
|
|
50
|
+
_LARGE_DIFF_LINES = 1000
|
|
51
|
+
|
|
52
|
+
# 5min — ceo-audit is the slow part; cache to keep pre-PR hooks snappy.
|
|
53
|
+
_CEO_AUDIT_CACHE_TTL = 300
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MergeSafety:
|
|
57
|
+
"""Pre-merge / pre-PR safety gate.
|
|
58
|
+
|
|
59
|
+
Each check is independent and contributes to the final verdict.
|
|
60
|
+
All checks are best-effort — a missing CLI downgrades a check
|
|
61
|
+
to a warning, not a failure.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, repo_root: Optional[Path] = None) -> None:
|
|
65
|
+
self.repo_root = Path(repo_root) if repo_root else Path.cwd()
|
|
66
|
+
# Per-process cache for ceo-audit results. Key: (profile, base, head).
|
|
67
|
+
self._audit_cache: Dict[Tuple[str, str, str], Tuple[float, Dict[str, Any]]] = {}
|
|
68
|
+
|
|
69
|
+
def check(
|
|
70
|
+
self,
|
|
71
|
+
base: str = "main",
|
|
72
|
+
head: str = "HEAD",
|
|
73
|
+
profile: str = "QUICK",
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""Run all safety checks and return a verdict.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
base: base ref (default ``main``).
|
|
79
|
+
head: head ref (default ``HEAD``).
|
|
80
|
+
profile: ceo-audit profile (default ``QUICK``).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dict with ``pass`` (bool), ``blockers`` (list of str),
|
|
84
|
+
``warnings`` (list of str), ``checks`` (per-check dict),
|
|
85
|
+
and ``verdict`` ("READY" or "FIX_FIRST").
|
|
86
|
+
"""
|
|
87
|
+
blockers: List[str] = []
|
|
88
|
+
warnings: List[str] = []
|
|
89
|
+
checks: Dict[str, Any] = {}
|
|
90
|
+
|
|
91
|
+
# ── 1. CoDocs coverage ───────────────────────────────────────
|
|
92
|
+
codocs = self._check_codocs()
|
|
93
|
+
checks["codocs"] = codocs
|
|
94
|
+
if codocs.get("broken", 0) > 0:
|
|
95
|
+
blockers.append(
|
|
96
|
+
f"CoDocs: {codocs['broken']} broken .doc.md reference(s) — fix before merge"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# ── 2. ceo-audit (cached) ────────────────────────────────────
|
|
100
|
+
audit = self._check_ceo_audit(profile, base, head)
|
|
101
|
+
checks["ceo_audit"] = audit
|
|
102
|
+
grade = (audit.get("grade") or "").upper()
|
|
103
|
+
if grade == "F":
|
|
104
|
+
blockers.append("ceo-audit grade F — fix critical issues before merge")
|
|
105
|
+
elif grade == "D":
|
|
106
|
+
warnings.append("ceo-audit grade D — consider improving before merge")
|
|
107
|
+
elif not audit.get("ok"):
|
|
108
|
+
# ceo-audit didn't run — downgrade to warning (don't block).
|
|
109
|
+
warnings.append(
|
|
110
|
+
f"ceo-audit could not run: {audit.get('error', 'unknown')} — verify manually"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# ── 3. git diff stat (size + secrets in lines) ───────────────
|
|
114
|
+
diff = self._check_diff(base, head)
|
|
115
|
+
checks["diff"] = diff
|
|
116
|
+
if diff.get("lines_changed", 0) > _LARGE_DIFF_LINES:
|
|
117
|
+
warnings.append(
|
|
118
|
+
f"Large diff: {diff['lines_changed']} lines changed — consider splitting"
|
|
119
|
+
)
|
|
120
|
+
secret_hits = diff.get("secret_hits", [])
|
|
121
|
+
if secret_hits:
|
|
122
|
+
blockers.append(
|
|
123
|
+
f"Diff contains possible secrets ({len(secret_hits)} line(s)) — "
|
|
124
|
+
"rotate keys and re-commit before merge"
|
|
125
|
+
)
|
|
126
|
+
checks["diff"]["secret_examples"] = secret_hits[:3]
|
|
127
|
+
|
|
128
|
+
# ── 4. Working tree must be clean ────────────────────────────
|
|
129
|
+
tree = self._check_tree()
|
|
130
|
+
checks["working_tree"] = tree
|
|
131
|
+
if not tree.get("clean", False):
|
|
132
|
+
warnings.append(
|
|
133
|
+
f"Working tree is dirty ({tree.get('changes_count', '?')} change(s)) — "
|
|
134
|
+
"commit/stash before merge"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ── Compose verdict ──────────────────────────────────────────
|
|
138
|
+
passed = len(blockers) == 0
|
|
139
|
+
verdict = "READY" if passed else "FIX_FIRST"
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"pass": passed,
|
|
143
|
+
"verdict": verdict,
|
|
144
|
+
"blockers": blockers,
|
|
145
|
+
"warnings": warnings,
|
|
146
|
+
"checks": checks,
|
|
147
|
+
"base": base,
|
|
148
|
+
"head": head,
|
|
149
|
+
"profile": profile,
|
|
150
|
+
"timestamp": _now_iso(),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# ── helpers ─────────────────────────────────────────────────────
|
|
154
|
+
def _check_codocs(self) -> Dict[str, Any]:
|
|
155
|
+
try:
|
|
156
|
+
from . import codocs
|
|
157
|
+
|
|
158
|
+
broken = codocs.find_broken(str(self.repo_root))
|
|
159
|
+
return {
|
|
160
|
+
"ok": not bool(broken),
|
|
161
|
+
"broken": len(broken),
|
|
162
|
+
"items": [b.to_dict() for b in broken][:10],
|
|
163
|
+
}
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
return {"ok": True, "broken": 0, "error": str(exc)}
|
|
166
|
+
|
|
167
|
+
def _check_ceo_audit(
|
|
168
|
+
self,
|
|
169
|
+
profile: str,
|
|
170
|
+
base: str,
|
|
171
|
+
head: str,
|
|
172
|
+
) -> Dict[str, Any]:
|
|
173
|
+
"""Run ceo-audit, with a 5-minute in-process cache."""
|
|
174
|
+
cache_key = (profile, base, head)
|
|
175
|
+
now = time.time()
|
|
176
|
+
|
|
177
|
+
if cache_key in self._audit_cache:
|
|
178
|
+
ts, data = self._audit_cache[cache_key]
|
|
179
|
+
if (now - ts) < _CEO_AUDIT_CACHE_TTL:
|
|
180
|
+
return {**data, "cache_hit": True}
|
|
181
|
+
# Stale — fall through and re-run.
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
sin_bin = shutil.which("sin") or _CEO_AUDIT_FALLBACK
|
|
185
|
+
if not Path(sin_bin).exists():
|
|
186
|
+
return {"ok": False, "error": "sin CLI not installed"}
|
|
187
|
+
|
|
188
|
+
proc = subprocess.run(
|
|
189
|
+
[
|
|
190
|
+
sin_bin,
|
|
191
|
+
"ceo-audit",
|
|
192
|
+
"run",
|
|
193
|
+
str(self.repo_root),
|
|
194
|
+
f"--profile={profile}",
|
|
195
|
+
"--json",
|
|
196
|
+
],
|
|
197
|
+
capture_output=True,
|
|
198
|
+
text=True,
|
|
199
|
+
timeout=180, # 3min ceiling
|
|
200
|
+
)
|
|
201
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
202
|
+
data = json.loads(proc.stdout)
|
|
203
|
+
result = {
|
|
204
|
+
"ok": True,
|
|
205
|
+
"grade": data.get("grade"),
|
|
206
|
+
"report_path": data.get("report_path"),
|
|
207
|
+
}
|
|
208
|
+
self._audit_cache[cache_key] = (now, result)
|
|
209
|
+
return result
|
|
210
|
+
return {"ok": False, "error": proc.stderr[-300:]}
|
|
211
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
|
212
|
+
return {"ok": False, "error": str(exc)}
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
return {"ok": False, "error": str(exc)}
|
|
215
|
+
|
|
216
|
+
def _check_diff(self, base: str, head: str) -> Dict[str, Any]:
|
|
217
|
+
try:
|
|
218
|
+
proc = subprocess.run(
|
|
219
|
+
["git", "diff", "--shortstat", f"{base}...{head}"],
|
|
220
|
+
cwd=self.repo_root,
|
|
221
|
+
capture_output=True,
|
|
222
|
+
text=True,
|
|
223
|
+
timeout=15,
|
|
224
|
+
)
|
|
225
|
+
shortstat = proc.stdout.strip() if proc.returncode == 0 else ""
|
|
226
|
+
# " 3 files changed, 42 insertions(+), 17 deletions(-)"
|
|
227
|
+
lines_changed = _parse_shortstat(shortstat)
|
|
228
|
+
|
|
229
|
+
# Fetch the actual diff content for secret scan.
|
|
230
|
+
content_proc = subprocess.run(
|
|
231
|
+
["git", "diff", f"{base}...{head}"],
|
|
232
|
+
cwd=self.repo_root,
|
|
233
|
+
capture_output=True,
|
|
234
|
+
text=True,
|
|
235
|
+
timeout=30,
|
|
236
|
+
)
|
|
237
|
+
content = content_proc.stdout if content_proc.returncode == 0 else ""
|
|
238
|
+
secret_hits = _scan_for_secrets(content)
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"ok": True,
|
|
242
|
+
"lines_changed": lines_changed,
|
|
243
|
+
"shortstat": shortstat,
|
|
244
|
+
"secret_hits": secret_hits,
|
|
245
|
+
}
|
|
246
|
+
except subprocess.TimeoutExpired:
|
|
247
|
+
return {"ok": False, "lines_changed": 0, "secret_hits": [], "error": "git diff timeout"}
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
return {"ok": False, "lines_changed": 0, "secret_hits": [], "error": str(exc)}
|
|
250
|
+
|
|
251
|
+
def _check_tree(self) -> Dict[str, Any]:
|
|
252
|
+
try:
|
|
253
|
+
proc = subprocess.run(
|
|
254
|
+
["git", "status", "--porcelain"],
|
|
255
|
+
cwd=self.repo_root,
|
|
256
|
+
capture_output=True,
|
|
257
|
+
text=True,
|
|
258
|
+
timeout=5,
|
|
259
|
+
)
|
|
260
|
+
if proc.returncode == 0:
|
|
261
|
+
changes = proc.stdout.strip().splitlines()
|
|
262
|
+
return {"ok": True, "clean": not bool(changes), "changes_count": len(changes)}
|
|
263
|
+
return {"ok": False, "clean": False, "error": proc.stderr[-200:]}
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
return {"ok": False, "clean": False, "error": str(exc)}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── module-level helpers ────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _parse_shortstat(shortstat: str) -> int:
|
|
272
|
+
"""Parse ``3 files changed, 42 insertions(+), 17 deletions(-)`` → 59."""
|
|
273
|
+
m = re.search(r"(\d+)\s+insertion", shortstat)
|
|
274
|
+
insertions = int(m.group(1)) if m else 0
|
|
275
|
+
m = re.search(r"(\d+)\s+deletion", shortstat)
|
|
276
|
+
deletions = int(m.group(1)) if m else 0
|
|
277
|
+
return insertions + deletions
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _scan_for_secrets(diff_content: str) -> List[str]:
|
|
281
|
+
"""Return a list of human-readable hits for any secret-like content.
|
|
282
|
+
|
|
283
|
+
Two passes:
|
|
284
|
+
1. Substring scan for known SaaS key prefixes.
|
|
285
|
+
2. Regex for ``key = "value"`` / ``token: "value"`` patterns.
|
|
286
|
+
"""
|
|
287
|
+
hits: List[str] = []
|
|
288
|
+
|
|
289
|
+
# Pass 1: substring hints
|
|
290
|
+
for hint in _SECRET_HINTS:
|
|
291
|
+
if hint in diff_content:
|
|
292
|
+
# Find a representative line for the report.
|
|
293
|
+
for i, line in enumerate(diff_content.splitlines(), 1):
|
|
294
|
+
if hint in line:
|
|
295
|
+
hits.append(f"line {i}: contains '{hint}'")
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
# Pass 2: regex (only added lines, not context — context can be noisy).
|
|
299
|
+
for i, line in enumerate(diff_content.splitlines(), 1):
|
|
300
|
+
if not line.startswith("+"):
|
|
301
|
+
continue
|
|
302
|
+
if _SECRET_LINE_PATTERN.search(line):
|
|
303
|
+
hits.append(f"line {i}: key=value pattern")
|
|
304
|
+
if len(hits) >= 20:
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
return hits
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _now_iso() -> str:
|
|
311
|
+
from datetime import datetime, timezone
|
|
312
|
+
|
|
313
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Purpose: Isolated worktree orchestration — parallel agent tasks without conflicts.
|
|
2
|
+
|
|
3
|
+
Docs: orchestration_worktrees.doc.md
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SINWorktreeOrchestrator:
|
|
17
|
+
"""Manages isolated git worktrees for parallel agent task execution."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, repo_root: Optional[Path] = None):
|
|
20
|
+
self.repo_root = repo_root or Path.cwd()
|
|
21
|
+
self.active_worktrees: list[Path] = []
|
|
22
|
+
|
|
23
|
+
def is_git_repo(self) -> bool:
|
|
24
|
+
return (self.repo_root / ".git").exists()
|
|
25
|
+
|
|
26
|
+
def create_worktree(self, branch_name: Optional[str] = None) -> dict:
|
|
27
|
+
if not self.is_git_repo():
|
|
28
|
+
return {"error": "Not a git repository. Worktree isolation requires git."}
|
|
29
|
+
# Sibling-dir layout (.sin-worktrees-<name>) keeps the worktrees
|
|
30
|
+
# OUTSIDE the repo's own working tree. That matters because git
|
|
31
|
+
# refuses to checkout branches into a worktree that overlaps the
|
|
32
|
+
# main repo's `.git`-tracked files (silent failure mode).
|
|
33
|
+
branch = (
|
|
34
|
+
branch_name or f"sin-task-{uuid.uuid4().hex[:8]}"
|
|
35
|
+
) # 8 hex chars = 32 bits, plenty unique for worktrees
|
|
36
|
+
worktree_path = self.repo_root.parent / f".sin-worktrees-{self.repo_root.name}" / branch
|
|
37
|
+
try:
|
|
38
|
+
subprocess.run(
|
|
39
|
+
["git", "worktree", "add", str(worktree_path), "-b", branch],
|
|
40
|
+
cwd=self.repo_root,
|
|
41
|
+
check=True,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
)
|
|
45
|
+
self.active_worktrees.append(worktree_path)
|
|
46
|
+
return {
|
|
47
|
+
"success": True,
|
|
48
|
+
"worktree_path": str(worktree_path),
|
|
49
|
+
"branch": branch,
|
|
50
|
+
"message": f"Isolated worktree created at {worktree_path}",
|
|
51
|
+
}
|
|
52
|
+
except subprocess.CalledProcessError as e:
|
|
53
|
+
return {"error": f"Git worktree creation failed: {e.stderr}"}
|
|
54
|
+
|
|
55
|
+
def execute_in_worktree(self, worktree_path: str, task_func: Callable, *args, **kwargs) -> dict:
|
|
56
|
+
original_cwd = os.getcwd()
|
|
57
|
+
try:
|
|
58
|
+
os.chdir(worktree_path)
|
|
59
|
+
result = task_func(*args, **kwargs)
|
|
60
|
+
return {"success": True, "result": result}
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return {"success": False, "error": str(e)}
|
|
63
|
+
finally:
|
|
64
|
+
os.chdir(original_cwd)
|
|
65
|
+
|
|
66
|
+
def cleanup_worktree(self, worktree_path: str, merge_back: bool = False) -> dict:
|
|
67
|
+
path = Path(worktree_path)
|
|
68
|
+
if path not in self.active_worktrees:
|
|
69
|
+
return {"error": "Worktree not managed by this orchestrator"}
|
|
70
|
+
try:
|
|
71
|
+
if merge_back:
|
|
72
|
+
branch = path.name
|
|
73
|
+
subprocess.run(
|
|
74
|
+
["git", "checkout", "main"], cwd=self.repo_root, check=True, capture_output=True
|
|
75
|
+
)
|
|
76
|
+
merge_result = subprocess.run(
|
|
77
|
+
[
|
|
78
|
+
"git",
|
|
79
|
+
"merge",
|
|
80
|
+
"--no-ff",
|
|
81
|
+
branch,
|
|
82
|
+
"-m",
|
|
83
|
+
f"Auto-merge from SIN worktree: {branch}",
|
|
84
|
+
],
|
|
85
|
+
cwd=self.repo_root,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
)
|
|
89
|
+
if merge_result.returncode != 0:
|
|
90
|
+
return {"error": f"Merge conflict: {merge_result.stderr}"}
|
|
91
|
+
subprocess.run(
|
|
92
|
+
["git", "worktree", "remove", str(path), "--force"],
|
|
93
|
+
cwd=self.repo_root,
|
|
94
|
+
check=True,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
)
|
|
97
|
+
self.active_worktrees.remove(path)
|
|
98
|
+
if path.exists():
|
|
99
|
+
shutil.rmtree(path)
|
|
100
|
+
return {"success": True, "message": "Worktree cleaned up successfully"}
|
|
101
|
+
except subprocess.CalledProcessError as e:
|
|
102
|
+
return {"error": f"Worktree cleanup failed: {e.stderr}"}
|