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.
Files changed (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. 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()
@@ -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.