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
sid_harness/receipt.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The harness run receipt.
|
|
2
|
+
|
|
3
|
+
One JSON artifact per run, written to disk so a run explains itself: what was
|
|
4
|
+
asked, what the loop did, the diff, the route, and — honestly — whether the
|
|
5
|
+
approved tests ran (SUB-80: executed locally, with backend/exit/passed and a
|
|
6
|
+
bounded output capture; ``tests_executed`` stays false when no approved test
|
|
7
|
+
command ran). Durable per-session history is SUB-58.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
RECEIPT_VERSION = 1
|
|
20
|
+
HARNESS_NAME = "sid-harness"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def new_run_id() -> str:
|
|
24
|
+
return f"run_{uuid.uuid4().hex}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def utc_now() -> str:
|
|
28
|
+
return datetime.now(timezone.utc).isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_receipt_path(directory: Path, run_id: str) -> Path:
|
|
32
|
+
return Path(directory) / ".sid" / "runs" / f"{run_id}.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_receipt(
|
|
36
|
+
*,
|
|
37
|
+
run_id: str,
|
|
38
|
+
task: str,
|
|
39
|
+
directory: Path,
|
|
40
|
+
base_url: str,
|
|
41
|
+
routing_profile: str,
|
|
42
|
+
requested_model: str,
|
|
43
|
+
workspace: dict[str, Any],
|
|
44
|
+
result: dict[str, Any],
|
|
45
|
+
verify: dict[str, Any] | None = None,
|
|
46
|
+
started_at: str | None = None,
|
|
47
|
+
finished_at: str | None = None,
|
|
48
|
+
git: dict[str, Any] | None = None,
|
|
49
|
+
git_status: list[str] | None = None,
|
|
50
|
+
wrote: list[str] | None = None,
|
|
51
|
+
deleted: list[str] | None = None,
|
|
52
|
+
pending_deletions: list[str] | None = None,
|
|
53
|
+
context: dict[str, Any] | None = None,
|
|
54
|
+
tools: dict[str, Any] | None = None,
|
|
55
|
+
bounds: dict[str, Any] | None = None,
|
|
56
|
+
policy: dict[str, Any] | None = None,
|
|
57
|
+
diagnostics: dict[str, Any] | None = None,
|
|
58
|
+
git_write: dict[str, Any] | None = None,
|
|
59
|
+
project_rules: dict[str, Any] | None = None,
|
|
60
|
+
skills: dict[str, Any] | None = None,
|
|
61
|
+
subagents: list[dict[str, Any]] | None = None,
|
|
62
|
+
plan: dict[str, Any] | None = None,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
test = result.get("test") or {}
|
|
65
|
+
receipt = {
|
|
66
|
+
"receipt_version": RECEIPT_VERSION,
|
|
67
|
+
"run_id": run_id,
|
|
68
|
+
"harness": {"name": HARNESS_NAME, "engine": "native"},
|
|
69
|
+
"created_at": utc_now(),
|
|
70
|
+
"started_at": started_at,
|
|
71
|
+
"finished_at": finished_at,
|
|
72
|
+
"task": task,
|
|
73
|
+
"dir": str(directory),
|
|
74
|
+
"backend": {"base_url": base_url, "routing_profile": routing_profile},
|
|
75
|
+
"requested_model": requested_model,
|
|
76
|
+
"workspace": workspace,
|
|
77
|
+
"tests_executed": bool(test.get("executed")),
|
|
78
|
+
"test_backend": test.get("backend"),
|
|
79
|
+
"test_exit_code": test.get("exit_code"),
|
|
80
|
+
"test_passed": test.get("passed"),
|
|
81
|
+
"test_timed_out": bool(test.get("timed_out", False)),
|
|
82
|
+
"changed": result.get("changed", []),
|
|
83
|
+
"diff": result.get("diff"),
|
|
84
|
+
"patches": result.get("patches", []),
|
|
85
|
+
"final_response": result.get("final_response"),
|
|
86
|
+
"model": result.get("model"),
|
|
87
|
+
"steps": result.get("steps"),
|
|
88
|
+
"tool_calls": result.get("tool_calls"),
|
|
89
|
+
"turn_trace": result.get("turn_trace", []),
|
|
90
|
+
"route_calls": result.get("route_calls", []),
|
|
91
|
+
"test": test,
|
|
92
|
+
"shell": result.get("shell", []),
|
|
93
|
+
"shell_executed": any(
|
|
94
|
+
bool(entry.get("executed")) for entry in (result.get("shell") or [])
|
|
95
|
+
),
|
|
96
|
+
"verify": verify,
|
|
97
|
+
}
|
|
98
|
+
if git is not None:
|
|
99
|
+
receipt["git"] = git
|
|
100
|
+
receipt["git_status"] = git_status or []
|
|
101
|
+
if wrote is not None:
|
|
102
|
+
receipt["wrote"] = wrote
|
|
103
|
+
if deleted is not None:
|
|
104
|
+
receipt["deleted"] = deleted
|
|
105
|
+
if pending_deletions is not None:
|
|
106
|
+
receipt["pending_deletions"] = pending_deletions
|
|
107
|
+
if context is not None:
|
|
108
|
+
receipt["context"] = context
|
|
109
|
+
if tools is not None:
|
|
110
|
+
receipt["tools"] = tools
|
|
111
|
+
if bounds is not None:
|
|
112
|
+
receipt["bounds"] = bounds
|
|
113
|
+
if policy is not None:
|
|
114
|
+
receipt["policy"] = policy
|
|
115
|
+
if diagnostics is not None:
|
|
116
|
+
receipt["diagnostics"] = diagnostics
|
|
117
|
+
readback = result.get("readback")
|
|
118
|
+
if isinstance(readback, dict) and readback:
|
|
119
|
+
receipt["readback"] = readback
|
|
120
|
+
if git_write is not None:
|
|
121
|
+
receipt["git_write"] = git_write
|
|
122
|
+
if project_rules is not None:
|
|
123
|
+
receipt["project_rules"] = project_rules
|
|
124
|
+
if skills is not None:
|
|
125
|
+
receipt["skills"] = skills
|
|
126
|
+
if subagents:
|
|
127
|
+
receipt["subagents"] = subagents
|
|
128
|
+
if plan is not None:
|
|
129
|
+
receipt["plan"] = plan
|
|
130
|
+
return receipt
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def write_receipt(path: str | os.PathLike[str], receipt: dict[str, Any]) -> Path:
|
|
134
|
+
target = Path(path)
|
|
135
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
tmp = target.with_name(target.name + ".tmp")
|
|
137
|
+
tmp.write_text(
|
|
138
|
+
json.dumps(receipt, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
|
139
|
+
encoding="utf-8",
|
|
140
|
+
)
|
|
141
|
+
os.replace(tmp, target)
|
|
142
|
+
return target
|
sid_harness/runner.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""One run: load the workspace, drive the shared loop, verify, write a receipt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import receipt as receipt_mod
|
|
14
|
+
from .client import SIDClient
|
|
15
|
+
from .gateway import native_agent
|
|
16
|
+
from .local_workspace import LocalWorkspace
|
|
17
|
+
from .loop import execute_test_argv, run_loop
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONTEXT_K = 4
|
|
20
|
+
DEFAULT_MAX_CONTEXT_CHARS = 24000
|
|
21
|
+
|
|
22
|
+
_NUMBERED_STEP = re.compile(r"^\s*(?:\d+[.)]|[-*])\s+(.+?)\s*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunError(RuntimeError):
|
|
26
|
+
"""A run option (project policy, skill, plan) could not be honoured."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sha256_text(text: str) -> str:
|
|
30
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _config_string_list(config: dict[str, Any], key: str) -> list[str]:
|
|
34
|
+
value = config.get(key)
|
|
35
|
+
if value is None:
|
|
36
|
+
return []
|
|
37
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
38
|
+
raise RunError(f"project policy .sid/config field {key!r} must be a list of strings")
|
|
39
|
+
return [item.strip() for item in value if item.strip()]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _config_subagent_tasks(config: dict[str, Any]) -> list[dict[str, str]]:
|
|
43
|
+
value = config.get("subagents")
|
|
44
|
+
if value is None:
|
|
45
|
+
return []
|
|
46
|
+
if not isinstance(value, list):
|
|
47
|
+
raise RunError("project policy .sid/config field 'subagents' must be a list")
|
|
48
|
+
tasks: list[dict[str, str]] = []
|
|
49
|
+
for index, item in enumerate(value, start=1):
|
|
50
|
+
if isinstance(item, str) and item.strip():
|
|
51
|
+
tasks.append({"id": f"sub{index}", "task": item.strip()})
|
|
52
|
+
elif isinstance(item, dict) and str(item.get("task") or "").strip():
|
|
53
|
+
tasks.append(
|
|
54
|
+
{
|
|
55
|
+
"id": str(item.get("id") or f"sub{index}").strip() or f"sub{index}",
|
|
56
|
+
"task": str(item["task"]).strip(),
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
raise RunError(
|
|
61
|
+
"project policy .sid/config subagent entries must be strings "
|
|
62
|
+
"or objects with a non-empty 'task'"
|
|
63
|
+
)
|
|
64
|
+
return tasks
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_skills(
|
|
68
|
+
*,
|
|
69
|
+
root: str | Path | None,
|
|
70
|
+
names: list[str],
|
|
71
|
+
config: dict[str, Any],
|
|
72
|
+
project_root: Path,
|
|
73
|
+
source: str,
|
|
74
|
+
) -> tuple[str, dict[str, Any]]:
|
|
75
|
+
from .tools.skills import SkillCatalog, SkillError, default_skills_root
|
|
76
|
+
|
|
77
|
+
resolved = Path(root).expanduser() if root else None
|
|
78
|
+
if resolved is None and isinstance(config.get("skills_root"), str) and config["skills_root"].strip():
|
|
79
|
+
resolved = Path(config["skills_root"].strip()).expanduser()
|
|
80
|
+
if not resolved.is_absolute():
|
|
81
|
+
resolved = project_root / resolved
|
|
82
|
+
if resolved is None:
|
|
83
|
+
resolved = default_skills_root(project_root) or default_skills_root()
|
|
84
|
+
if resolved is None:
|
|
85
|
+
raise RunError(
|
|
86
|
+
"skills were requested but no .opencode/skills directory was found; "
|
|
87
|
+
"pass --skills-root <dir>"
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
catalog = SkillCatalog.load(resolved)
|
|
91
|
+
rendered = catalog.render(names)
|
|
92
|
+
except SkillError as exc:
|
|
93
|
+
raise RunError(str(exc)) from exc
|
|
94
|
+
loaded = []
|
|
95
|
+
for name in names:
|
|
96
|
+
skill = catalog.get(name)
|
|
97
|
+
try:
|
|
98
|
+
digest = _sha256_text(skill.path.read_text(encoding="utf-8"))
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
raise RunError(f"cannot hash skill {name!r}: {exc}") from exc
|
|
101
|
+
loaded.append({"name": skill.name, "path": str(skill.path), "sha256": digest})
|
|
102
|
+
return rendered, {"root": str(resolved), "source": source, "loaded": loaded}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class RunOutcome:
|
|
107
|
+
receipt: dict[str, Any]
|
|
108
|
+
receipt_path: Path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_context(
|
|
112
|
+
workspace: LocalWorkspace,
|
|
113
|
+
query: str,
|
|
114
|
+
*,
|
|
115
|
+
k: int,
|
|
116
|
+
max_chars: int,
|
|
117
|
+
embedder: Any | None = None,
|
|
118
|
+
) -> tuple[str, dict[str, Any]]:
|
|
119
|
+
"""Repo-scale retrieval over ``workspace`` (SUB-55) for ``query``.
|
|
120
|
+
|
|
121
|
+
Local, read-only, and reuses the gateway's ``app.rag`` retrieval seam. The
|
|
122
|
+
returned text is data-boundaried and sanitized; it is prepended to the task.
|
|
123
|
+
"""
|
|
124
|
+
from .context import build_index, context_block
|
|
125
|
+
|
|
126
|
+
index = build_index(workspace)
|
|
127
|
+
block, meta = context_block(index, query, k=k, embedder=embedder)
|
|
128
|
+
truncated = len(block) > max_chars
|
|
129
|
+
if truncated:
|
|
130
|
+
block = block[:max_chars]
|
|
131
|
+
meta = {
|
|
132
|
+
**meta,
|
|
133
|
+
"indexed_files": len(index.files),
|
|
134
|
+
"index_truncated": index.truncated,
|
|
135
|
+
"chars": len(block),
|
|
136
|
+
"truncated": truncated,
|
|
137
|
+
}
|
|
138
|
+
return block, meta
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def execute_run(
|
|
142
|
+
*,
|
|
143
|
+
task: str,
|
|
144
|
+
directory: str | Path,
|
|
145
|
+
client: SIDClient,
|
|
146
|
+
model: str = "auto",
|
|
147
|
+
criteria: list[str] | None = None,
|
|
148
|
+
out_of_scope: list[str] | None = None,
|
|
149
|
+
test_argv: list[str] | None = None,
|
|
150
|
+
max_tokens: int = 8000,
|
|
151
|
+
reasoning_effort: str | None = None,
|
|
152
|
+
verify: bool = False,
|
|
153
|
+
write: bool = False,
|
|
154
|
+
allow_delete: bool = False,
|
|
155
|
+
git_write: bool = False,
|
|
156
|
+
git_branch: str | None = None,
|
|
157
|
+
git_message: str | None = None,
|
|
158
|
+
git_base: str | None = None,
|
|
159
|
+
git_pr: bool = True,
|
|
160
|
+
git_approve: Any | None = None,
|
|
161
|
+
gh_runner: Any | None = None,
|
|
162
|
+
receipt_path: str | Path | None = None,
|
|
163
|
+
reporter: Callable[[str], None] | None = None,
|
|
164
|
+
context: bool = False,
|
|
165
|
+
context_k: int = DEFAULT_CONTEXT_K,
|
|
166
|
+
max_context_chars: int = DEFAULT_MAX_CONTEXT_CHARS,
|
|
167
|
+
registry: Any | None = None,
|
|
168
|
+
bounds: Any | None = None,
|
|
169
|
+
diagnostics: bool = True,
|
|
170
|
+
shell_policy: Any | None = None,
|
|
171
|
+
skills: list[str] | None = None,
|
|
172
|
+
skills_root: str | Path | None = None,
|
|
173
|
+
subagents: list[str] | None = None,
|
|
174
|
+
plan: bool = False,
|
|
175
|
+
todos: list[str] | None = None,
|
|
176
|
+
) -> RunOutcome:
|
|
177
|
+
emit = reporter or (lambda _message: None)
|
|
178
|
+
workspace = LocalWorkspace.open(directory)
|
|
179
|
+
load_kwargs: dict[str, Any] = {}
|
|
180
|
+
if bounds is not None:
|
|
181
|
+
load_kwargs = {
|
|
182
|
+
"max_files": bounds.max_files,
|
|
183
|
+
"max_bytes": bounds.max_workspace_bytes,
|
|
184
|
+
"max_file_bytes": bounds.max_file_bytes,
|
|
185
|
+
}
|
|
186
|
+
selection = workspace.load(**load_kwargs)
|
|
187
|
+
emit(f"workspace: {len(selection.files)} file(s) from {selection.root}")
|
|
188
|
+
if workspace.git.is_repo:
|
|
189
|
+
where = "worktree" if workspace.git.is_worktree else "checkout"
|
|
190
|
+
emit(f"git: {where} branch={workspace.git.branch or '?'} ({workspace.git.root})")
|
|
191
|
+
for path, reason in selection.skipped:
|
|
192
|
+
emit(f"skipped: {path} ({reason})")
|
|
193
|
+
|
|
194
|
+
# SUB-55: repo-scale context. Retrieval is over the whole indexed tree; the
|
|
195
|
+
# sanitized, data-boundaried block is prepended to the task.
|
|
196
|
+
effective_task = task
|
|
197
|
+
context_meta: dict[str, Any] | None = None
|
|
198
|
+
if context:
|
|
199
|
+
block, context_meta = _build_context(
|
|
200
|
+
workspace, task, k=context_k, max_chars=max_context_chars
|
|
201
|
+
)
|
|
202
|
+
if block:
|
|
203
|
+
effective_task = f"{block}\n\nTask: {task}"
|
|
204
|
+
emit(f"context: {context_meta['chunks_used']} chunk(s) from {context_meta['indexed_files']} file(s)")
|
|
205
|
+
|
|
206
|
+
# SUB-96: the editor/LSP diagnostics feed (``sid lsp`` writes it) is
|
|
207
|
+
# prepended to the task so the one loop sees the compiler's findings.
|
|
208
|
+
diagnostics_meta: dict[str, Any] | None = None
|
|
209
|
+
if diagnostics:
|
|
210
|
+
from .lsp import diagnostics_context
|
|
211
|
+
|
|
212
|
+
diag_block, diagnostics_meta = diagnostics_context(selection.root)
|
|
213
|
+
if diag_block:
|
|
214
|
+
effective_task = f"{diag_block}\n\n{effective_task}"
|
|
215
|
+
emit(f"diagnostics: {diagnostics_meta['count']} item(s) from the editor/LSP feed")
|
|
216
|
+
|
|
217
|
+
# SUB-94: the project policy the run will obey. The loop loads the same
|
|
218
|
+
# files into its system prompt; reading it here lets `.sid/config` supply
|
|
219
|
+
# run options (skills/subagents/plan) exactly as documented, with explicit
|
|
220
|
+
# CLI flags taking precedence.
|
|
221
|
+
try:
|
|
222
|
+
project_rules = native_agent.load_project_rules(selection.files)
|
|
223
|
+
except native_agent.NativeAgentInputError as exc:
|
|
224
|
+
raise RunError(str(exc)) from exc
|
|
225
|
+
project_config = project_rules.get("config") or {}
|
|
226
|
+
|
|
227
|
+
config_plan = project_config.get("plan")
|
|
228
|
+
if config_plan is not None and not isinstance(config_plan, bool):
|
|
229
|
+
raise RunError("project policy .sid/config field 'plan' must be true or false")
|
|
230
|
+
plan_active = bool(plan or config_plan)
|
|
231
|
+
plan_source = "flag" if plan else ("config" if config_plan else None)
|
|
232
|
+
|
|
233
|
+
skills_meta: dict[str, Any] | None = None
|
|
234
|
+
skill_names = (
|
|
235
|
+
[name.strip() for name in skills if name and name.strip()]
|
|
236
|
+
if skills is not None
|
|
237
|
+
else _config_string_list(project_config, "skills")
|
|
238
|
+
)
|
|
239
|
+
if skill_names:
|
|
240
|
+
rendered, skills_meta = _load_skills(
|
|
241
|
+
root=skills_root,
|
|
242
|
+
names=skill_names,
|
|
243
|
+
config=project_config,
|
|
244
|
+
project_root=selection.root,
|
|
245
|
+
source="flags" if skills is not None else "config",
|
|
246
|
+
)
|
|
247
|
+
effective_task = f"SKILLS — loaded project skills:\n{rendered}\n\n{effective_task}"
|
|
248
|
+
emit(f"skills: {', '.join(skill_names)}")
|
|
249
|
+
|
|
250
|
+
if subagents is not None:
|
|
251
|
+
subagent_specs = [
|
|
252
|
+
{"id": f"sub{index}", "task": text.strip()}
|
|
253
|
+
for index, text in enumerate(subagents, start=1)
|
|
254
|
+
if text and text.strip()
|
|
255
|
+
]
|
|
256
|
+
else:
|
|
257
|
+
subagent_specs = _config_subagent_tasks(project_config)
|
|
258
|
+
from .tools.subagents import SubTask
|
|
259
|
+
|
|
260
|
+
subagent_tasks = [
|
|
261
|
+
SubTask(id=spec["id"], task=spec["task"]) for spec in subagent_specs
|
|
262
|
+
]
|
|
263
|
+
todo_texts = [text.strip() for text in (todos or []) if text and text.strip()]
|
|
264
|
+
|
|
265
|
+
acceptance = criteria or ["Complete the task described in the goal."]
|
|
266
|
+
scope = out_of_scope or []
|
|
267
|
+
run_id = receipt_mod.new_run_id()
|
|
268
|
+
started_at = receipt_mod.utc_now()
|
|
269
|
+
|
|
270
|
+
result = asyncio.run(
|
|
271
|
+
run_loop(
|
|
272
|
+
client=client,
|
|
273
|
+
task=effective_task,
|
|
274
|
+
workspace_files=selection.files,
|
|
275
|
+
acceptance_criteria=acceptance,
|
|
276
|
+
out_of_scope=scope,
|
|
277
|
+
test_argv=test_argv or [],
|
|
278
|
+
workspace_root=selection.root,
|
|
279
|
+
model=model,
|
|
280
|
+
max_tokens=max_tokens,
|
|
281
|
+
reasoning_effort=reasoning_effort,
|
|
282
|
+
registry=registry,
|
|
283
|
+
bounds=bounds,
|
|
284
|
+
shell_policy=shell_policy,
|
|
285
|
+
plan_only=plan_active,
|
|
286
|
+
subagent_tasks=subagent_tasks,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
finished_at = receipt_mod.utc_now()
|
|
290
|
+
|
|
291
|
+
plan_meta: dict[str, Any] | None = None
|
|
292
|
+
if plan_active or todo_texts:
|
|
293
|
+
from .tools.plan import PlanError, PlanGate
|
|
294
|
+
|
|
295
|
+
gate = PlanGate(require_plan=plan_active)
|
|
296
|
+
for text in todo_texts:
|
|
297
|
+
gate.todos.add(text)
|
|
298
|
+
if plan_active:
|
|
299
|
+
steps = [
|
|
300
|
+
match.group(1)
|
|
301
|
+
for line in (result.get("plan") or "").splitlines()
|
|
302
|
+
if (match := _NUMBERED_STEP.match(line))
|
|
303
|
+
]
|
|
304
|
+
if steps:
|
|
305
|
+
try:
|
|
306
|
+
gate.set_plan(steps)
|
|
307
|
+
except PlanError:
|
|
308
|
+
pass
|
|
309
|
+
plan_meta = {
|
|
310
|
+
"mode": "plan" if plan_active else "write",
|
|
311
|
+
"source": plan_source or "flags",
|
|
312
|
+
"gate": gate.snapshot(),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# SUB-54: opt-in write-back to the real tree. Never a commit, never a push.
|
|
316
|
+
# Deletions are never silent: plan_apply separates writes from deletions and
|
|
317
|
+
# a deletion reaches disk only when --allow-delete approves it explicitly.
|
|
318
|
+
# SUB-57: the platform write policy gates it — inside the platform repo a
|
|
319
|
+
# source write from the MAIN checkout is refused.
|
|
320
|
+
apply_plan = workspace.plan_apply(
|
|
321
|
+
result.get("changed", []), result.get("readback", {})
|
|
322
|
+
)
|
|
323
|
+
wrote: list[str] = []
|
|
324
|
+
deleted: list[str] = []
|
|
325
|
+
pending_deletions: list[str] = []
|
|
326
|
+
policy_block: dict[str, Any] | None = None
|
|
327
|
+
if write:
|
|
328
|
+
from .policy import preflight_write
|
|
329
|
+
|
|
330
|
+
decision = preflight_write(workspace.root, list(result.get("changed", [])))
|
|
331
|
+
policy_block = decision.as_dict()
|
|
332
|
+
if decision.allowed:
|
|
333
|
+
approved_deletions = apply_plan["deletions"] if allow_delete else []
|
|
334
|
+
wrote = workspace.apply(
|
|
335
|
+
result.get("changed", []),
|
|
336
|
+
result.get("readback", {}),
|
|
337
|
+
approved_deletions=approved_deletions,
|
|
338
|
+
)
|
|
339
|
+
deleted = list(approved_deletions)
|
|
340
|
+
pending_deletions = [
|
|
341
|
+
rel for rel in apply_plan["deletions"] if rel not in approved_deletions
|
|
342
|
+
]
|
|
343
|
+
emit(f"wrote: {len(wrote)} file(s) to {workspace.root}")
|
|
344
|
+
if deleted:
|
|
345
|
+
emit(f"deleted (approved): {', '.join(deleted)}")
|
|
346
|
+
if pending_deletions:
|
|
347
|
+
emit(
|
|
348
|
+
"deletions awaiting explicit approval (--allow-delete): "
|
|
349
|
+
+ ", ".join(pending_deletions)
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
emit(f"write refused by repo policy: {decision.explanation}")
|
|
353
|
+
|
|
354
|
+
# SUB-80: the in-loop run executes the approved argv against the on-disk
|
|
355
|
+
# tree. When --write applied the model's edits, re-run it so the receipt's
|
|
356
|
+
# test block describes the tree the user now has, not the pre-write tree.
|
|
357
|
+
if wrote and test_argv:
|
|
358
|
+
fresh = execute_test_argv(test_argv, root=workspace.root)
|
|
359
|
+
result["test"] = fresh
|
|
360
|
+
result["tests"] = fresh
|
|
361
|
+
emit(f"tests re-run on the written tree: {fresh.get('detail')}")
|
|
362
|
+
|
|
363
|
+
# SUB-95: explicit opt-in git write path (branch -> commit -> push -> PR).
|
|
364
|
+
# The default stays read-only. Only files actually applied are committed;
|
|
365
|
+
# each step needs its own approval, and main/master is refused inside the
|
|
366
|
+
# module before any mutation. The receipt names the branch, commit and PR.
|
|
367
|
+
git_write_block: dict[str, Any] | None = None
|
|
368
|
+
if git_write:
|
|
369
|
+
from .git_write import (
|
|
370
|
+
GitWritePlan,
|
|
371
|
+
default_branch_name,
|
|
372
|
+
default_commit_message,
|
|
373
|
+
execute_git_write,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
planned_branch = git_branch or default_branch_name(run_id)
|
|
377
|
+
if not write:
|
|
378
|
+
git_write_block = {
|
|
379
|
+
"status": "refused",
|
|
380
|
+
"reason": "--git-write requires --write so there is an approved diff to commit",
|
|
381
|
+
"branch": planned_branch,
|
|
382
|
+
"commit": None,
|
|
383
|
+
"pr_url": None,
|
|
384
|
+
"pushed": False,
|
|
385
|
+
"steps": [],
|
|
386
|
+
"commands": [],
|
|
387
|
+
}
|
|
388
|
+
elif not wrote:
|
|
389
|
+
git_write_block = {
|
|
390
|
+
"status": "refused",
|
|
391
|
+
"reason": "no approved changes were applied to the working tree",
|
|
392
|
+
"branch": planned_branch,
|
|
393
|
+
"commit": None,
|
|
394
|
+
"pr_url": None,
|
|
395
|
+
"pushed": False,
|
|
396
|
+
"steps": [],
|
|
397
|
+
"commands": [],
|
|
398
|
+
}
|
|
399
|
+
else:
|
|
400
|
+
plan = GitWritePlan(
|
|
401
|
+
directory=workspace.root,
|
|
402
|
+
branch=planned_branch,
|
|
403
|
+
message=git_message or default_commit_message(task, run_id),
|
|
404
|
+
paths=tuple(wrote),
|
|
405
|
+
base_branch=git_base or "main",
|
|
406
|
+
open_pr=git_pr,
|
|
407
|
+
)
|
|
408
|
+
git_outcome = execute_git_write(
|
|
409
|
+
plan, approve=git_approve, gh_runner=gh_runner
|
|
410
|
+
)
|
|
411
|
+
git_write_block = git_outcome.as_dict()
|
|
412
|
+
emit(
|
|
413
|
+
f"git-write: {git_outcome.status} branch={git_outcome.branch} "
|
|
414
|
+
f"commit={git_outcome.commit} pr={git_outcome.pr_url}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
verify_block: dict[str, Any] | None = None
|
|
418
|
+
if verify and result.get("diff"):
|
|
419
|
+
try:
|
|
420
|
+
verify_block = client.verify_patch(
|
|
421
|
+
result["diff"],
|
|
422
|
+
model=model,
|
|
423
|
+
context="\n".join(acceptance),
|
|
424
|
+
)
|
|
425
|
+
except Exception as exc: # verify is advisory; never mask the run itself
|
|
426
|
+
verify_block = {"verdict": "unavailable", "error": str(exc)}
|
|
427
|
+
|
|
428
|
+
receipt = receipt_mod.build_receipt(
|
|
429
|
+
run_id=run_id,
|
|
430
|
+
task=effective_task,
|
|
431
|
+
directory=selection.root,
|
|
432
|
+
base_url=client.base_url,
|
|
433
|
+
routing_profile=client.routing_profile,
|
|
434
|
+
requested_model=model,
|
|
435
|
+
workspace={
|
|
436
|
+
"files": len(selection.files),
|
|
437
|
+
"bytes": selection.total_bytes,
|
|
438
|
+
"skipped": selection.skipped_report(),
|
|
439
|
+
},
|
|
440
|
+
result=result,
|
|
441
|
+
verify=verify_block,
|
|
442
|
+
started_at=started_at,
|
|
443
|
+
finished_at=finished_at,
|
|
444
|
+
git=workspace.git.as_dict(),
|
|
445
|
+
git_status=workspace.status(),
|
|
446
|
+
wrote=wrote,
|
|
447
|
+
deleted=deleted,
|
|
448
|
+
pending_deletions=pending_deletions,
|
|
449
|
+
context=context_meta,
|
|
450
|
+
tools={"extension_tools": registry.extension_names(), "all_tools": registry.names()}
|
|
451
|
+
if registry is not None
|
|
452
|
+
else None,
|
|
453
|
+
bounds=bounds.as_dict() if bounds is not None else None,
|
|
454
|
+
policy=policy_block,
|
|
455
|
+
diagnostics=diagnostics_meta,
|
|
456
|
+
git_write=git_write_block,
|
|
457
|
+
project_rules=project_rules,
|
|
458
|
+
skills=skills_meta,
|
|
459
|
+
subagents=result.get("subagents") or [],
|
|
460
|
+
plan=plan_meta,
|
|
461
|
+
)
|
|
462
|
+
target = (
|
|
463
|
+
Path(receipt_path)
|
|
464
|
+
if receipt_path
|
|
465
|
+
else receipt_mod.default_receipt_path(selection.root, run_id)
|
|
466
|
+
)
|
|
467
|
+
written = receipt_mod.write_receipt(target, receipt)
|
|
468
|
+
return RunOutcome(receipt=receipt, receipt_path=written)
|