sid-harness 0.3.0__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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Skills loader for the SID harness (the repo's ``.opencode/skills`` pattern).
|
|
2
|
+
|
|
3
|
+
A skill is a directory containing ``SKILL.md`` with a YAML frontmatter block
|
|
4
|
+
(``name``, ``description``) and a markdown body. This module reads that layout
|
|
5
|
+
and renders a skill into prompt text so the loop can load a workstream skill
|
|
6
|
+
without a third-party YAML dependency. The frontmatter subset the repo uses —
|
|
7
|
+
scalars plus ``|`` / ``|-`` / ``>`` / ``>-`` block scalars — is parsed directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
_BLOCK_MARKERS = ("|", "|-", "|+", ">", ">-", ">+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SkillError(RuntimeError):
|
|
21
|
+
"""A skill file or directory could not be loaded."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Skill:
|
|
26
|
+
name: str
|
|
27
|
+
description: str
|
|
28
|
+
body: str
|
|
29
|
+
path: Path
|
|
30
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _strip_quotes(value: str) -> str:
|
|
34
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
35
|
+
return value[1:-1]
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_block(
|
|
40
|
+
lines: list[str], start: int, *, folded: bool, strip_chomp: bool
|
|
41
|
+
) -> tuple[str, int]:
|
|
42
|
+
collected: list[str] = []
|
|
43
|
+
indent: int | None = None
|
|
44
|
+
index = start
|
|
45
|
+
while index < len(lines):
|
|
46
|
+
line = lines[index]
|
|
47
|
+
if line.strip() == "":
|
|
48
|
+
collected.append("")
|
|
49
|
+
index += 1
|
|
50
|
+
continue
|
|
51
|
+
current = len(line) - len(line.lstrip(" "))
|
|
52
|
+
if current == 0:
|
|
53
|
+
break
|
|
54
|
+
if indent is None:
|
|
55
|
+
indent = current
|
|
56
|
+
if current < indent:
|
|
57
|
+
break
|
|
58
|
+
collected.append(line[indent:])
|
|
59
|
+
index += 1
|
|
60
|
+
while collected and collected[-1] == "":
|
|
61
|
+
collected.pop()
|
|
62
|
+
text = " ".join(part for part in collected) if folded else "\n".join(collected)
|
|
63
|
+
if strip_chomp:
|
|
64
|
+
text = text.rstrip("\n")
|
|
65
|
+
return text, index
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
69
|
+
"""Split ``text`` into (frontmatter mapping, body). Missing frontmatter -> ({}, text)."""
|
|
70
|
+
lines = text.split("\n")
|
|
71
|
+
if not lines or lines[0].strip() != "---":
|
|
72
|
+
return {}, text
|
|
73
|
+
meta: dict[str, Any] = {}
|
|
74
|
+
index = 1
|
|
75
|
+
while index < len(lines) and lines[index].strip() != "---":
|
|
76
|
+
raw = lines[index]
|
|
77
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
78
|
+
index += 1
|
|
79
|
+
continue
|
|
80
|
+
if ":" not in raw:
|
|
81
|
+
raise SkillError(f"invalid frontmatter line: {raw!r}")
|
|
82
|
+
key, _, rest = raw.partition(":")
|
|
83
|
+
key = key.strip()
|
|
84
|
+
rest = rest.strip()
|
|
85
|
+
if not key:
|
|
86
|
+
raise SkillError(f"invalid frontmatter line: {raw!r}")
|
|
87
|
+
if rest in _BLOCK_MARKERS:
|
|
88
|
+
block, index = _read_block(
|
|
89
|
+
lines,
|
|
90
|
+
index + 1,
|
|
91
|
+
folded=rest.startswith(">"),
|
|
92
|
+
strip_chomp=rest.endswith("-"),
|
|
93
|
+
)
|
|
94
|
+
meta[key] = block
|
|
95
|
+
else:
|
|
96
|
+
meta[key] = _strip_quotes(rest)
|
|
97
|
+
index += 1
|
|
98
|
+
body = "\n".join(lines[index + 1 :]) if index < len(lines) else ""
|
|
99
|
+
return meta, body.lstrip("\n")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_skill(path: str | os.PathLike[str]) -> Skill:
|
|
103
|
+
skill_path = Path(path)
|
|
104
|
+
try:
|
|
105
|
+
text = skill_path.read_text(encoding="utf-8")
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise SkillError(f"cannot read skill {skill_path}: {exc}") from exc
|
|
108
|
+
meta, body = parse_frontmatter(text)
|
|
109
|
+
name = str(meta.get("name") or skill_path.parent.name).strip()
|
|
110
|
+
if not name:
|
|
111
|
+
raise SkillError(f"skill {skill_path} has no name")
|
|
112
|
+
description = str(meta.get("description") or "").strip()
|
|
113
|
+
return Skill(
|
|
114
|
+
name=name,
|
|
115
|
+
description=description,
|
|
116
|
+
body=body,
|
|
117
|
+
path=skill_path,
|
|
118
|
+
metadata=meta,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_skills(root: str | os.PathLike[str]) -> list[Skill]:
|
|
123
|
+
base = Path(root).expanduser()
|
|
124
|
+
if not base.is_dir():
|
|
125
|
+
raise SkillError(f"skills directory not found: {base}")
|
|
126
|
+
skills: list[Skill] = []
|
|
127
|
+
for entry in sorted(base.iterdir(), key=lambda item: item.name):
|
|
128
|
+
if not entry.is_dir():
|
|
129
|
+
continue
|
|
130
|
+
skill_file = entry / "SKILL.md"
|
|
131
|
+
if skill_file.is_file():
|
|
132
|
+
skills.append(load_skill(skill_file))
|
|
133
|
+
return skills
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def default_skills_root(start: str | os.PathLike[str] | None = None) -> Path | None:
|
|
137
|
+
"""Walk up from ``start`` (or cwd) to the nearest ``.opencode/skills``."""
|
|
138
|
+
here = Path(start).expanduser().resolve() if start else Path.cwd().resolve()
|
|
139
|
+
for candidate in (here, *here.parents):
|
|
140
|
+
probe = candidate / ".opencode" / "skills"
|
|
141
|
+
if probe.is_dir():
|
|
142
|
+
return probe
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SkillCatalog:
|
|
147
|
+
"""Named skills with prompt rendering."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, skills: list[Skill] | None = None) -> None:
|
|
150
|
+
self._by_name: dict[str, Skill] = {}
|
|
151
|
+
for skill in skills or []:
|
|
152
|
+
self._by_name[skill.name] = skill
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def load(cls, root: str | os.PathLike[str]) -> SkillCatalog:
|
|
156
|
+
return cls(load_skills(root))
|
|
157
|
+
|
|
158
|
+
def names(self) -> list[str]:
|
|
159
|
+
return sorted(self._by_name)
|
|
160
|
+
|
|
161
|
+
def get(self, name: str) -> Skill:
|
|
162
|
+
if name not in self._by_name:
|
|
163
|
+
raise SkillError(f"unknown skill {name!r}; known: {', '.join(self.names())}")
|
|
164
|
+
return self._by_name[name]
|
|
165
|
+
|
|
166
|
+
def render(self, names: list[str]) -> str:
|
|
167
|
+
parts: list[str] = []
|
|
168
|
+
for name in names:
|
|
169
|
+
skill = self.get(name)
|
|
170
|
+
header = f"## skill: {skill.name}"
|
|
171
|
+
if skill.description:
|
|
172
|
+
header = f"{header}\n{skill.description}"
|
|
173
|
+
parts.append(f"{header}\n\n{skill.body}".rstrip())
|
|
174
|
+
return "\n\n".join(parts)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = [
|
|
178
|
+
"Skill",
|
|
179
|
+
"SkillCatalog",
|
|
180
|
+
"SkillError",
|
|
181
|
+
"default_skills_root",
|
|
182
|
+
"load_skill",
|
|
183
|
+
"load_skills",
|
|
184
|
+
"parse_frontmatter",
|
|
185
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Subagent fan-out over the SAME native loop (SUB-56).
|
|
2
|
+
|
|
3
|
+
Each sub-task is a bounded ``native_agent.run_native_agent`` run — the exact
|
|
4
|
+
engine the main loop uses, driven by the caller's ``complete`` callback so every
|
|
5
|
+
model call still settles through the gateway under the caller's key. There is no
|
|
6
|
+
second engine and no second verify path; this module only schedules the same
|
|
7
|
+
loop concurrently, with a bounded number in flight.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from sid_harness.gateway import native_agent
|
|
18
|
+
|
|
19
|
+
Complete = Callable[[list[dict[str, Any]], list[dict[str, Any]]], Awaitable[dict[str, Any]]]
|
|
20
|
+
RunTests = Callable[[dict[str, str]], Awaitable[dict[str, Any]]]
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_CONCURRENCY = 4
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SubagentError(ValueError):
|
|
26
|
+
"""A subagent fan-out was requested with invalid inputs."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SubTask:
|
|
31
|
+
id: str
|
|
32
|
+
task: str
|
|
33
|
+
acceptance_criteria: list[str] = field(default_factory=list)
|
|
34
|
+
out_of_scope: list[str] = field(default_factory=list)
|
|
35
|
+
workspace_files: dict[str, str] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _summarize(subtask: SubTask, result: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"id": subtask.id,
|
|
41
|
+
"task": subtask.task,
|
|
42
|
+
"changed": result.get("changed", []),
|
|
43
|
+
"diff": result.get("diff"),
|
|
44
|
+
"final_response": result.get("final_response"),
|
|
45
|
+
"steps": result.get("steps"),
|
|
46
|
+
"tool_calls": result.get("tool_calls"),
|
|
47
|
+
"model": result.get("model"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def fan_out(
|
|
52
|
+
*,
|
|
53
|
+
tasks: list[SubTask],
|
|
54
|
+
complete: Complete,
|
|
55
|
+
run_tests: RunTests,
|
|
56
|
+
workspace_files: dict[str, str],
|
|
57
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY,
|
|
58
|
+
) -> list[dict[str, Any]]:
|
|
59
|
+
"""Run each sub-task through the shared native loop, bounded concurrency.
|
|
60
|
+
|
|
61
|
+
Each sub-task gets its own copy of the workspace, so sub-runs cannot see
|
|
62
|
+
each other's edits; the caller's ``complete``/``run_tests`` callbacks keep
|
|
63
|
+
every model call and every test on the single gateway path.
|
|
64
|
+
"""
|
|
65
|
+
if max_concurrency < 1:
|
|
66
|
+
raise SubagentError("max_concurrency must be at least 1")
|
|
67
|
+
if not tasks:
|
|
68
|
+
return []
|
|
69
|
+
seen: set[str] = set()
|
|
70
|
+
for subtask in tasks:
|
|
71
|
+
if subtask.id in seen:
|
|
72
|
+
raise SubagentError(f"duplicate sub-task id {subtask.id!r}")
|
|
73
|
+
seen.add(subtask.id)
|
|
74
|
+
|
|
75
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
76
|
+
|
|
77
|
+
async def run_one(subtask: SubTask) -> dict[str, Any]:
|
|
78
|
+
async with semaphore:
|
|
79
|
+
files = dict(
|
|
80
|
+
subtask.workspace_files
|
|
81
|
+
if subtask.workspace_files is not None
|
|
82
|
+
else workspace_files
|
|
83
|
+
)
|
|
84
|
+
result = await native_agent.run_native_agent(
|
|
85
|
+
task=subtask.task,
|
|
86
|
+
workspace_files=files,
|
|
87
|
+
acceptance_criteria=subtask.acceptance_criteria
|
|
88
|
+
or ["Complete the sub-task described in the goal."],
|
|
89
|
+
out_of_scope=subtask.out_of_scope,
|
|
90
|
+
test_argv=[],
|
|
91
|
+
complete=complete,
|
|
92
|
+
run_tests=run_tests,
|
|
93
|
+
)
|
|
94
|
+
return _summarize(subtask, result)
|
|
95
|
+
|
|
96
|
+
return list(await asyncio.gather(*(run_one(subtask) for subtask in tasks)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fan_out_sync(**kwargs: Any) -> list[dict[str, Any]]:
|
|
100
|
+
"""Blocking wrapper around ``fan_out`` for callers without an event loop."""
|
|
101
|
+
return asyncio.run(fan_out(**kwargs))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"Complete",
|
|
106
|
+
"DEFAULT_MAX_CONCURRENCY",
|
|
107
|
+
"RunTests",
|
|
108
|
+
"SubTask",
|
|
109
|
+
"SubagentError",
|
|
110
|
+
"fan_out",
|
|
111
|
+
"fan_out_sync",
|
|
112
|
+
]
|
sid_harness/tui.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Minimal interactive terminal UI — the same engine as ``sid run``.
|
|
2
|
+
|
|
3
|
+
Standard library only (``input()``); no curses dependency. Each entered task is
|
|
4
|
+
one bounded run against the same directory, writing its own receipt.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .cli import (
|
|
13
|
+
_approved_test_argv,
|
|
14
|
+
_build_registry,
|
|
15
|
+
_file_link,
|
|
16
|
+
_git_write_approver,
|
|
17
|
+
_resolve_bounds,
|
|
18
|
+
)
|
|
19
|
+
from .client import SIDClient, SIDClientError
|
|
20
|
+
from .local_workspace import LocalWorkspaceError
|
|
21
|
+
from .runner import RunError, execute_run
|
|
22
|
+
from .session.bounds import BoundsError
|
|
23
|
+
from .workspace import WorkspaceError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_tui(args: argparse.Namespace) -> int:
|
|
27
|
+
try:
|
|
28
|
+
client = SIDClient(
|
|
29
|
+
base_url=args.base_url,
|
|
30
|
+
api_key=args.api_key,
|
|
31
|
+
routing_profile=args.routing_profile,
|
|
32
|
+
)
|
|
33
|
+
bounds = _resolve_bounds(args)
|
|
34
|
+
registry, closers = _build_registry(args)
|
|
35
|
+
except (SIDClientError, BoundsError) as exc:
|
|
36
|
+
print(f"BLOCKED: {exc}", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
print("sid-harness TUI — enter a task; an empty line or Ctrl-D exits.")
|
|
40
|
+
try:
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
task = input("sid> ").strip()
|
|
44
|
+
except (EOFError, KeyboardInterrupt):
|
|
45
|
+
print()
|
|
46
|
+
return 0
|
|
47
|
+
if not task:
|
|
48
|
+
return 0
|
|
49
|
+
git_write = bool(getattr(args, "git_write", False))
|
|
50
|
+
try:
|
|
51
|
+
outcome = execute_run(
|
|
52
|
+
task=task,
|
|
53
|
+
directory=args.dir,
|
|
54
|
+
client=client,
|
|
55
|
+
model=args.model,
|
|
56
|
+
criteria=args.criteria,
|
|
57
|
+
out_of_scope=args.out_of_scope,
|
|
58
|
+
test_argv=_approved_test_argv(args),
|
|
59
|
+
max_tokens=args.max_tokens,
|
|
60
|
+
reasoning_effort=args.reasoning_effort,
|
|
61
|
+
verify=args.verify,
|
|
62
|
+
write=getattr(args, "write", False),
|
|
63
|
+
git_write=git_write,
|
|
64
|
+
git_branch=getattr(args, "git_branch", None),
|
|
65
|
+
git_message=getattr(args, "git_message", None),
|
|
66
|
+
git_base=getattr(args, "git_base", None),
|
|
67
|
+
git_pr=not getattr(args, "no_pr", False),
|
|
68
|
+
git_approve=_git_write_approver(args) if git_write else None,
|
|
69
|
+
receipt_path=args.receipt,
|
|
70
|
+
context=getattr(args, "context", False),
|
|
71
|
+
context_k=getattr(args, "context_k", 4),
|
|
72
|
+
registry=registry,
|
|
73
|
+
bounds=bounds,
|
|
74
|
+
skills=getattr(args, "skills", None),
|
|
75
|
+
skills_root=getattr(args, "skills_root", None),
|
|
76
|
+
subagents=getattr(args, "subagent", None),
|
|
77
|
+
plan=bool(getattr(args, "plan", False)),
|
|
78
|
+
todos=getattr(args, "todo", None),
|
|
79
|
+
)
|
|
80
|
+
except (SIDClientError, WorkspaceError, LocalWorkspaceError, BoundsError, RunError) as exc:
|
|
81
|
+
print(f"BLOCKED: {exc}", file=sys.stderr)
|
|
82
|
+
continue
|
|
83
|
+
receipt = outcome.receipt
|
|
84
|
+
print(f"changed: {', '.join(receipt['changed']) or '(none)'}")
|
|
85
|
+
git_block = receipt.get("git_write")
|
|
86
|
+
if git_block:
|
|
87
|
+
print(
|
|
88
|
+
f"git_write: {git_block.get('status')} branch: "
|
|
89
|
+
f"{git_block.get('branch')} commit: {git_block.get('commit')} "
|
|
90
|
+
f"pr: {git_block.get('pr_url')}"
|
|
91
|
+
)
|
|
92
|
+
print(receipt["final_response"])
|
|
93
|
+
print(f"receipt: {_file_link(outcome.receipt_path)}")
|
|
94
|
+
finally:
|
|
95
|
+
for mcp in closers:
|
|
96
|
+
mcp.close()
|
sid_harness/workspace.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Load a local directory into the native loop's bounded in-memory workspace.
|
|
2
|
+
|
|
3
|
+
v1 is READ-ONLY: it selects eligible text files from ``--dir`` and never writes
|
|
4
|
+
back to the user's tree (local FS writes are SUB-54). Eligibility and the secret
|
|
5
|
+
/ dependency / VCS denylist come from the shared ``native_agent._safe_path`` —
|
|
6
|
+
the harness does not carry a second safety model.
|
|
7
|
+
|
|
8
|
+
SUB-54 adds an optional ``ignore`` predicate (used by ``local_workspace`` for
|
|
9
|
+
``.gitignore``); the walk, the shared denylist, and the bounds stay here so
|
|
10
|
+
there is one selection path, not two.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .gateway import native_agent
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_FILES = native_agent.MAX_FILES
|
|
23
|
+
DEFAULT_MAX_WORKSPACE_BYTES = native_agent.MAX_WORKSPACE_BYTES
|
|
24
|
+
DEFAULT_MAX_FILE_BYTES = native_agent.MAX_FILE_BYTES
|
|
25
|
+
|
|
26
|
+
# Pruned during the walk purely for speed; correctness still runs every file
|
|
27
|
+
# through native_agent._safe_path, which enforces the same denylist.
|
|
28
|
+
_SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WorkspaceError(RuntimeError):
|
|
32
|
+
"""The local directory could not be loaded into a bounded workspace."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class WorkspaceSelection:
|
|
37
|
+
root: Path
|
|
38
|
+
files: dict[str, str]
|
|
39
|
+
skipped: list[tuple[str, str]]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def total_bytes(self) -> int:
|
|
43
|
+
return sum(len(value.encode("utf-8")) for value in self.files.values())
|
|
44
|
+
|
|
45
|
+
def skipped_report(self) -> list[dict[str, str]]:
|
|
46
|
+
return [{"path": path, "reason": reason} for path, reason in self.skipped]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _iter_files(root: Path):
|
|
50
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
51
|
+
dirnames[:] = sorted(
|
|
52
|
+
name for name in dirnames if name.lower() not in _SKIP_DIRS
|
|
53
|
+
)
|
|
54
|
+
for name in sorted(filenames):
|
|
55
|
+
yield Path(dirpath) / name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_workspace(
|
|
59
|
+
root: str | os.PathLike[str],
|
|
60
|
+
*,
|
|
61
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
62
|
+
max_bytes: int = DEFAULT_MAX_WORKSPACE_BYTES,
|
|
63
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
64
|
+
ignore: Callable[[str], bool] | None = None,
|
|
65
|
+
) -> WorkspaceSelection:
|
|
66
|
+
base = Path(root).expanduser().resolve()
|
|
67
|
+
if not base.is_dir():
|
|
68
|
+
raise WorkspaceError(f"--dir is not a directory: {base}")
|
|
69
|
+
|
|
70
|
+
files: dict[str, str] = {}
|
|
71
|
+
skipped: list[tuple[str, str]] = []
|
|
72
|
+
total = 0
|
|
73
|
+
for path in _iter_files(base):
|
|
74
|
+
rel = path.relative_to(base).as_posix()
|
|
75
|
+
try:
|
|
76
|
+
safe = native_agent._safe_path(rel)
|
|
77
|
+
except native_agent.NativeAgentError as exc:
|
|
78
|
+
skipped.append((rel, str(exc)))
|
|
79
|
+
continue
|
|
80
|
+
if ignore is not None and ignore(rel):
|
|
81
|
+
skipped.append((rel, "gitignored"))
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
size = path.stat().st_size
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
skipped.append((rel, f"stat failed: {exc}"))
|
|
87
|
+
continue
|
|
88
|
+
if size > max_file_bytes:
|
|
89
|
+
skipped.append((rel, f"exceeds {max_file_bytes}-byte file limit"))
|
|
90
|
+
continue
|
|
91
|
+
if len(files) >= max_files:
|
|
92
|
+
skipped.append((rel, f"exceeds {max_files}-file workspace limit"))
|
|
93
|
+
continue
|
|
94
|
+
if total + size > max_bytes:
|
|
95
|
+
skipped.append((rel, f"exceeds {max_bytes}-byte workspace limit"))
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
text = path.read_text(encoding="utf-8")
|
|
99
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
100
|
+
skipped.append((rel, f"not readable utf-8 text: {exc}"))
|
|
101
|
+
continue
|
|
102
|
+
files[safe] = text
|
|
103
|
+
total += len(text.encode("utf-8"))
|
|
104
|
+
|
|
105
|
+
if not files:
|
|
106
|
+
raise WorkspaceError(f"no eligible text files found under {base}")
|
|
107
|
+
return WorkspaceSelection(root=base, files=files, skipped=skipped)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SID editor integration
|
|
2
|
+
|
|
3
|
+
A VS Code extension that drives the **same** SID engine as `sid run`, `sid tui`,
|
|
4
|
+
and the Studio bridge. It does not carry a second loop, a second tool contract,
|
|
5
|
+
or a second write path.
|
|
6
|
+
|
|
7
|
+
## One install command
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m sid_harness editor install
|
|
11
|
+
# or, when the console script is on PATH:
|
|
12
|
+
sid editor install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That copies `editors/vscode` into VS Code's extensions directory as
|
|
16
|
+
`sid.sid-harness-<version>`. Reload VS Code to activate it. Set `SID_API_KEY` in
|
|
17
|
+
the environment VS Code inherits (and optionally `sid.command` / `sid.testCommand`
|
|
18
|
+
in Settings). Re-run with `--force` to refresh an existing install.
|
|
19
|
+
|
|
20
|
+
## What the extension does
|
|
21
|
+
|
|
22
|
+
| Command | Engine path |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `SID: Open Coding Session` | `sid session start` (the one native loop) |
|
|
25
|
+
| `SID: Show Proposed Diff` | the run receipt's `diff` |
|
|
26
|
+
| `SID: Approve and Apply` | `sid apply --approve` (same `LocalWorkspace` + repo policy write path) |
|
|
27
|
+
| `SID: Attach Selection as Context` | prepends the selection to the next session task |
|
|
28
|
+
| `SID: Push Diagnostics to Session` | forwards VS Code diagnostics to `sid lsp` |
|
|
29
|
+
|
|
30
|
+
The extension also starts `sid lsp --dir <workspace>` automatically. The server
|
|
31
|
+
publishes real Python compiler diagnostics (`compile`) and ingests diagnostics
|
|
32
|
+
from every other language server in VS Code through `sid/diagnostics`. It writes
|
|
33
|
+
the merged feed to `<workspace>/.sid/diagnostics.json`; the loop reads that file
|
|
34
|
+
and prepends a data-boundaried `COMPILER DIAGNOSTICS` block to the task, and the
|
|
35
|
+
receipt records `diagnostics: {count, sources, path}`. Use `--no-diagnostics` to
|
|
36
|
+
opt out for one run.
|
|
37
|
+
|
|
38
|
+
## Install from a source checkout
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd infra/sid-harness
|
|
42
|
+
python -m sid_harness editor install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`SID_HARNESS_EXTENSION_PATH` overrides extension discovery (for a packaged
|
|
46
|
+
install). No `vsce`, npm dependency, or marketplace account is required.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SID Harness for VS Code
|
|
2
|
+
|
|
3
|
+
Drive SID's first-party coding loop without leaving the editor.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m sid_harness editor install
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Reload VS Code. The `SID:` commands appear in the Command Palette.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- The `sid` harness installed (`pip install sid-harness`) and on `PATH`, or set
|
|
16
|
+
`sid.command` to its executable.
|
|
17
|
+
- `SID_API_KEY` in the environment VS Code inherits.
|
|
18
|
+
- Open a folder; SID works on that folder only.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
- **SID: Open Coding Session** — runs `sid session start` and shows the result.
|
|
23
|
+
- **SID: Show Proposed Diff** — opens the receipt's diff in a diff editor.
|
|
24
|
+
- **SID: Approve and Apply** — asks for confirmation, then calls `sid apply`
|
|
25
|
+
(the same write path and repo policy as the CLI).
|
|
26
|
+
- **SID: Attach Selection as Context** — includes the selected text in the next
|
|
27
|
+
session task.
|
|
28
|
+
- **SID: Push Diagnostics to Session** — forwards VS Code diagnostics now.
|
|
29
|
+
|
|
30
|
+
`sid lsp` starts with the extension (Settings → SID → `autoStartLsp`). It feeds
|
|
31
|
+
compiler and language-server diagnostics into every session through the same
|
|
32
|
+
loop the CLI and Studio use.
|