chipgraph 0.1.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 (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,787 @@
1
+ """The MCP server itself (task M0-14): `build_server` wires up an `mcp` 2.x `MCPServer`
2
+ with the five M0 tools, and `run_stdio` runs it over stdio.
3
+
4
+ The `init` tool (M1-16) is meant for a project with no `.chipgraph.yml` yet: it previews,
5
+ and with `confirm`, writes the profile `chipgraph init` would write.
6
+
7
+ Each tool call loads a fresh `chipgraph.app.context.AppContext` from `start` (and
8
+ `profile_path`, if given): a stdio session is long-lived, so a project's
9
+ `.chipgraph.yml` may change between calls, and every tool must see the current one
10
+ (no caching across calls). All the actual work is delegated to the same
11
+ `chipgraph.app` functions `chipgraph.cli` uses, so behavior never drifts between the
12
+ CLI and the MCP surface.
13
+
14
+ `AppError`/`ConfigError` (and `chipgraph.core.engine.gate.GateError`) are caught and
15
+ turned into `mcp.server.mcpserver.exceptions.ToolError`, which the SDK reports back to
16
+ the caller as a tool result with `isError=True` and the message as text -- never an
17
+ unhandled exception that would crash the server loop.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import functools
24
+ import json
25
+ from collections import Counter
26
+ from collections.abc import Awaitable, Callable
27
+ from pathlib import Path
28
+ from typing import Any, Literal
29
+
30
+ from mcp.server.mcpserver import MCPServer
31
+ from mcp.server.mcpserver.exceptions import ToolError
32
+
33
+ from chipgraph import __version__
34
+ from chipgraph.adapters.runtime.claude_code import decisions as cc_decisions
35
+ from chipgraph.adapters.runtime.claude_code import review as cc_review
36
+ from chipgraph.adapters.runtime.claude_code import service as cc_service
37
+ from chipgraph.app.build import make_scheduler, project_graph
38
+ from chipgraph.app.checks import ProfileCheckRunner
39
+ from chipgraph.app.context import AppContext, default_identity, find_repo_root
40
+ from chipgraph.app.errors import AppError
41
+ from chipgraph.core.config.errors import ConfigError
42
+ from chipgraph.core.contracts import ArtifactRef, RuleInstance
43
+ from chipgraph.core.engine import gate as gate_mod
44
+ from chipgraph.core.engine.graph import BuildGraph, GraphError
45
+ from chipgraph.core.runtime import TaskQueue
46
+ from chipgraph.core.runtime.roles import find_role
47
+ from chipgraph.core.state import journal as journal_mod
48
+ from chipgraph.core.state.layout import StateLayout
49
+ from chipgraph.mcp import model_tools
50
+
51
+ __all__ = ["build_server", "run_stdio"]
52
+
53
+
54
+ def _latest_run_id(layout: StateLayout) -> str | None:
55
+ """The id of the most recently created run, or `None` if there has never been one.
56
+
57
+ A minimal re-implementation of `chipgraph.cli._latest_run_id` (private to the CLI
58
+ module): both list `.chipgraph/state/runs` and take the lexicographically last id
59
+ (run ids are time-sortable, see `chipgraph.core.state.layout.new_run_id`).
60
+ """
61
+ if not layout.runs_dir.is_dir():
62
+ return None
63
+ ids = sorted(p.name for p in layout.runs_dir.iterdir() if p.is_dir())
64
+ return ids[-1] if ids else None
65
+
66
+
67
+ def _fake_check_instance(check_id: str, block: str | None) -> RuleInstance:
68
+ """A throwaway `RuleInstance` carrying just the params `ProfileCheckRunner` reads.
69
+
70
+ A minimal re-implementation of `chipgraph.cli._fake_instance` (private to the CLI
71
+ module): `chipgraph check`/the `check` tool run checks directly, outside the build
72
+ graph, so there is no real rule instance to hand the runner.
73
+ """
74
+ params = {"block": block} if block is not None else {}
75
+ rule_id = "chipgraph/check"
76
+ safe = "".join(c if c.isalnum() else "_" for c in check_id)
77
+ output_path = f".chipgraph/tmp/check-{safe}-{block or 'all'}.json"
78
+ return RuleInstance(
79
+ rule_id=rule_id,
80
+ params=params,
81
+ outputs=(ArtifactRef(kind="report", path=output_path),),
82
+ instance_id=RuleInstance.make_id(rule_id, params),
83
+ )
84
+
85
+
86
+ def _gated_instance(graph: BuildGraph, gate_id: str) -> str:
87
+ """The one instance whose rule's gate is `gate_id` (a re-implementation of
88
+ `chipgraph.cli._gated_instance`); a `ToolError` naming the gates when not exactly one."""
89
+ found: list[str] = []
90
+ known: set[str] = set()
91
+ for iid, inst in graph.instances.items():
92
+ rule = graph.rules[inst.rule_id]
93
+ if not rule.gate:
94
+ continue
95
+ try:
96
+ formatted = gate_mod.gate_id_for(rule.gate, inst.params)
97
+ except gate_mod.GateError:
98
+ formatted = rule.gate
99
+ known.add(formatted)
100
+ if formatted == gate_id:
101
+ found.append(iid)
102
+ if len(found) != 1:
103
+ what = "no instance has" if not found else f"{len(found)} instances have"
104
+ raise ToolError(
105
+ f"{what} gate {gate_id!r}: pass instance; gates: {', '.join(sorted(known)) or 'none'}"
106
+ )
107
+ return found[0]
108
+
109
+
110
+ _SERIOUS = ("blocker", "major")
111
+ """Review comment severities `status` lists one by one (the others are counted)."""
112
+
113
+
114
+ def _read_json(path: Path) -> dict[str, Any] | None:
115
+ try:
116
+ data = json.loads(path.read_text(encoding="utf-8"))
117
+ except (OSError, ValueError):
118
+ return None
119
+ return data if isinstance(data, dict) else None
120
+
121
+
122
+ def _last_checks(ctx: AppContext, run_id: str | None, task_id: str) -> list[dict[str, Any]]:
123
+ """The check results of a task's last submit: the `check_result` events after its
124
+ last dispatch in the journal of the run that dispatched it."""
125
+ if run_id is None or not ctx.layout.journal(run_id).is_file():
126
+ return []
127
+ checks: list[dict[str, Any]] = []
128
+ for event in journal_mod.read(ctx.layout.journal(run_id)).events:
129
+ if event.rule_instance != task_id:
130
+ continue
131
+ if event.type == "agent_turn" and event.payload.get("phase") == "dispatch":
132
+ checks = []
133
+ elif event.type == "check_result":
134
+ checks.append(
135
+ {
136
+ "check_id": event.payload.get("check_id"),
137
+ "status": event.payload.get("status"),
138
+ }
139
+ )
140
+ return checks
141
+
142
+
143
+ def _block_report(
144
+ ctx: AppContext, graph: BuildGraph, selected: set[str], block: str
145
+ ) -> dict[str, Any]:
146
+ """What a person reads of one block of a named target's run: each agent task (status,
147
+ files, checks of its last submit), the Critic's review (verdict, summary, the blocker
148
+ and major comments) and the pull request record (branch, head, URL)."""
149
+ queue = TaskQueue(ctx.layout)
150
+ report: dict[str, Any] = {"tasks": [], "review": None, "pr": None}
151
+ for iid in graph.topo_order():
152
+ if iid not in selected:
153
+ continue
154
+ instance = graph.instances[iid]
155
+ if instance.params.get("block") != block:
156
+ continue
157
+ rule = graph.rules[instance.rule_id]
158
+ out = instance.outputs[0].path if instance.outputs else None
159
+ if rule.kind == "agent":
160
+ record = queue.get(iid)
161
+ if record is not None:
162
+ files = (
163
+ sorted(record.accepted_hashes)
164
+ if record.status == "accepted"
165
+ else list(record.last_result.files_written if record.last_result else ())
166
+ )
167
+ report["tasks"].append(
168
+ {
169
+ "instance": iid,
170
+ "role": record.role,
171
+ "status": record.status,
172
+ "tries_used": record.state.tries_used,
173
+ "tries": record.tries,
174
+ "files": files,
175
+ "checks": _last_checks(ctx, record.run_id, iid),
176
+ }
177
+ )
178
+ if out is not None and cc_review.is_review_role(find_role(rule.role or "")):
179
+ data = _read_json(ctx.root / out)
180
+ if data is not None:
181
+ comments = [c for c in data.get("comments", []) if isinstance(c, dict)]
182
+ report["review"] = {
183
+ "path": out,
184
+ "verdict": data.get("verdict"),
185
+ "summary": data.get("summary"),
186
+ "comments": [
187
+ {
188
+ k: c.get(k)
189
+ for k in ("severity", "file", "line", "claim", "req_id")
190
+ if c.get(k) is not None
191
+ }
192
+ for c in comments
193
+ if c.get("severity") in _SERIOUS
194
+ ],
195
+ "other_comments": sum(c.get("severity") not in _SERIOUS for c in comments),
196
+ }
197
+ elif rule.run is not None and rule.run.use == "open_pr" and out is not None:
198
+ data = _read_json(ctx.root / out)
199
+ if data is not None:
200
+ report["pr"] = {
201
+ k: data.get(k)
202
+ for k in ("gate", "review", "branch", "head_sha", "number", "url", "files")
203
+ }
204
+ return report
205
+
206
+
207
+ def _chain_status(ctx: AppContext, rid: str, states: dict[str, str]) -> dict[str, Any]:
208
+ """The run's named target (`rtl:<block>`) in build order with each instance's state,
209
+ and a `_block_report` per block of it. Empty when the run's target is not a named
210
+ target or the graph cannot be built now (the same rows as `chipgraph status`)."""
211
+ empty: dict[str, Any] = {"target": None, "chain": [], "blocks": {}}
212
+ if ctx.resolved is None:
213
+ return empty
214
+ try:
215
+ target = journal_mod.read_manifest(ctx.layout, rid).target
216
+ empty["target"] = target
217
+ graph = project_graph(ctx)
218
+ if graph.targets is None or not graph.targets.is_target(target):
219
+ return empty
220
+ selected = graph.select(target)
221
+ except (AppError, GraphError, OSError, ValueError):
222
+ return empty
223
+ chain = [
224
+ {"instance": iid, "state": states.get(iid, "not run")}
225
+ for iid in graph.topo_order()
226
+ if iid in selected
227
+ ]
228
+ names = sorted({b for iid in selected if (b := graph.instances[iid].params.get("block"))})
229
+ blocks = {name: _block_report(ctx, graph, selected, name) for name in names}
230
+ return {"target": target, "chain": chain, "blocks": blocks}
231
+
232
+
233
+ def _json_safe(value: object) -> object:
234
+ """Make a `ResolvedProfile.explain()` leaf value JSON-serialisable (tuples -> lists)."""
235
+ if isinstance(value, tuple | list):
236
+ return [_json_safe(item) for item in value]
237
+ if isinstance(value, dict):
238
+ return {str(k): _json_safe(v) for k, v in value.items()}
239
+ return value
240
+
241
+
242
+ def _guard[F: Callable[..., Awaitable[dict[str, Any]]]](func: F) -> F:
243
+ """Turn `AppError`/`ConfigError`/`GateError` into an MCP `ToolError`.
244
+
245
+ Anything else propagates and the SDK reports it as an unexpected tool error
246
+ (still a tool result, never a crash of the server loop -- see `_handle_call_tool`
247
+ in `mcp.server.mcpserver.server`).
248
+ """
249
+
250
+ @functools.wraps(func)
251
+ async def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
252
+ try:
253
+ return await func(*args, **kwargs)
254
+ except (AppError, ConfigError, gate_mod.GateError) as exc:
255
+ raise ToolError(str(exc)) from exc
256
+
257
+ return wrapper # type: ignore[return-value]
258
+
259
+
260
+ def build_server(start: Path, *, profile_path: Path | None = None) -> MCPServer:
261
+ """Build the MCP server for the project rooted at (or above) `start`.
262
+
263
+ Registers `status`, `build`, `check`, `approve`, `plan_show`, `config_show` and the
264
+ rest below. `start` and
265
+ `profile_path` are the server's fixed project start directory / profile override
266
+ (mirroring the CLI's `-C/--dir` and `--profile`); everything else about the
267
+ project (the profile, the graph, the state) is re-read from disk on every call.
268
+ """
269
+ server: MCPServer = MCPServer(name="chipgraph", version=__version__)
270
+
271
+ def _load_ctx() -> AppContext:
272
+ return AppContext.load(start, profile_path=profile_path)
273
+
274
+ @server.tool(
275
+ description=(
276
+ "Show the status of the latest run, or a given run id. For a named target "
277
+ "(rtl:<block>, plan:<block>) also its chain in build order and, per block, the "
278
+ "agent tasks (status, files, checks of the last submit), the Critic's verdict "
279
+ "with its blocker and major comments, and the pull request (branch, URL)."
280
+ )
281
+ )
282
+ @_guard
283
+ async def status(run_id: str | None = None) -> dict[str, Any]:
284
+ ctx = _load_ctx()
285
+ layout = ctx.layout
286
+ rid = run_id or _latest_run_id(layout)
287
+ if rid is None:
288
+ return {"runs": []}
289
+
290
+ journal_path = layout.journal(rid)
291
+ if not journal_path.is_file():
292
+ raise ToolError(f"no such run {rid!r}")
293
+ journal_read = journal_mod.read(journal_path)
294
+ run_state = journal_mod.replay(journal_read.events)
295
+
296
+ gate_of: dict[str, str] = {}
297
+ for event in journal_read.events:
298
+ if event.type == "gate_wait" and event.rule_instance is not None:
299
+ gate_of[event.rule_instance] = str(event.payload.get("gate", ""))
300
+ waiting = {
301
+ iid: gate_of.get(iid, "") for iid, st in run_state.rules.items() if st == "waiting_gate"
302
+ }
303
+ counts = Counter(run_state.rules.values())
304
+
305
+ return {
306
+ "run_id": rid,
307
+ "started": run_state.started,
308
+ "stopped": run_state.stopped,
309
+ "rules": dict(run_state.rules),
310
+ "counts": dict(counts),
311
+ "waiting_gates": waiting,
312
+ **_chain_status(ctx, rid, dict(run_state.rules)),
313
+ }
314
+
315
+ @server.tool(description="Build a target with the scheduler and return the run summary.")
316
+ @_guard
317
+ async def build(target: str, concurrency: int = 4) -> dict[str, Any]:
318
+ ctx = _load_ctx()
319
+ ctx.require_profile()
320
+ scheduler = make_scheduler(ctx, target, concurrency=concurrency)
321
+ summary = await scheduler.run(target)
322
+ return summary.model_dump(mode="json")
323
+
324
+ @server.tool(
325
+ description="Run checks directly against the working tree, without the build graph."
326
+ )
327
+ @_guard
328
+ async def check(
329
+ blocks: list[str] | None = None, only: list[str] | None = None
330
+ ) -> dict[str, Any]:
331
+ ctx = _load_ctx()
332
+ resolved = ctx.require_profile()
333
+
334
+ check_ids = list(only) if only else sorted(resolved.profile.adapters)
335
+ raw_blocks = list(blocks) if blocks else sorted(resolved.profile.blocks)
336
+ block_list: list[str | None] = list(raw_blocks) if raw_blocks else [None]
337
+
338
+ runner = ProfileCheckRunner(ctx)
339
+ results = []
340
+ for check_id in check_ids:
341
+ for block in block_list:
342
+ instance = _fake_check_instance(check_id, block)
343
+ results.append(await runner.run(check_id, instance))
344
+
345
+ return {
346
+ "ok": all(result.ok for result in results),
347
+ "results": [result.model_dump(mode="json") for result in results],
348
+ }
349
+
350
+ @server.tool(
351
+ description="Run every deterministic check over the whole project, grouped by layer."
352
+ )
353
+ @_guard
354
+ async def audit(blocks: list[str] | None = None, ingest: bool = True) -> dict[str, Any]:
355
+ from chipgraph.packs.assist.audit import run_audit
356
+
357
+ ctx = _load_ctx()
358
+ ctx.require_profile()
359
+ block_list = list(blocks) if blocks else None
360
+ # `run_audit` is synchronous and drives its own event loop; run it in a worker
361
+ # thread so it does not clash with the MCP server's running loop.
362
+ report = await asyncio.to_thread(run_audit, ctx, ingest=ingest, blocks=block_list)
363
+ return report.model_dump(mode="json")
364
+
365
+ @server.tool(
366
+ description=(
367
+ "Record a person's approve or reject decision for a gate over a rule instance "
368
+ "(instance: optional when one instance has the gate). Only on a person's "
369
+ "explicit answer. `by` defaults to the local user (git user.name); a reject "
370
+ "with a note sends the note to the agent task whose output the gate covers."
371
+ )
372
+ )
373
+ @_guard
374
+ async def approve(
375
+ gate_id: str,
376
+ instance: str | None = None,
377
+ reject: bool = False,
378
+ by: str | None = None,
379
+ note: str | None = None,
380
+ ) -> dict[str, Any]:
381
+ ctx = _load_ctx()
382
+ ctx.require_profile()
383
+ scheduler = make_scheduler(ctx, "*")
384
+ if instance is None:
385
+ instance = _gated_instance(scheduler.graph, gate_id)
386
+ rule_instance = scheduler.graph.instances.get(instance)
387
+ if rule_instance is None:
388
+ raise ToolError(
389
+ f"no such rule instance {instance!r}; known instances: "
390
+ f"{sorted(scheduler.graph.instances)}"
391
+ )
392
+ decision: Literal["approve", "reject"] = "reject" if reject else "approve"
393
+ who = by or default_identity()
394
+ approval = gate_mod.approve(
395
+ ctx.review,
396
+ ctx.store,
397
+ gate_id,
398
+ rule_instance,
399
+ by=who,
400
+ decision=decision,
401
+ note=note or "",
402
+ )
403
+ return approval.model_dump(mode="json")
404
+
405
+ @server.tool(
406
+ description=(
407
+ "A block's plan, for the person who approves gate plan:<block> (the data of "
408
+ "`chipgraph plan show`): gate status and approve command, plan_check errors "
409
+ "and warnings, modules in build order (summary, reqs, dependencies, write "
410
+ "sets, ports), REQ coverage, limits, assumptions, open questions. Read-only."
411
+ )
412
+ )
413
+ @_guard
414
+ async def plan_show(block: str) -> dict[str, Any]:
415
+ from chipgraph.packs.digital_rtl.plan.show import plan_summary
416
+
417
+ return plan_summary(_load_ctx(), block)
418
+
419
+ @server.tool(description="Show the merged project profile, optionally with each key's source.")
420
+ @_guard
421
+ async def config_show(explain: bool = False) -> dict[str, Any]:
422
+ ctx = _load_ctx()
423
+ resolved = ctx.require_profile()
424
+ if explain:
425
+ return {
426
+ "explain": [
427
+ {
428
+ "key": key,
429
+ "value": _json_safe(value),
430
+ "source": {"kind": source.kind, "location": source.location},
431
+ }
432
+ for key, value, source in resolved.explain()
433
+ ]
434
+ }
435
+ return {"profile": resolved.profile.model_dump(mode="json")}
436
+
437
+ # --- runtime claude-code (M1-11): the task loop the plugin command drives ----------
438
+ # One lock for the three tools: the stdio session may run calls concurrently, and
439
+ # each reads and writes the same task queue.
440
+ runtime_lock = asyncio.Lock()
441
+
442
+ @server.tool(
443
+ description=(
444
+ "Run the build and hand out the agent tasks that are ready: tasks (start one "
445
+ "role subagent per task, in parallel), in_progress, and done or waiting."
446
+ )
447
+ )
448
+ @_guard
449
+ async def next_task(target: str = "*") -> dict[str, Any]:
450
+ async with runtime_lock:
451
+ return await cc_service.next_task(_load_ctx(), target)
452
+
453
+ @server.tool(
454
+ description=(
455
+ "The context of one dispatched task, for its role subagent: inputs as text, "
456
+ "outputs (the only files it may write), role, skills, checks."
457
+ )
458
+ )
459
+ @_guard
460
+ async def get_context(task_id: str) -> dict[str, Any]:
461
+ async with runtime_lock:
462
+ return await cc_service.get_context(_load_ctx(), task_id)
463
+
464
+ @server.tool(
465
+ description=(
466
+ "Hand a task back: the engine checks that only its outputs changed, that they "
467
+ "exist, and runs the rule's checks; then accepts or rejects it."
468
+ )
469
+ )
470
+ @_guard
471
+ async def submit(task_id: str, result: cc_service.SubmitReport | None = None) -> dict[str, Any]:
472
+ async with runtime_lock:
473
+ return await cc_service.submit(_load_ctx(), task_id, result)
474
+
475
+ # --- decide() in runtime claude-code (M1-12): questions for the decider subagent ---
476
+
477
+ @server.tool(
478
+ description=(
479
+ "Questions decide() queued for a model: for each, start one decider subagent "
480
+ "(agent, model, prompt as given) and pass its JSON answer to answer_decision."
481
+ )
482
+ )
483
+ @_guard
484
+ async def pending_decisions() -> dict[str, Any]:
485
+ async with runtime_lock:
486
+ return cc_decisions.pending_decisions(_load_ctx().layout)
487
+
488
+ @server.tool(
489
+ description=(
490
+ "Record the decider's answer to a pending question: value (one of its "
491
+ "choices), confidence from 0 to 1, and a short reason."
492
+ )
493
+ )
494
+ @_guard
495
+ async def answer_decision(
496
+ question_id: str, value: str, confidence: float, reason: str = ""
497
+ ) -> dict[str, Any]:
498
+ async with runtime_lock:
499
+ try:
500
+ return cc_decisions.answer_decision(
501
+ _load_ctx().layout, question_id, value, confidence, reason
502
+ )
503
+ except cc_decisions.DecisionStateError as exc:
504
+ raise ToolError(str(exc)) from exc
505
+
506
+ @server.tool(
507
+ description="Retrieve a block and what it contains: modules, ports, registers, interrupts."
508
+ )
509
+ @_guard
510
+ async def model_block(name: str) -> dict[str, Any]:
511
+ ctx = _load_ctx()
512
+ return await model_tools.model_block(ctx, name)
513
+
514
+ @server.tool(description="Retrieve a module: ports, parameters, instances, instantiated_by.")
515
+ @_guard
516
+ async def model_module(name: str) -> dict[str, Any]:
517
+ ctx = _load_ctx()
518
+ return await model_tools.model_module(ctx, name)
519
+
520
+ @server.tool(description="Find entities by kind, name (glob pattern), or attributes.")
521
+ @_guard
522
+ async def model_find(
523
+ kind: str | None = None,
524
+ name: str | None = None,
525
+ limit: int = 100,
526
+ ) -> dict[str, Any]:
527
+ ctx = _load_ctx()
528
+ return await model_tools.model_find(ctx, kind=kind, name=name, limit=limit)
529
+
530
+ @server.tool(description="Trace a requirement or entity: implements, verifies, derives_from.")
531
+ @_guard
532
+ async def model_trace(key: str) -> dict[str, Any]:
533
+ ctx = _load_ctx()
534
+ return await model_tools.model_trace(ctx, key)
535
+
536
+ @server.tool(description="Compute downstream impact from a change: reachable entities.")
537
+ @_guard
538
+ async def model_impact(key: str, max_depth: int = 5) -> dict[str, Any]:
539
+ ctx = _load_ctx()
540
+ return await model_tools.model_impact(ctx, key, max_depth=max_depth)
541
+
542
+ @server.tool(description="Find adjacent entities (one-hop neighbors).")
543
+ @_guard
544
+ async def model_neighbors(
545
+ key: str,
546
+ kinds: list[str] | None = None,
547
+ relation: str | None = None,
548
+ direction: str = "out",
549
+ ) -> dict[str, Any]:
550
+ ctx = _load_ctx()
551
+ return await model_tools.model_neighbors(
552
+ ctx, key, kinds=kinds, relation=relation, direction=direction
553
+ )
554
+
555
+ @server.tool(description="Full-text search over entities and documents.")
556
+ @_guard
557
+ async def model_search(
558
+ text: str,
559
+ kinds: list[str] | None = None,
560
+ limit: int = 20,
561
+ ) -> dict[str, Any]:
562
+ ctx = _load_ctx()
563
+ return await model_tools.model_search(ctx, text, kinds=kinds, limit=limit)
564
+
565
+ # --- /ask (M1-13): retrieval and the citation check the asker subagent uses ----------
566
+
567
+ @server.tool(
568
+ description=(
569
+ "The sources for one /ask question: typed Design Model lookups and full-text "
570
+ "hits in the project's documents, each with the exact citation to use "
571
+ "('model:<key>' or 'path:line'). Answer only from these; none means unknown."
572
+ )
573
+ )
574
+ @_guard
575
+ async def ask_context(question: str, limit: int = 12) -> dict[str, Any]:
576
+ from chipgraph.packs.assist.ask import ask_context as run_ask_context
577
+
578
+ result = run_ask_context(_load_ctx(), question, limit)
579
+ return result.model_dump(mode="json")
580
+
581
+ @server.tool(
582
+ description=(
583
+ "Check an /ask answer before it is shown: every citation must be an indexed "
584
+ "'path:line' or a 'model:<key>', and an answer that is not unknown needs at "
585
+ "least one. Returns ok and the verified answer, or the reasons to fix."
586
+ )
587
+ )
588
+ @_guard
589
+ async def ask_check(
590
+ answer: str, citations: list[str] | None = None, unknown: bool = False
591
+ ) -> dict[str, Any]:
592
+ from chipgraph.packs.assist.ask import AskAnswer
593
+ from chipgraph.packs.assist.ask import ask_check as run_ask_check
594
+
595
+ submitted = AskAnswer(answer=answer, citations=tuple(citations or ()), unknown=unknown)
596
+ return run_ask_check(_load_ctx(), submitted).model_dump(mode="json")
597
+
598
+ # --- /triage (M1-14): classify a failing log; models answer through the decider ----
599
+
600
+ @server.tool(
601
+ description=(
602
+ "Classify a failing lint/sim/check log as infra, rtl, tb or spec, with a "
603
+ "summary, a suggestion and evidence. Give `path` (a log file inside the "
604
+ "project) or `log` (its text), and `check_id` if known. Rules answer first; "
605
+ "otherwise status is 'deferred': answer pending_decisions with the decider "
606
+ "subagent (answer_decision), then call triage again with the same arguments."
607
+ )
608
+ )
609
+ @_guard
610
+ async def triage(
611
+ path: str | None = None, log: str | None = None, check_id: str | None = None
612
+ ) -> dict[str, Any]:
613
+ from chipgraph.packs.assist.triage import triage_log
614
+
615
+ ctx = _load_ctx()
616
+ profile = ctx.require_profile().profile
617
+ if (path is None) == (log is None):
618
+ raise ToolError("give exactly one of `path` (a log file) or `log` (its text)")
619
+ source: str | None = None
620
+ if path is not None:
621
+ root = ctx.root.resolve()
622
+ target = (root / path).resolve()
623
+ if not target.is_relative_to(root):
624
+ raise ToolError(f"{path!r} is outside the project; give a path inside it")
625
+ if not target.is_file():
626
+ raise ToolError(f"no log file {path!r} in the project")
627
+ log = target.read_text(encoding="utf-8", errors="replace")
628
+ source = target.relative_to(root).as_posix()
629
+ assert log is not None
630
+ # In Claude Code the model runs in the user's session (D35): queue the question
631
+ # for the decider subagent, whatever API runtime the profile names.
632
+ backend = cc_decisions.ClaudeCodeDecideBackend(ctx.layout, profile.models)
633
+ async with runtime_lock:
634
+ report = await triage_log(ctx, log, source=source, check_id=check_id, backend=backend)
635
+ return report.model_dump(mode="json")
636
+
637
+ # --- /init-chipgraph (M1-16): `chipgraph init`, previewed before it writes -------
638
+
639
+ @server.tool(
640
+ description=(
641
+ "Set chipgraph up in this project (works with no .chipgraph.yml): returns the "
642
+ ".chipgraph.yml `chipgraph init` would write (a stub, or inferred from the repo "
643
+ "with from_learn). Writes it only with confirm=true, and never overwrites an "
644
+ "existing one without force=true. Show the preview to the user first."
645
+ )
646
+ )
647
+ @_guard
648
+ async def init(
649
+ project: str | None = None,
650
+ preset: str | None = None,
651
+ from_learn: bool = False,
652
+ confirm: bool = False,
653
+ force: bool = False,
654
+ ) -> dict[str, Any]:
655
+ return await asyncio.to_thread(
656
+ _init_project,
657
+ find_repo_root(start),
658
+ project=project,
659
+ preset=preset,
660
+ from_learn=from_learn,
661
+ confirm=confirm,
662
+ force=force,
663
+ )
664
+
665
+ return server
666
+
667
+
668
+ _INIT_NEXT_STEPS = (
669
+ "chipgraph config check # validate the profile",
670
+ "chipgraph doctor # check tools are on PATH",
671
+ "chipgraph ingest # build the Design Model (for /chipgraph:ask and /chipgraph:trace)",
672
+ )
673
+
674
+
675
+ def _init_preview(
676
+ root: Path, *, project: str | None, preset: str | None, from_learn: bool
677
+ ) -> tuple[str, list[dict[str, str]]]:
678
+ """The `.chipgraph.yml` text `chipgraph init` would write into `root`, and any other
679
+ file it would write (`--from-learn`'s naming rules), without writing anything.
680
+
681
+ The stub is `chipgraph.cli._init_content`, the CLI's own text. The `--from-learn`
682
+ text is composed from the same `chipgraph.learn` functions `init_from_learn` uses
683
+ (the write itself goes through `init_from_learn`; a test checks the two agree).
684
+ """
685
+ if not from_learn:
686
+ from chipgraph.cli import _init_content
687
+
688
+ return _init_content(project or root.name, preset), []
689
+
690
+ from chipgraph.learn import (
691
+ _INIT_NAMING_RULES_REL,
692
+ build_naming_rules,
693
+ build_profile_dict,
694
+ dump_naming_rules_yaml,
695
+ learn,
696
+ )
697
+ from chipgraph.learn.draft import dump_yaml
698
+
699
+ result = learn(root)
700
+ rules = build_naming_rules(result)
701
+ others: list[dict[str, str]] = []
702
+ naming_ref: str | None = None
703
+ if rules is not None:
704
+ naming_ref = _INIT_NAMING_RULES_REL
705
+ others.append({"path": naming_ref, "content": dump_naming_rules_yaml(rules)})
706
+ header = (
707
+ "# chipgraph project profile written by `chipgraph init --from-learn` (DESIGN.md 8.6).\n"
708
+ "# Review it: each rule was inferred from the repo. Edit freely.\n"
709
+ )
710
+ data = build_profile_dict(result, naming_rules_ref=naming_ref)
711
+ return header + dump_yaml(data), others
712
+
713
+
714
+ def _init_project(
715
+ root: Path,
716
+ *,
717
+ project: str | None,
718
+ preset: str | None,
719
+ from_learn: bool,
720
+ confirm: bool,
721
+ force: bool,
722
+ ) -> dict[str, Any]:
723
+ """The `init` tool: preview `chipgraph init`, and with `confirm`, do it.
724
+
725
+ Mirrors the CLI command: refuses to overwrite an existing `.chipgraph.yml` unless
726
+ `force`; `from_learn` infers the profile (and naming rules) from the repo; otherwise
727
+ it writes the stub (`project` defaults to the root directory's name, `preset` adds
728
+ `extends`). `preset` and `project` are ignored with `from_learn`, as in the CLI.
729
+ """
730
+ profile = root / ".chipgraph.yml"
731
+ exists = profile.is_file()
732
+ content, others = _init_preview(root, project=project, preset=preset, from_learn=from_learn)
733
+ reply: dict[str, Any] = {
734
+ "path": ".chipgraph.yml",
735
+ "exists": exists,
736
+ "written": False,
737
+ "content": content,
738
+ "other_files": others,
739
+ }
740
+ if exists and not force:
741
+ if confirm:
742
+ raise ToolError(".chipgraph.yml already exists; pass force=true to overwrite it")
743
+ reply["message"] = (
744
+ ".chipgraph.yml already exists: nothing will be written unless force=true "
745
+ "(which overwrites it)."
746
+ )
747
+ return reply
748
+ if not confirm:
749
+ verb = "overwrite" if exists else "write"
750
+ reply["message"] = (
751
+ f"Preview only: nothing was written. Call init again with confirm=true to {verb} "
752
+ "these files."
753
+ )
754
+ return reply
755
+
756
+ if from_learn:
757
+ from chipgraph.learn import InitFromLearnError, init_from_learn
758
+
759
+ old = profile.read_bytes() if exists else None
760
+ if exists:
761
+ profile.unlink()
762
+ try:
763
+ _, naming_path = init_from_learn(root)
764
+ except Exception as exc:
765
+ if old is not None and not profile.exists():
766
+ profile.write_bytes(old) # keep the user's file if the write failed
767
+ if isinstance(exc, InitFromLearnError):
768
+ raise ToolError(str(exc)) from exc
769
+ raise
770
+ written = [".chipgraph.yml"]
771
+ if naming_path is not None:
772
+ written.append(naming_path.relative_to(root).as_posix())
773
+ reply["content"] = profile.read_text(encoding="utf-8")
774
+ else:
775
+ profile.write_text(content, encoding="utf-8")
776
+ written = [".chipgraph.yml"]
777
+ reply["written"] = True
778
+ reply["files_written"] = written
779
+ reply["message"] = f"wrote {', '.join(written)}; review it, then run the next steps."
780
+ reply["next_steps"] = list(_INIT_NEXT_STEPS)
781
+ return reply
782
+
783
+
784
+ async def run_stdio(start: Path, profile_path: Path | None = None) -> None:
785
+ """Run the MCP server over stdio for the project rooted at (or above) `start`."""
786
+ server = build_server(start, profile_path=profile_path)
787
+ await server.run_stdio_async()