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,280 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sin-codocs
|
|
3
|
+
description: SOTA Code Documentation — every code file gets both a `.doc.md` companion AND proper inline `#` comments. Create both for new files, update both on changes, verify with `sin codocs check`. Use when the user says "document this", "add docs", "explain the code", "comment this", "add inline documentation", "self-documenting code", or "SOTA docs".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Code Documentation Standard
|
|
7
|
+
|
|
8
|
+
Every meaningful code file needs **two documentation layers**:
|
|
9
|
+
|
|
10
|
+
1. **`.doc.md` companion** — the "what and why" overview
|
|
11
|
+
2. **Inline `#` comments** — the "how and why here" detail in the code itself
|
|
12
|
+
|
|
13
|
+
Both layers must exist. Neither replaces the other.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Layer 1: CoDocs (.doc.md companion)
|
|
18
|
+
|
|
19
|
+
Every code file gets a `.doc.md` companion file in the same directory.
|
|
20
|
+
|
|
21
|
+
### Naming
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
router.py → router.doc.md
|
|
25
|
+
config.yaml → config.doc.md
|
|
26
|
+
api/types.ts → api/types.doc.md
|
|
27
|
+
Makefile → Makefile.doc.md
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Code reference
|
|
31
|
+
|
|
32
|
+
First line of the code file:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
# Docs: router.doc.md
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// Docs: types.doc.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```makefile
|
|
43
|
+
# Docs: Makefile.doc.md
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### What belongs in a `.doc.md`
|
|
47
|
+
|
|
48
|
+
- What does this file do? (1 sentence)
|
|
49
|
+
- Which other files import / touch it? (dependency map)
|
|
50
|
+
- Important config values & limits
|
|
51
|
+
- Why certain decisions were made (e.g. "no async here because X")
|
|
52
|
+
- Usage examples (1-2 lines)
|
|
53
|
+
- Known caveats or footguns
|
|
54
|
+
|
|
55
|
+
### What does NOT belong in a `.doc.md`
|
|
56
|
+
|
|
57
|
+
- Implementation details (inline comments handle that)
|
|
58
|
+
- Git history (that's what `git log` is for)
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Layer 2: SOTA Inline Documentation
|
|
63
|
+
|
|
64
|
+
Every code file must also have professional inline `#`/`//`/`#:` comments. This is **not** about "comment every line" — it is about providing **semantic context** that an agent can't infer from the code alone.
|
|
65
|
+
|
|
66
|
+
### SOTA Inline Doc Rules
|
|
67
|
+
|
|
68
|
+
#### 1. File header (mandatory)
|
|
69
|
+
|
|
70
|
+
Every code file starts with:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
# Purpose: <what this file does in 1 line>
|
|
74
|
+
# Docs: <companion .doc.md path>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For Python use `"""` module docstrings instead of `#`:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
"""Handle user authentication.
|
|
81
|
+
|
|
82
|
+
Docs: auth.doc.md
|
|
83
|
+
"""
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For TypeScript/Rust/etc use doc-comment style:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
/**
|
|
90
|
+
* Handle user authentication.
|
|
91
|
+
* Docs: auth.doc.md
|
|
92
|
+
*/
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
#### 2. Public API: docstrings (mandatory)
|
|
96
|
+
|
|
97
|
+
Every public function, method, class, type, and constant needs a docstring:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
def calculate_route(
|
|
101
|
+
origin: Coordinate,
|
|
102
|
+
dest: Coordinate,
|
|
103
|
+
traffic: bool = False,
|
|
104
|
+
) -> Route:
|
|
105
|
+
"""Shortest path between two coordinates.
|
|
106
|
+
|
|
107
|
+
Uses A* with Manhattan heuristic. Raises if both coords
|
|
108
|
+
are identical (avoids zero-length route).
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
/** Shortest path between two coordinates.
|
|
115
|
+
*
|
|
116
|
+
* Uses A* with Manhattan heuristic. Throws if both coords
|
|
117
|
+
* are identical (avoids zero-length route).
|
|
118
|
+
*/
|
|
119
|
+
function calculateRoute(
|
|
120
|
+
origin: Coordinate,
|
|
121
|
+
dest: Coordinate,
|
|
122
|
+
traffic: boolean = false,
|
|
123
|
+
): Route { ... }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### 3. Non-obvious logic: inline context comments
|
|
127
|
+
|
|
128
|
+
Add a comment when the code does something surprising:
|
|
129
|
+
|
|
130
|
+
- **Why NOT the obvious approach**: `# not using dict comprehension because ...`
|
|
131
|
+
- **Why this value**: `# 50ms timeout — must be < retry-after of upstream (60ms)`
|
|
132
|
+
- **Why this ordering**: `# flush before close — close may skip unflushed data`
|
|
133
|
+
- **Edge case**: `# handles None because protocol allows null fields`
|
|
134
|
+
- **Performance note**: `# O(n²) but n ≤ 10 in practice`
|
|
135
|
+
- **Security note**: `# sanitize_input() prevents SQL injection here`
|
|
136
|
+
|
|
137
|
+
#### 4. Section separators (recommended for 100+ line files)
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
# ── Auth ──────────────────────────────────────
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Visually group related blocks. The long line makes sections scannable.
|
|
144
|
+
|
|
145
|
+
#### 5. Magic values & config keys
|
|
146
|
+
|
|
147
|
+
Always explain:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
MAX_RETRIES = 3 # upstream SLA guarantees < 2 failures per 1000
|
|
151
|
+
WAIT_SECONDS = 60 # must match upstream rate-limit window
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const MAX_RETRIES = 3 // upstream SLA guarantees < 2 per 1000
|
|
156
|
+
const WAIT_SECONDS = 60 // must match upstream rate-limit window
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
#### 6. Tests: describe scenario + expected behavior
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
def test_retry_exhaustion():
|
|
163
|
+
"""After 3 retries, route should raise UpstreamError."""
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Test names plus docstrings = executable documentation.
|
|
167
|
+
|
|
168
|
+
#### 7. Deprecation & migration markers
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
def old_login(): # DEPRECATED(v2): use authenticate() instead
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### When to update inline docs
|
|
175
|
+
|
|
176
|
+
- **Every change to a function's signature**: update its docstring
|
|
177
|
+
- **Every change to non-obvious logic**: add/update the context comment
|
|
178
|
+
- **Every new module**: file header + section separators
|
|
179
|
+
- **Every new public API**: docstring on add
|
|
180
|
+
|
|
181
|
+
### When NOT to comment
|
|
182
|
+
|
|
183
|
+
- `i += 1` — obvious code needs no comment
|
|
184
|
+
- `x = 1` — unless 1 is a meaningful constant
|
|
185
|
+
- Getter/setter boilerplate
|
|
186
|
+
- Standard library calls with obvious semantics
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Validation
|
|
191
|
+
|
|
192
|
+
After changes, verify with the bundle CLI:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
sin codocs check # exit 1 if any .doc.md reference is broken
|
|
196
|
+
sin codocs check --json # machine-readable output
|
|
197
|
+
sin codocs list # list every reference and whether it resolves
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
For inline docs, use manual review with:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
# Check files that have NO module-level docstring/Purpose line
|
|
204
|
+
python3 -c "
|
|
205
|
+
import ast, sys
|
|
206
|
+
for f in sys.argv[1:]:
|
|
207
|
+
try:
|
|
208
|
+
tree = ast.parse(open(f).read())
|
|
209
|
+
if not (isinstance(tree.body[0], ast.Expr) and hasattr(tree.body[0].value, 'value') and 'Purpose' in tree.body[0].value.s if hasattr(tree.body[0].value, 's') else isinstance(tree.body[0], ast.Expr) and isinstance(tree.body[0].value, ast.Constant)):
|
|
210
|
+
print(f'MISSING PURPOSE: {f}')
|
|
211
|
+
except: print(f'PARSE ERROR: {f}')
|
|
212
|
+
"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Exceptions
|
|
216
|
+
|
|
217
|
+
- `docs/` folder — architecture docs, ADRs, setup guides
|
|
218
|
+
- `README.md` — project overview
|
|
219
|
+
- No `.doc.md` for pure config files without logic (`.gitignore`, `.prettierrc`, etc.)
|
|
220
|
+
- No inline docs required for throwaway scripts in `debug/`, `tmp/`, experimental branches
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## MarkItDown Integration (Microsoft)
|
|
225
|
+
|
|
226
|
+
**Converts everything to Markdown** for LLM consumption: PDF, DOCX, PPTX, XLSX,
|
|
227
|
+
Images (OCR), Audio, HTML, CSV/JSON/XML, ZIP, YouTube, EPUB, Outlook MSG.
|
|
228
|
+
|
|
229
|
+
### Installation
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pipx install markitdown # recommended (CLI + library)
|
|
233
|
+
pip install 'markitdown[pdf, docx, pptx, xlsx]' # minimal
|
|
234
|
+
pip install 'markitdown[all]' # everything
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### CLI
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
markitdown file.pdf > file.md # stdout
|
|
241
|
+
markitdown file.pdf -o file.md # output file
|
|
242
|
+
cat file.pdf | markitdown # pipe
|
|
243
|
+
markitdown --use-plugins file.pdf # with plugins (OCR)
|
|
244
|
+
markitdown file.pdf --use-cu --cu-endpoint "<e>" # Azure Content Understanding
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Python API
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from markitdown import MarkItDown
|
|
251
|
+
md = MarkItDown()
|
|
252
|
+
result = md.convert("document.pdf")
|
|
253
|
+
print(result.text_content)
|
|
254
|
+
|
|
255
|
+
# With LLM vision (image descriptions in PPTX/Images)
|
|
256
|
+
from openai import OpenAI
|
|
257
|
+
md = MarkItDown(llm_client=OpenAI(), llm_model="gpt-4o")
|
|
258
|
+
|
|
259
|
+
# Security: local files only
|
|
260
|
+
result = md.convert_local("document.pdf")
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### CoDocs pipeline
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
for f in docs/*.pdf docs/*.docx docs/*.pptx; do
|
|
267
|
+
markitdown "$f" -o "${f%.*}.doc.md"
|
|
268
|
+
done
|
|
269
|
+
# then add `# Docs: filename.doc.md` to the matching code file
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Security
|
|
273
|
+
|
|
274
|
+
- `convert()` runs with the calling process's full file-IO rights. Never pass
|
|
275
|
+
untrusted input directly.
|
|
276
|
+
- Prefer `convert_local()` / `convert_stream()` for controlled access.
|
|
277
|
+
|
|
278
|
+
### Reference
|
|
279
|
+
|
|
280
|
+
https://github.com/microsoft/markitdown | `pipx install markitdown`
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""GitNexus bridge.
|
|
3
|
+
|
|
4
|
+
GitNexus (https://github.com/abhigyanpatwari/GitNexus) is an *upstream* tool,
|
|
5
|
+
distributed as the npm package ``gitnexus`` under the PolyForm Noncommercial
|
|
6
|
+
license. We never vendor or copy its source; we only invoke the published
|
|
7
|
+
package via ``npx`` and read the artifacts it produces. This keeps the bundle
|
|
8
|
+
MIT-licensed while making GitNexus a hard, always-on dependency so that coder
|
|
9
|
+
agents never operate "blind" on a repository.
|
|
10
|
+
|
|
11
|
+
The bridge provides:
|
|
12
|
+
* discovery / health checks for Node + the ``gitnexus`` package,
|
|
13
|
+
* an ``ensure_index`` helper that auto-indexes a repo when the graph is
|
|
14
|
+
missing or stale,
|
|
15
|
+
* thin wrappers over the GitNexus CLI query surface
|
|
16
|
+
(``ai-context``, ``query``, ``context``, ``impact``),
|
|
17
|
+
* MCP wiring so OpenCode / Codex / Hermes each get the GitNexus MCP server.
|
|
18
|
+
|
|
19
|
+
Docs: gitnexus.doc.md
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
# ── GitNexusBridge: Graph Context Provider ─────────────────────────────
|
|
34
|
+
# This module turns GitNexus (the upstream npm package) into a hard,
|
|
35
|
+
# always-on dependency for coder agents. We never vendor the package —
|
|
36
|
+
# we only invoke it via npx, which fetches and caches the published
|
|
37
|
+
# build on first use (mirroring GitNexus' own `.mcp.json` recommendation).
|
|
38
|
+
|
|
39
|
+
# How GitNexus is provided. We always pin to the published package and let npx
|
|
40
|
+
# fetch/cache it, mirroring GitNexus' own `.mcp.json` recommendation.
|
|
41
|
+
GITNEXUS_PACKAGE = "gitnexus@latest"
|
|
42
|
+
|
|
43
|
+
# Re-index when the stored graph is older than this many seconds (default 24h).
|
|
44
|
+
DEFAULT_STALE_SECONDS = 24 * 60 * 60
|
|
45
|
+
|
|
46
|
+
# GitNexus stores its index per-repo under this directory.
|
|
47
|
+
INDEX_DIRNAME = ".gitnexus"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitNexusError(RuntimeError):
|
|
51
|
+
"""Raised when GitNexus is unavailable or a command fails."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class GitNexusEnv:
|
|
56
|
+
"""Resolved runtime environment for invoking GitNexus."""
|
|
57
|
+
|
|
58
|
+
node: str | None
|
|
59
|
+
npx: str | None
|
|
60
|
+
package: str = GITNEXUS_PACKAGE
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def available(self) -> bool:
|
|
64
|
+
"""True iff a usable ``npx`` was detected on PATH."""
|
|
65
|
+
return bool(self.npx)
|
|
66
|
+
|
|
67
|
+
def base_cmd(self) -> list[str]:
|
|
68
|
+
"""Return the base command list to invoke the GitNexus package.
|
|
69
|
+
|
|
70
|
+
Raises GitNexusError if npx is missing — this is the gate every GitNexus
|
|
71
|
+
command in the bundle funnels through, so the error is raised once and
|
|
72
|
+
in one place.
|
|
73
|
+
"""
|
|
74
|
+
if not self.npx:
|
|
75
|
+
raise GitNexusError(
|
|
76
|
+
"npx not found on PATH. GitNexus requires Node.js (>=18). "
|
|
77
|
+
"Install Node, then re-run. The bundle does not vendor GitNexus."
|
|
78
|
+
)
|
|
79
|
+
# `npx -y <pkg>` auto-installs/caches the published package on first use.
|
|
80
|
+
return [self.npx, "-y", self.package]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def detect_env(package: str = GITNEXUS_PACKAGE) -> GitNexusEnv:
|
|
84
|
+
"""Locate Node + npx without mutating anything."""
|
|
85
|
+
return GitNexusEnv(
|
|
86
|
+
node=shutil.which("node"),
|
|
87
|
+
npx=shutil.which("npx"),
|
|
88
|
+
package=package,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run(
|
|
93
|
+
cmd: list[str],
|
|
94
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
95
|
+
timeout: int = 900, # 900s = 15min default — npx cold-cache + first-time `gitnexus analyze` can be slow on monorepos
|
|
96
|
+
capture: bool = True,
|
|
97
|
+
) -> subprocess.CompletedProcess:
|
|
98
|
+
try:
|
|
99
|
+
return subprocess.run(
|
|
100
|
+
cmd,
|
|
101
|
+
cwd=str(cwd) if cwd else None,
|
|
102
|
+
check=False,
|
|
103
|
+
text=True,
|
|
104
|
+
capture_output=capture,
|
|
105
|
+
timeout=timeout,
|
|
106
|
+
)
|
|
107
|
+
except FileNotFoundError as exc: # npx vanished mid-run
|
|
108
|
+
raise GitNexusError(f"Failed to execute {cmd[0]!r}: {exc}") from exc
|
|
109
|
+
except subprocess.TimeoutExpired as exc:
|
|
110
|
+
raise GitNexusError(
|
|
111
|
+
f"GitNexus command timed out after {timeout}s: {' '.join(cmd)}"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class IndexState:
|
|
117
|
+
"""Whether a repo has a usable GitNexus index."""
|
|
118
|
+
|
|
119
|
+
exists: bool
|
|
120
|
+
path: Path
|
|
121
|
+
age_seconds: float | None = None
|
|
122
|
+
stale: bool = False
|
|
123
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
"""Serialize index state for diagnostic JSON (e.g. ``doctor()``)."""
|
|
127
|
+
return {
|
|
128
|
+
"exists": self.exists,
|
|
129
|
+
"path": str(self.path),
|
|
130
|
+
"age_seconds": self.age_seconds,
|
|
131
|
+
"stale": self.stale,
|
|
132
|
+
**({"details": self.details} if self.details else {}),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def index_state(root: str = ".", stale_seconds: int = DEFAULT_STALE_SECONDS) -> IndexState:
|
|
137
|
+
"""Inspect the on-disk GitNexus index for ``root`` without running GitNexus."""
|
|
138
|
+
index_path = Path(root).resolve() / INDEX_DIRNAME
|
|
139
|
+
if not index_path.exists():
|
|
140
|
+
return IndexState(exists=False, path=index_path)
|
|
141
|
+
|
|
142
|
+
# Use the most recently modified file inside the index dir as the age basis.
|
|
143
|
+
# We deliberately do NOT use the directory's own mtime — editors and package
|
|
144
|
+
# managers often touch the dir without rewriting any real index file.
|
|
145
|
+
newest = 0.0
|
|
146
|
+
for p in index_path.rglob("*"):
|
|
147
|
+
if p.is_file():
|
|
148
|
+
newest = max(newest, p.stat().st_mtime)
|
|
149
|
+
age = time.time() - newest if newest else None
|
|
150
|
+
stale = age is not None and age > stale_seconds
|
|
151
|
+
return IndexState(exists=True, path=index_path, age_seconds=age, stale=stale)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def analyze(
|
|
155
|
+
root: str = ".",
|
|
156
|
+
env: GitNexusEnv | None = None,
|
|
157
|
+
timeout: int = 1800, # 1800s = 30min — full re-analyze of a large repo; auto-only on missing or stale
|
|
158
|
+
) -> subprocess.CompletedProcess:
|
|
159
|
+
"""Build/refresh the GitNexus index for ``root`` (``gitnexus analyze``)."""
|
|
160
|
+
env = env or detect_env()
|
|
161
|
+
cmd = env.base_cmd() + ["analyze", "--path", str(Path(root).resolve())]
|
|
162
|
+
proc = _run(cmd, cwd=root, timeout=timeout)
|
|
163
|
+
if proc.returncode != 0:
|
|
164
|
+
raise GitNexusError(
|
|
165
|
+
f"`gitnexus analyze` failed (exit {proc.returncode}).\n{proc.stderr or proc.stdout}"
|
|
166
|
+
)
|
|
167
|
+
return proc
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── Preflight: Validate Index Freshness ───────────────────────────────
|
|
171
|
+
# `ensure_index` is the gate every query flows through: it inspects the
|
|
172
|
+
# on-disk `.gitnexus/` directory, compares its age against the staleness
|
|
173
|
+
# threshold, and (by default) auto-rebuilds so agents never run blind.
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ensure_index(
|
|
177
|
+
root: str = ".",
|
|
178
|
+
*,
|
|
179
|
+
env: GitNexusEnv | None = None,
|
|
180
|
+
stale_seconds: int = DEFAULT_STALE_SECONDS,
|
|
181
|
+
auto: bool = True,
|
|
182
|
+
) -> IndexState:
|
|
183
|
+
"""Guarantee a fresh index exists.
|
|
184
|
+
|
|
185
|
+
With ``auto=True`` (the bundle default) a missing or stale index is rebuilt
|
|
186
|
+
automatically so agents always have graph context. With ``auto=False`` the
|
|
187
|
+
caller is told to index but nothing is mutated.
|
|
188
|
+
"""
|
|
189
|
+
env = env or detect_env()
|
|
190
|
+
if not env.available:
|
|
191
|
+
raise GitNexusError(
|
|
192
|
+
"GitNexus is required but Node/npx is not available. "
|
|
193
|
+
"Install Node.js (>=18) so coder agents are not flying blind."
|
|
194
|
+
)
|
|
195
|
+
# Note: we only auto-rebuild on missing OR stale. A valid-but-old index
|
|
196
|
+
# within `stale_seconds` is trusted as-is to avoid a full re-analyze on
|
|
197
|
+
# every query, which on large repos can take minutes.
|
|
198
|
+
state = index_state(root, stale_seconds=stale_seconds)
|
|
199
|
+
if state.exists and not state.stale:
|
|
200
|
+
return state
|
|
201
|
+
if not auto:
|
|
202
|
+
return state
|
|
203
|
+
analyze(root, env=env)
|
|
204
|
+
return index_state(root, stale_seconds=stale_seconds)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Query: Cached Codebase Graph Access ───────────────────────────────
|
|
208
|
+
# Thin wrappers over the GitNexus CLI query surface. Each is a one-line
|
|
209
|
+
# passthrough to `_query` so the error-handling and timeout policy stay
|
|
210
|
+
# in one place. The CLI's own caching layer is what makes these fast
|
|
211
|
+
# for repeated calls within a session.
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _query(
|
|
215
|
+
subcommand: list[str],
|
|
216
|
+
root: str = ".",
|
|
217
|
+
env: GitNexusEnv | None = None,
|
|
218
|
+
timeout: int = 300, # 300s = 5min for read-only graph queries (should hit npx cache, hence lower than analyze)
|
|
219
|
+
) -> str:
|
|
220
|
+
"""Run a read-only GitNexus query command and return stdout."""
|
|
221
|
+
env = env or detect_env()
|
|
222
|
+
cmd = env.base_cmd() + subcommand
|
|
223
|
+
proc = _run(cmd, cwd=root, timeout=timeout)
|
|
224
|
+
if proc.returncode != 0:
|
|
225
|
+
raise GitNexusError(
|
|
226
|
+
f"`gitnexus {' '.join(subcommand)}` failed (exit {proc.returncode}).\n"
|
|
227
|
+
f"{proc.stderr or proc.stdout}"
|
|
228
|
+
)
|
|
229
|
+
return proc.stdout.strip()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def ai_context(task: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
|
|
233
|
+
"""Get task-scoped, graph-aware context for an agent (``gitnexus ai-context``)."""
|
|
234
|
+
return _query(["ai-context", task], root=root, env=env)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def query(question: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
|
|
238
|
+
"""Natural-language graph query (``gitnexus query``)."""
|
|
239
|
+
return _query(["query", question], root=root, env=env)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def context(symbol: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
|
|
243
|
+
"""Structural context for a symbol (``gitnexus context``)."""
|
|
244
|
+
return _query(["context", symbol], root=root, env=env)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def impact(symbol: str, root: str = ".", env: GitNexusEnv | None = None) -> str:
|
|
248
|
+
"""Blast-radius / impact analysis for a symbol (``gitnexus impact``)."""
|
|
249
|
+
return _query(["impact", symbol], root=root, env=env)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def doctor(root: str = ".", env: GitNexusEnv | None = None) -> dict[str, Any]:
|
|
253
|
+
"""Aggregate health report: runtime + index availability."""
|
|
254
|
+
env = env or detect_env()
|
|
255
|
+
report: dict[str, Any] = {
|
|
256
|
+
"node": env.node,
|
|
257
|
+
"npx": env.npx,
|
|
258
|
+
"package": env.package,
|
|
259
|
+
"available": env.available,
|
|
260
|
+
}
|
|
261
|
+
if env.available:
|
|
262
|
+
state = index_state(root)
|
|
263
|
+
report["index"] = state.to_dict()
|
|
264
|
+
else:
|
|
265
|
+
report["error"] = "Node.js/npx not found on PATH."
|
|
266
|
+
return report
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ── MCP wiring: GitNexus server for coder agents ──────────────────────
|
|
270
|
+
# GitNexus exposes its graph tools over stdio via `gitnexus mcp`. We register
|
|
271
|
+
# that same command with every supported agent so the agent's tools list
|
|
272
|
+
# includes `gitnexus_query`, `gitnexus_context`, `gitnexus_impact`, etc.
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# The single MCP server entry every agent should run. GitNexus exposes its graph
|
|
276
|
+
# tools over stdio via `gitnexus mcp`.
|
|
277
|
+
def mcp_server_command(package: str = GITNEXUS_PACKAGE) -> dict[str, Any]:
|
|
278
|
+
"""Return the MCP server launch spec as a ``{command, args}`` dict.
|
|
279
|
+
|
|
280
|
+
This is the canonical payload used by every ``_wire_*`` helper below, and
|
|
281
|
+
can also be passed to external MCP-aware clients directly.
|
|
282
|
+
"""
|
|
283
|
+
return {"command": "npx", "args": ["-y", package, "mcp"]}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _opencode_config_path() -> Path:
|
|
287
|
+
return Path.home() / ".config" / "opencode" / "opencode.json"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _codex_config_path() -> Path:
|
|
291
|
+
return Path.home() / ".codex" / "config.toml"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _hermes_config_path() -> Path:
|
|
295
|
+
return Path.home() / ".hermes" / "mcp.json"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
AGENTS = ("opencode", "codex", "hermes")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _wire_opencode(package: str) -> str:
|
|
302
|
+
path = _opencode_config_path()
|
|
303
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
data: dict[str, Any] = {}
|
|
305
|
+
if path.is_file():
|
|
306
|
+
try:
|
|
307
|
+
data = json.loads(path.read_text() or "{}")
|
|
308
|
+
except json.JSONDecodeError:
|
|
309
|
+
data = {}
|
|
310
|
+
mcp = data.setdefault("mcp", {})
|
|
311
|
+
mcp["gitnexus"] = {
|
|
312
|
+
"type": "local",
|
|
313
|
+
"command": ["npx", "-y", package, "mcp"],
|
|
314
|
+
"enabled": True,
|
|
315
|
+
}
|
|
316
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
317
|
+
return str(path)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _wire_codex(package: str) -> str:
|
|
321
|
+
path = _codex_config_path()
|
|
322
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
block = f'\n[mcp_servers.gitnexus]\ncommand = "npx"\nargs = ["-y", "{package}", "mcp"]\n'
|
|
324
|
+
existing = path.read_text() if path.is_file() else ""
|
|
325
|
+
if "[mcp_servers.gitnexus]" in existing:
|
|
326
|
+
return str(path) # already wired; leave user edits intact
|
|
327
|
+
path.write_text(existing + block)
|
|
328
|
+
return str(path)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _wire_hermes(package: str) -> str:
|
|
332
|
+
path = _hermes_config_path()
|
|
333
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
data: dict[str, Any] = {}
|
|
335
|
+
if path.is_file():
|
|
336
|
+
try:
|
|
337
|
+
data = json.loads(path.read_text() or "{}")
|
|
338
|
+
except json.JSONDecodeError:
|
|
339
|
+
data = {}
|
|
340
|
+
servers = data.setdefault("mcpServers", {})
|
|
341
|
+
servers["gitnexus"] = mcp_server_command(package)
|
|
342
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
343
|
+
return str(path)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
_WIRERS = {
|
|
347
|
+
"opencode": _wire_opencode,
|
|
348
|
+
"codex": _wire_codex,
|
|
349
|
+
"hermes": _wire_hermes,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def setup_agents(
|
|
354
|
+
agents: list[str] | None = None,
|
|
355
|
+
package: str = GITNEXUS_PACKAGE,
|
|
356
|
+
) -> dict[str, str]:
|
|
357
|
+
"""Wire the GitNexus MCP server into each agent's config.
|
|
358
|
+
|
|
359
|
+
Returns a mapping of agent -> config file written.
|
|
360
|
+
"""
|
|
361
|
+
chosen = agents or list(AGENTS)
|
|
362
|
+
written: dict[str, str] = {}
|
|
363
|
+
for agent in chosen:
|
|
364
|
+
wirer = _WIRERS.get(agent)
|
|
365
|
+
if not wirer:
|
|
366
|
+
raise GitNexusError(f"Unknown agent: {agent!r}. Known: {', '.join(AGENTS)}")
|
|
367
|
+
written[agent] = wirer(package)
|
|
368
|
+
return written
|