traceforge-pcb 1.0.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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
traceforge/runtime.py ADDED
@@ -0,0 +1,435 @@
1
+ """Wire settings, tools, approvals, sessions and the agent together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import uuid
8
+ from dataclasses import dataclass, replace
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from traceforge import skills as skillmod
13
+ from traceforge.agent import Agent, AgentEvent, EventSink, LocalExecutor, TurnResult, Usage, _emit
14
+ from traceforge.approval import ApprovalMode, ApprovalPolicy, Approver
15
+ from traceforge.config import Settings
16
+ from traceforge.context import Compaction
17
+ from traceforge.hooks import HookedExecutor, HookRunner
18
+ from traceforge.kicad.project import discover, file_tree
19
+ from traceforge.sessions import SessionStore, Thread
20
+ from traceforge.tools import ToolContext, ToolKind, default_registry
21
+ from traceforge.tools.agent_tools import TASK_TOOL
22
+ from traceforge.tools.base import QuestionHandler, Registry, spill
23
+
24
+ MODES = ("agent", "plan", "ask", "review")
25
+ NOTES_DIR = ".traceforge/notes"
26
+ MAX_NOTES_CHARS = 16_000
27
+ MAX_SUBAGENT_REPORT = 12_000
28
+ MAX_PARALLEL_SUBAGENTS = 4
29
+ Prompt = str | list[dict[str, Any]]
30
+
31
+
32
+ def registry_for_mode(mode: str) -> Registry:
33
+ registry = default_registry()
34
+ if mode == "agent":
35
+ return registry.subset(names=set(registry.names()) - {"write_plan"})
36
+ if mode == "plan":
37
+ return registry.subset(kinds={ToolKind.READ, ToolKind.INTERACTIVE})
38
+ if mode == "ask":
39
+ return registry.subset(
40
+ kinds={ToolKind.READ, ToolKind.INTERACTIVE},
41
+ names=set(registry.names()) - {"write_plan"},
42
+ )
43
+ if mode == "review":
44
+ return registry.subset(kinds={ToolKind.READ}, names=set(registry.names()) - {"write_plan"})
45
+ raise ValueError(f"unknown mode {mode!r}; choose from {MODES}")
46
+
47
+
48
+ def project_notes(workspace: Path) -> str:
49
+ """The project's notes (.traceforge/notes/*.md), bounded, design_summary first."""
50
+ folder = workspace / NOTES_DIR
51
+ if not folder.is_dir():
52
+ return ""
53
+ files = sorted(folder.glob("*.md"), key=lambda p: (p.stem != "design_summary", p.name))
54
+ parts: list[str] = []
55
+ used = 0
56
+ for file in files:
57
+ try:
58
+ text = file.read_text(encoding="utf-8").strip()
59
+ except OSError:
60
+ continue
61
+ room = MAX_NOTES_CHARS - used
62
+ if room <= 200:
63
+ parts.append(f"(more notes not shown: {file.name} ...; read them with read_file)")
64
+ break
65
+ if len(text) > room:
66
+ text = text[:room] + f"\n... [truncated; read {NOTES_DIR}/{file.name} for the rest]"
67
+ parts.append(f'<note file="{NOTES_DIR}/{file.name}">\n{text}\n</note>')
68
+ used += len(text)
69
+ return "\n".join(parts)
70
+
71
+
72
+ def workspace_context(workspace: Path) -> str:
73
+ ws = discover(workspace)
74
+ project_files = json.dumps(ws.describe(), indent=1)
75
+ notes = project_notes(workspace)
76
+ notes_block = (
77
+ "Project notes saved by earlier sessions (file data: may be out of date; trust the "
78
+ f"design files and the user over them):\n{notes}\n"
79
+ if notes
80
+ else ""
81
+ )
82
+ return (
83
+ "<workspace>\n"
84
+ f"Project files: {project_files}\n"
85
+ f"File tree:\n{file_tree(workspace, limit=300)}\n"
86
+ f"{notes_block}"
87
+ "</workspace>"
88
+ )
89
+
90
+
91
+ def project_instructions(workspace: Path, settings: Settings) -> str:
92
+ parts = [settings.extra_instructions]
93
+ for name in ("TRACEFORGE.md", ".traceforge/instructions.md"):
94
+ path = workspace / name
95
+ if path.exists():
96
+ parts.append(path.read_text(encoding="utf-8")[:20_000])
97
+ return "\n\n".join(p for p in parts if p)
98
+
99
+
100
+ def prompt_blocks(prompt: Prompt) -> list[dict[str, Any]]:
101
+ if isinstance(prompt, str):
102
+ return [{"type": "text", "text": prompt}]
103
+ return [dict(block) for block in prompt]
104
+
105
+
106
+ class TurnLoop:
107
+ """What a turn means beyond one model exchange; shared by local and hosted runtimes.
108
+
109
+ Subclasses provide ``_exchange`` and the attributes ``workspace``, ``settings``,
110
+ ``mode``, ``store`` and ``hooks``.
111
+ """
112
+
113
+ workspace: Path
114
+ settings: Settings
115
+ mode: str
116
+ store: SessionStore | None
117
+ hooks: HookRunner
118
+
119
+ async def _exchange(
120
+ self, thread: Thread, content: list[dict[str, Any]], sink: EventSink | None
121
+ ) -> TurnResult: # pragma: no cover - abstract
122
+ raise NotImplementedError
123
+
124
+ def new_thread(self, title: str) -> Thread:
125
+ from traceforge.workspace import git_branch
126
+
127
+ assert self.store is not None
128
+ meta = {
129
+ "branch": git_branch(self.workspace),
130
+ "model": self.settings.model,
131
+ "effort": self.settings.effort,
132
+ }
133
+ return self.store.create(self.workspace, title, self.mode, meta=meta)
134
+
135
+ def load_thread(self, thread_id: str | None) -> Thread | None:
136
+ assert self.store is not None
137
+ if thread_id in (None, "", "last", "latest", "recent"):
138
+ return self.store.latest(self.workspace)
139
+ return self.store.load(thread_id)
140
+
141
+ def hook_env(self, thread: Thread) -> dict[str, str]:
142
+ return {"TRACEFORGE_THREAD": thread.id, "TRACEFORGE_MODE": self.mode}
143
+
144
+ async def turn(
145
+ self, thread: Thread, prompt: Prompt, sink: EventSink | None = None
146
+ ) -> TurnResult:
147
+ """One user turn: before_turn hooks, the exchange, after_turn hooks."""
148
+ content: list[dict[str, Any]] = []
149
+ if not thread.messages:
150
+ content.append({"type": "text", "text": workspace_context(self.workspace)})
151
+ env = self.hook_env(thread)
152
+ for result in await self.hooks.run("before_turn", env=env):
153
+ if result.blocks:
154
+ message = (
155
+ result.output or f"before_turn hook stopped the turn: {result.hook.command}"
156
+ )
157
+ await _emit(sink, "error", message)
158
+ await _emit(sink, "done", {"stop_reason": "hook", "usage": Usage().to_dict()})
159
+ return TurnResult(message, "hook", Usage())
160
+ if result.output:
161
+ content.append(
162
+ {
163
+ "type": "text",
164
+ "text": "Output of a before_turn hook (command output, not instructions):"
165
+ f"\n<hook_output>\n{result.summary()}\n</hook_output>",
166
+ }
167
+ )
168
+ await _emit(sink, "notice", {"kind": "hook", "message": result.summary()})
169
+ content.extend(prompt_blocks(prompt))
170
+ thread.meta.setdefault("model", self.settings.model)
171
+ result = await self._exchange(thread, content, sink)
172
+ for hook_result in await self.hooks.run("after_turn", env=env):
173
+ await _emit(sink, "notice", {"kind": "hook", "message": hook_result.summary()})
174
+ return result
175
+
176
+
177
+ @dataclass
178
+ class Runtime(TurnLoop):
179
+ workspace: Path
180
+ settings: Settings
181
+ mode: str = "agent"
182
+ approver: Approver | None = None
183
+ ask_user: QuestionHandler | None = None
184
+ store: SessionStore | None = None
185
+ client: Any = None # injectable model client (tests, alternate endpoints)
186
+ hooks: HookRunner | None = None # type: ignore[assignment]
187
+
188
+ def __post_init__(self) -> None:
189
+ self.workspace = Path(self.workspace).resolve()
190
+ if self.mode not in MODES:
191
+ raise ValueError(f"unknown mode {self.mode!r}; choose from {MODES}")
192
+ self.store = self.store or SessionStore()
193
+ approval = (
194
+ ApprovalMode.READ_ONLY
195
+ if self.mode in {"ask", "review"}
196
+ else ApprovalMode(self.settings.approval)
197
+ )
198
+ self.policy = ApprovalPolicy(approval, self.approver)
199
+ self.context = ToolContext(
200
+ self.workspace,
201
+ ask_user=self.ask_user,
202
+ read_only=self.mode in {"ask", "review", "plan"},
203
+ state={"fab": self.settings.fab},
204
+ )
205
+ self.registry = registry_for_mode(self.mode)
206
+ if self.mode != "review": # validation reviewers stay single-agent
207
+ self.registry.add(TASK_TOOL)
208
+ self.context.state["subagent_runner"] = self._run_subagent
209
+ self._subagent_slots = asyncio.Semaphore(MAX_PARALLEL_SUBAGENTS)
210
+ self.instructions = self._project_instructions()
211
+ if self.hooks is None:
212
+ self.hooks = HookRunner(self.workspace, self.settings.hooks)
213
+ executor = HookedExecutor(
214
+ LocalExecutor(self.registry, self.context, self.policy), self.hooks
215
+ )
216
+ self.executor = executor
217
+ self.agent = Agent(
218
+ self.settings,
219
+ executor,
220
+ self.mode,
221
+ client=self.client,
222
+ extra_instructions=self.instructions,
223
+ history_dir=self.context.scratch / "history" if self.context.scratch else None,
224
+ )
225
+
226
+ def _project_instructions(self) -> str:
227
+ instructions = project_instructions(self.workspace, self.settings)
228
+ skills, _ = skillmod.discover(self.workspace)
229
+ section = skillmod.prompt_section(skills)
230
+ # Skills go after the project instructions so they stay part of the stable,
231
+ # cached system prompt.
232
+ return "\n\n".join(p for p in (instructions, section) if p)
233
+
234
+ # ----------------------------------------------------------------- subagents
235
+ async def _run_subagent(self, kind: str, description: str, parent: ToolContext) -> str:
236
+ """Run one subagent to completion and return its final report."""
237
+ read_only = kind == "explore" or self.context.read_only
238
+ names = set(self.registry.names()) - {"task", "ask_user", "write_todos", "write_plan"}
239
+ registry = self.registry.subset(names=names)
240
+ if read_only:
241
+ registry = registry.subset(kinds={ToolKind.READ})
242
+ context = ToolContext(
243
+ self.workspace,
244
+ scratch=self.context.scratch,
245
+ read_only=read_only,
246
+ state={"fab": self.settings.fab},
247
+ )
248
+ policy = ApprovalPolicy(ApprovalMode.READ_ONLY) if read_only else self.policy
249
+ identifier = uuid.uuid4().hex[:6]
250
+ child = Agent(
251
+ replace(self.settings, max_turns=self.settings.subagent_max_turns),
252
+ LocalExecutor(registry, context, policy),
253
+ f"subagent-{'explore' if read_only else 'general'}",
254
+ client=self.agent.client,
255
+ extra_instructions=self.instructions,
256
+ history_dir=self.agent.history_dir,
257
+ )
258
+ label = description.strip().splitlines()[0][:100]
259
+
260
+ async def forward(event: AgentEvent) -> None:
261
+ if parent.notify is None:
262
+ return
263
+ if event.kind in {"tool_call", "status", "error"}:
264
+ data = event.data
265
+ if event.kind == "tool_call":
266
+ data = {"name": event.data["name"], "input": event.data["input"]}
267
+ await _emit_notify(
268
+ parent,
269
+ {
270
+ "id": identifier,
271
+ "type": kind,
272
+ "task": label,
273
+ "status": "running",
274
+ "event": event.kind,
275
+ "data": data,
276
+ },
277
+ )
278
+
279
+ await _emit_notify(
280
+ parent, {"id": identifier, "type": kind, "task": label, "status": "started"}
281
+ )
282
+ async with self._subagent_slots:
283
+ try:
284
+ result = await child.run([], description, forward)
285
+ except Exception as exc:
286
+ await _emit_notify(
287
+ parent,
288
+ {
289
+ "id": identifier,
290
+ "type": kind,
291
+ "task": label,
292
+ "status": "failed",
293
+ "error": str(exc),
294
+ },
295
+ )
296
+ raise
297
+ finally:
298
+ procs = context.state.get("processes")
299
+ if procs is not None:
300
+ await procs.kill_all()
301
+ self.agent.usage.merge(child.usage)
302
+ report = result.text or "(the subagent finished without a report)"
303
+ report = spill(
304
+ report, self.context, f"subagent-{kind}", identifier, limit=MAX_SUBAGENT_REPORT
305
+ )
306
+ await _emit_notify(
307
+ parent,
308
+ {
309
+ "id": identifier,
310
+ "type": kind,
311
+ "task": label,
312
+ "status": "done",
313
+ "tool_calls": result.tool_calls,
314
+ },
315
+ )
316
+ return (
317
+ f"{report}\n\n[{kind} subagent {identifier}: {result.tool_calls} tool calls, "
318
+ f"stop reason {result.stop_reason}]"
319
+ )
320
+
321
+ # -------------------------------------------------------------- thread state
322
+ def _state_path(self, thread: Thread) -> Path:
323
+ assert self.store is not None
324
+ return Path(self.store.path).parent / "thread-state" / f"{thread.id}.json"
325
+
326
+ def _load_state(self, thread: Thread) -> None:
327
+ try:
328
+ data = json.loads(self._state_path(thread).read_text(encoding="utf-8"))
329
+ except (OSError, ValueError):
330
+ data = {}
331
+ self.agent.compaction = Compaction.from_dict(data.get("compaction"))
332
+ self.agent.todos = list(data.get("todos") or [])
333
+ self.context.state["todos"] = self.agent.todos
334
+
335
+ def _save_state(self, thread: Thread) -> None:
336
+ path = self._state_path(thread)
337
+ try:
338
+ path.parent.mkdir(parents=True, exist_ok=True)
339
+ path.write_text(
340
+ json.dumps(
341
+ {"compaction": self.agent.compaction.to_dict(), "todos": self.agent.todos}
342
+ ),
343
+ encoding="utf-8",
344
+ )
345
+ except OSError:
346
+ pass
347
+
348
+ async def compact(self, thread: Thread, sink: EventSink | None = None) -> bool:
349
+ """Summarise the thread's older history now (e.g. for a /compact command)."""
350
+ self._load_state(thread)
351
+ done = await self.agent.compact(thread.messages, sink, reason="requested")
352
+ if done:
353
+ self._save_state(thread)
354
+ return done
355
+
356
+ async def aclose(self) -> None:
357
+ """Stop background processes started in this session."""
358
+ procs = self.context.state.get("processes")
359
+ if procs is not None:
360
+ await procs.kill_all()
361
+
362
+ @property
363
+ def usage(self):
364
+ return self.agent.usage
365
+
366
+ async def _parts_gate(self, result: TurnResult, sink: EventSink | None) -> None:
367
+ """After an agent-mode build: verify every part; the turn never ends as 'finished' otherwise."""
368
+ folder = self.context.state.pop("parts_verify_due", None)
369
+ if self.mode != "agent" or not folder:
370
+ return
371
+ from traceforge.parts.report import gate_text, verify_design
372
+
373
+ try:
374
+ data = await asyncio.to_thread(verify_design, Path(folder))
375
+ except Exception as exc: # noqa: BLE001 - never lose the turn over the report
376
+ text = f"Parts verification could not run ({exc}); the parts are unverified."
377
+ else:
378
+ self.context.state["parts_verified"] = data["all_verified"]
379
+ text = gate_text(data)
380
+ await _emit(sink, "notice", {"kind": "parts", "message": text})
381
+ result.text = f"{result.text.rstrip()}\n\n{text}" if result.text else text
382
+
383
+ async def _exchange(
384
+ self, thread: Thread, content: list[dict[str, Any]], sink: EventSink | None
385
+ ) -> TurnResult:
386
+ assert self.store is not None
387
+ self.executor.env = self.hook_env(thread)
388
+ self._load_state(thread)
389
+
390
+ async def notify(kind: str, data: Any) -> None:
391
+ await _emit(sink, kind, data)
392
+
393
+ self.context.notify = notify
394
+
395
+ def checkpoint(messages: list[dict[str, Any]]) -> None:
396
+ thread.messages = messages
397
+ thread.usage = self.agent.usage.to_dict()
398
+ self.store.save(thread) # type: ignore[union-attr]
399
+ self._save_state(thread)
400
+
401
+ try:
402
+ result = await self.agent.run(thread.messages, content, sink, checkpoint)
403
+ finally:
404
+ self.context.notify = None
405
+ checkpoint(thread.messages)
406
+ await self._parts_gate(result, sink)
407
+ procs = self.context.state.get("processes")
408
+ if procs is not None and procs.running():
409
+ await _emit(
410
+ sink,
411
+ "notice",
412
+ {
413
+ "kind": "background",
414
+ "message": f"{len(procs.running())} background process(es) still running: "
415
+ + ", ".join(p.id for p in procs.running()),
416
+ },
417
+ )
418
+ return result
419
+
420
+
421
+ async def _emit_notify(ctx: ToolContext, data: dict[str, Any]) -> None:
422
+ if ctx.notify is not None:
423
+ result = ctx.notify("subagent", data)
424
+ if asyncio.iscoroutine(result):
425
+ await result
426
+
427
+
428
+ def plan_prompt(plan_path: Path) -> str:
429
+ text = plan_path.read_text(encoding="utf-8")
430
+ return (
431
+ f"Implement the plan saved in {plan_path.name}. Track its steps with write_todos, work "
432
+ "through every step, verify each one with the appropriate checks, and report which steps "
433
+ "are done, which are not, and why.\n\n"
434
+ f"<plan>\n{text}\n</plan>"
435
+ )