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
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)