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,7 @@
1
+ """Runner adapters: execute subprocess commands on behalf of `ToolAdapter`s."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from chipgraph.adapters.runner.local import LocalRunner
6
+
7
+ __all__ = ["LocalRunner"]
@@ -0,0 +1,122 @@
1
+ """`LocalRunner`: runs commands as a direct subprocess on the local machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import signal
9
+ import time
10
+ from collections.abc import Mapping, Sequence
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from chipgraph.core.plugin_api.types import RunResult
15
+
16
+ _MISSING_EXECUTABLE_RETURNCODE = 127
17
+ _POSIX = os.name == "posix"
18
+ _KILL_GRACE_S = 5.0
19
+ """How long, after a timeout's kill, the output pipes may stay open."""
20
+
21
+
22
+ def _kill_tree(proc: asyncio.subprocess.Process) -> None:
23
+ """Kill `proc` and, on POSIX, every process of its process group (its children)."""
24
+ if _POSIX:
25
+ try:
26
+ os.killpg(proc.pid, signal.SIGKILL)
27
+ return
28
+ except (ProcessLookupError, PermissionError):
29
+ pass # the group is gone: kill the child itself, if it is still there
30
+ with contextlib.suppress(ProcessLookupError):
31
+ proc.kill()
32
+
33
+
34
+ class LocalRunner:
35
+ """Runs `cmd` as an argument list via `asyncio.create_subprocess_exec`.
36
+
37
+ Never invokes a shell: `cmd` is always an argument vector. `env` is merged on top
38
+ of the current process environment (`os.environ`). On POSIX the child starts in a
39
+ session (and process group) of its own, so a timeout, or the caller being cancelled,
40
+ kills the child and everything it started: a grandchild that keeps the output pipes
41
+ open cannot block the run after the kill. A timeout returns a `RunResult` with
42
+ `timed_out=True`, keeping whatever output was captured before the kill (if the pipes
43
+ are still open `_KILL_GRACE_S` after it, the output is dropped). A missing executable
44
+ is reported as `returncode=127` rather than raising.
45
+ """
46
+
47
+ name = "local"
48
+
49
+ async def run(
50
+ self,
51
+ cmd: Sequence[str],
52
+ *,
53
+ cwd: Path,
54
+ env: Mapping[str, str] | None = None,
55
+ timeout_s: float | None = None,
56
+ ) -> RunResult:
57
+ full_env = dict(os.environ)
58
+ if env is not None:
59
+ full_env.update(env)
60
+
61
+ start = time.monotonic()
62
+ extra: dict[str, Any] = {"start_new_session": True} if _POSIX else {}
63
+ try:
64
+ proc = await asyncio.create_subprocess_exec(
65
+ *cmd,
66
+ cwd=str(cwd),
67
+ env=full_env,
68
+ stdout=asyncio.subprocess.PIPE,
69
+ stderr=asyncio.subprocess.PIPE,
70
+ **extra,
71
+ )
72
+ except FileNotFoundError as exc:
73
+ duration_s = time.monotonic() - start
74
+ return RunResult(
75
+ returncode=_MISSING_EXECUTABLE_RETURNCODE,
76
+ stdout="",
77
+ stderr=f"executable not found: {exc}",
78
+ duration_s=duration_s,
79
+ )
80
+ except OSError as exc:
81
+ duration_s = time.monotonic() - start
82
+ return RunResult(
83
+ returncode=_MISSING_EXECUTABLE_RETURNCODE,
84
+ stdout="",
85
+ stderr=f"failed to start command: {exc}",
86
+ duration_s=duration_s,
87
+ )
88
+
89
+ timed_out = False
90
+ try:
91
+ if timeout_s is None:
92
+ stdout_bytes, stderr_bytes = await proc.communicate()
93
+ else:
94
+ try:
95
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
96
+ proc.communicate(), timeout=timeout_s
97
+ )
98
+ except TimeoutError:
99
+ timed_out = True
100
+ _kill_tree(proc)
101
+ try:
102
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
103
+ proc.communicate(), timeout=_KILL_GRACE_S
104
+ )
105
+ except TimeoutError:
106
+ await proc.wait()
107
+ stdout_bytes = b""
108
+ stderr_bytes = b"output dropped: its pipes stayed open after the kill"
109
+ except BaseException:
110
+ _kill_tree(proc) # cancelled (e.g. Ctrl-C): leave nothing running
111
+ raise
112
+ finally:
113
+ duration_s = time.monotonic() - start
114
+
115
+ returncode = proc.returncode if proc.returncode is not None else -9
116
+ return RunResult(
117
+ returncode=returncode,
118
+ stdout=stdout_bytes.decode("utf-8", errors="replace"),
119
+ stderr=stderr_bytes.decode("utf-8", errors="replace"),
120
+ duration_s=duration_s,
121
+ timed_out=timed_out,
122
+ )
@@ -0,0 +1,5 @@
1
+ """Agent runtimes: where agent rules actually run (DESIGN.md 5.5, D35).
2
+
3
+ - `claude_code`: the default. The user's own Claude Code runs each task as a role
4
+ subagent; the engine hands tasks out and checks them over MCP.
5
+ """
@@ -0,0 +1,24 @@
1
+ """Runtime `claude-code` (DESIGN.md 5.5, D35, spike S7).
2
+
3
+ - `runtime.ClaudeCodeRuntime`: the `AgentRuntime` a build uses; it queues agent tasks.
4
+ `runtime.ClaudeCodeExecutor` is the build's executor over it.
5
+ - `loop`: the M2-02a agent rule loop applied to one submitted attempt (label, budget,
6
+ redo instruction, the dispatch bound).
7
+ - `previous`: the outputs of a role without a read tool, moved aside at each dispatch
8
+ (so its `Write` creates a file), shown in its context, and put back if it fails.
9
+ - `service`: what the MCP tools `next_task`, `get_context` and `submit` do.
10
+ - The Claude Code plugin (`plugin/` in the repo) holds the loop command, one subagent
11
+ per role, and the write guard hook.
12
+
13
+ `service` imports `chipgraph.app` (the build, the check runner), so it is not imported
14
+ here: `chipgraph.app.build` imports this package for the runtime.
15
+ """
16
+
17
+ from chipgraph.adapters.runtime.claude_code.runtime import (
18
+ PLUGIN_NAME,
19
+ RUN_COMMAND,
20
+ ClaudeCodeExecutor,
21
+ ClaudeCodeRuntime,
22
+ )
23
+
24
+ __all__ = ["PLUGIN_NAME", "RUN_COMMAND", "ClaudeCodeExecutor", "ClaudeCodeRuntime"]
@@ -0,0 +1,158 @@
1
+ """The plugin's role subagents, rendered from the role data (DESIGN.md 5.1, 5.5; D8, D35).
2
+
3
+ Each built-in role's tool table (`chipgraph.core.runtime.roles`) becomes a Claude Code
4
+ subagent file `plugin/agents/<role>.md`: its abstract capabilities mapped to Claude
5
+ Code tools (`CAPABILITY_TOOLS`), its first tier mapped to a model alias, its prompt with
6
+ the tool placeholders filled in. The files are generated; a test fails when a committed
7
+ one differs from the render::
8
+
9
+ python -m chipgraph.adapters.runtime.claude_code.agents --write # regenerate
10
+ python -m chipgraph.adapters.runtime.claude_code.agents --check # exit 1 if stale
11
+
12
+ `asker.md` and `decider.md` stay hand-written: `/chipgraph:ask` is not a role, and
13
+ `decider` serves the `triage` role through `decide()` (its tools and tier are checked
14
+ against that role by a test).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from chipgraph.adapters.runtime.claude_code.runtime import PLUGIN_NAME
24
+ from chipgraph.core.contracts import ModelTier
25
+ from chipgraph.core.runtime.roles import CAPABILITIES, Capability, RoleSpec, get_role
26
+
27
+ MCP_TOOL_PREFIX = f"mcp__plugin_{PLUGIN_NAME}_{PLUGIN_NAME}__"
28
+ """How Claude Code names the plugin's MCP server tools: `mcp__plugin_<p>_<server>__<t>`."""
29
+
30
+ CAPABILITY_TOOLS: dict[Capability, tuple[str, ...]] = {
31
+ "read_files": ("Read",),
32
+ "search_files": ("Glob", "Grep"),
33
+ "write_outputs": ("Write", "Edit", "MultiEdit"),
34
+ "engine_context": (f"{MCP_TOOL_PREFIX}get_context",),
35
+ "engine_decisions": (f"{MCP_TOOL_PREFIX}pending_decisions",),
36
+ }
37
+ """The Claude Code tools for each abstract capability. Nothing maps to a shell or to web
38
+ tools: roles get neither."""
39
+
40
+ DEFAULT_TIER_MODELS: dict[ModelTier, str] = {"small": "haiku", "medium": "sonnet", "large": "opus"}
41
+ """Claude Code model aliases per tier, when the profile's `models.tiers` names none. The
42
+ agent file's `model` is its role's first tier; `next_task` passes the model per attempt."""
43
+
44
+ RENDERED_ROLES = ("author", "tb-author", "rtl-author", "critic", "planner", "researcher")
45
+ """The roles with a generated subagent file."""
46
+
47
+ HAND_WRITTEN_AGENTS = {"triage": "decider"}
48
+ """Roles served by a hand-written subagent, and its name."""
49
+
50
+ _GENERATED_NOTE = (
51
+ "# Generated from src/chipgraph/core/runtime/roles/data/{role}.md by\n"
52
+ "# `python -m chipgraph.adapters.runtime.claude_code.agents --write`; do not edit.\n"
53
+ )
54
+
55
+
56
+ def agent_name(role_id: str) -> str:
57
+ """The subagent file name (without `.md`) for a role: its id, or the hand-written one."""
58
+ short = role_id.rsplit("/", 1)[-1]
59
+ return HAND_WRITTEN_AGENTS.get(short, short)
60
+
61
+
62
+ def agent_type(role_id: str) -> str:
63
+ """The `subagent_type` for a role: `chipgraph:<agent name>`."""
64
+ return f"{PLUGIN_NAME}:{agent_name(role_id)}"
65
+
66
+
67
+ def tools_for(role: RoleSpec) -> tuple[str, ...]:
68
+ """The Claude Code tools a role's subagent gets, in capability order."""
69
+ return tuple(
70
+ tool
71
+ for capability in CAPABILITIES
72
+ if capability in role.tools
73
+ for tool in CAPABILITY_TOOLS[capability]
74
+ )
75
+
76
+
77
+ def _quoted(text: str) -> str:
78
+ """A single-quoted YAML scalar."""
79
+ return "'" + text.replace("'", "''") + "'"
80
+
81
+
82
+ def render_agent(role: RoleSpec) -> str:
83
+ """The subagent file for `role`."""
84
+ names: dict[str, tuple[str, ...]] = {c: CAPABILITY_TOOLS[c] for c in role.tools}
85
+ head = (
86
+ "---\n"
87
+ + _GENERATED_NOTE.format(role=role.id)
88
+ + f"name: {agent_name(role.id)}\n"
89
+ + f"description: {_quoted(role.description)}\n"
90
+ + f"tools: {', '.join(tools_for(role))}\n"
91
+ + f"model: {DEFAULT_TIER_MODELS[role.default_tier]}\n"
92
+ + "---\n\n"
93
+ )
94
+ return head + role.render_prompt(names)
95
+
96
+
97
+ def rendered_files() -> dict[str, str]:
98
+ """File name (`<role>.md`) to content, for every generated subagent file."""
99
+ return {f"{agent_name(r)}.md": render_agent(get_role(r)) for r in RENDERED_ROLES}
100
+
101
+
102
+ def _default_agents_dir() -> Path:
103
+ here = Path(__file__).resolve()
104
+ for candidate in here.parents:
105
+ if (candidate / "pyproject.toml").is_file():
106
+ return candidate / "plugin" / "agents"
107
+ raise FileNotFoundError(f"no pyproject.toml found above {here}")
108
+
109
+
110
+ def check(agents_dir: Path) -> list[str]:
111
+ """Generated files in `agents_dir` that are missing or differ from the render."""
112
+ problems = []
113
+ for name, content in rendered_files().items():
114
+ path = agents_dir / name
115
+ if not path.is_file():
116
+ problems.append(f"missing: {path}")
117
+ elif path.read_text(encoding="utf-8") != content:
118
+ problems.append(f"outdated: {path}")
119
+ return problems
120
+
121
+
122
+ def main(argv: list[str] | None = None) -> int:
123
+ parser = argparse.ArgumentParser(description="Render plugin/agents/ from the role data.")
124
+ mode = parser.add_mutually_exclusive_group(required=True)
125
+ mode.add_argument("--write", action="store_true", help="write the agent files")
126
+ mode.add_argument("--check", action="store_true", help="exit 1 if a file is out of date")
127
+ parser.add_argument("--dir", type=Path, default=None, help="default: plugin/agents/")
128
+ args = parser.parse_args(argv)
129
+ agents_dir: Path = args.dir if args.dir is not None else _default_agents_dir()
130
+ if args.check:
131
+ problems = check(agents_dir)
132
+ for problem in problems:
133
+ print(problem, file=sys.stderr)
134
+ return 1 if problems else 0
135
+ agents_dir.mkdir(parents=True, exist_ok=True)
136
+ for name, content in rendered_files().items():
137
+ (agents_dir / name).write_text(content, encoding="utf-8")
138
+ print(f"wrote {len(RENDERED_ROLES)} agent files to {agents_dir}")
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ raise SystemExit(main())
144
+
145
+
146
+ __all__ = [
147
+ "CAPABILITY_TOOLS",
148
+ "DEFAULT_TIER_MODELS",
149
+ "HAND_WRITTEN_AGENTS",
150
+ "MCP_TOOL_PREFIX",
151
+ "RENDERED_ROLES",
152
+ "agent_name",
153
+ "agent_type",
154
+ "check",
155
+ "render_agent",
156
+ "rendered_files",
157
+ "tools_for",
158
+ ]
@@ -0,0 +1,280 @@
1
+ """`decide()` in runtime `claude-code`: questions answered by a small-model subagent.
2
+
3
+ chipgraph never calls a model in this runtime (D35), so `ClaudeCodeDecideBackend.ask`
4
+ does not answer: it records the question under the state backend and returns
5
+ `Deferred`. The plugin then runs the question in the user's Claude Code session::
6
+
7
+ pending_decisions() the MCP tool lists the queued questions, each with the
8
+ tier's model and a prompt, for a `chipgraph:decider`
9
+ subagent started with that model
10
+ answer_decision(id, ...) the MCP tool records the subagent's JSON answer, after
11
+ checking the value is one of the question's choices
12
+ decide(question) again the recorded answer comes back from `ask`; if it is
13
+ under the small threshold, `decide()` asks the large
14
+ tier, which queues the question again with its model
15
+
16
+ State, one file per question (never in the repo tree)::
17
+
18
+ <state>/runtime/decisions/<sha256(question_id)>.json PendingDecision
19
+
20
+ A recorded answer is reused only for the very same question (same fingerprint): a
21
+ question id asked with a new prompt, context or choices starts afresh. A question
22
+ labeled 'nda' is refused (`NdaBlocked`): every model of this runtime is a cloud model.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+ from typing import Any, Literal
31
+
32
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
33
+
34
+ from chipgraph.adapters.llm._errors import NdaBlocked
35
+ from chipgraph.adapters.llm.decide_backend import question_prompt
36
+ from chipgraph.adapters.runtime.claude_code.runtime import PLUGIN_NAME
37
+ from chipgraph.core.config.models import DecideTier, ModelsCfg
38
+ from chipgraph.core.engine.decide import Deferred, ModelAnswer, Question
39
+ from chipgraph.core.runtime import state_key
40
+ from chipgraph.core.state.layout import StateLayout
41
+
42
+ DECIDER_AGENT = f"{PLUGIN_NAME}:decider"
43
+ """The plugin subagent that answers one question (`plugin/agents/decider.md`)."""
44
+
45
+ DEFAULT_TIER_MODELS: dict[DecideTier, str] = {"small": "haiku", "large": "opus"}
46
+ """Claude Code model aliases per decide tier, when the profile's `models.tiers` names none
47
+ (the same aliases the task loop uses for these tiers)."""
48
+
49
+
50
+ class DecisionStateError(Exception):
51
+ """An unknown question, one not waiting for an answer, or an answer it cannot take."""
52
+
53
+
54
+ def _now() -> datetime:
55
+ return datetime.now(UTC)
56
+
57
+
58
+ class RecordedAnswer(BaseModel):
59
+ """What the decider subagent answered for one tier."""
60
+
61
+ model_config = ConfigDict(extra="forbid", frozen=True)
62
+
63
+ value: str = Field(description="The answer: one of the question's choices.")
64
+ confidence: float = Field(ge=0, le=1, description="The subagent's confidence.")
65
+ reason: str = Field(default="", description="Its short justification.")
66
+ model: str = Field(description="The model the subagent was asked to run on.")
67
+ answered_at: AwareDatetime = Field(default_factory=_now, description="When it answered.")
68
+
69
+
70
+ class PendingDecision(BaseModel):
71
+ """One question `decide()` handed to the Claude Code session, and its answers."""
72
+
73
+ model_config = ConfigDict(extra="forbid", frozen=True)
74
+
75
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
76
+ question: Question = Field(description="The question.")
77
+ fingerprint: str = Field(description="`question.fingerprint()` when it was queued.")
78
+ tier: DecideTier = Field(description="The tier last asked: the one pending, if any.")
79
+ model: str = Field(description="The model for `tier`.")
80
+ status: Literal["pending", "answered"] = Field(description="Whether `tier` has answered.")
81
+ answers: dict[DecideTier, RecordedAnswer] = Field(
82
+ default_factory=dict, description="Recorded answers, per tier."
83
+ )
84
+ updated_at: AwareDatetime = Field(default_factory=_now, description="Last change.")
85
+
86
+
87
+ def _write_atomic(path: Path, text: str) -> None:
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
90
+ tmp.write_text(text, encoding="utf-8")
91
+ os.replace(tmp, path)
92
+
93
+
94
+ class DecisionQueue:
95
+ """Reads and writes the queued questions under a project's state directory."""
96
+
97
+ def __init__(self, layout: StateLayout) -> None:
98
+ self.layout = layout
99
+ self.dir = layout.state_dir / "runtime" / "decisions"
100
+
101
+ def _path(self, question_id: str) -> Path:
102
+ return self.dir / f"{state_key(question_id)}.json"
103
+
104
+ def get(self, question_id: str) -> PendingDecision | None:
105
+ """The record for `question_id`, or `None` if it was never queued."""
106
+ path = self._path(question_id)
107
+ if not path.is_file():
108
+ return None
109
+ return PendingDecision.model_validate_json(path.read_text(encoding="utf-8"))
110
+
111
+ def all(self) -> list[PendingDecision]:
112
+ """Every record, sorted by question id."""
113
+ if not self.dir.is_dir():
114
+ return []
115
+ records = [
116
+ PendingDecision.model_validate_json(p.read_text(encoding="utf-8"))
117
+ for p in self.dir.glob("*.json")
118
+ ]
119
+ return sorted(records, key=lambda r: r.question.id)
120
+
121
+ def pending(self) -> list[PendingDecision]:
122
+ """The records waiting for an answer."""
123
+ return [r for r in self.all() if r.status == "pending"]
124
+
125
+ def _put(self, record: PendingDecision) -> PendingDecision:
126
+ record = record.model_copy(update={"updated_at": _now()})
127
+ _write_atomic(self._path(record.question.id), record.model_dump_json(indent=2))
128
+ return record
129
+
130
+ def request(self, question: Question, tier: DecideTier, model: str) -> PendingDecision:
131
+ """Queue `question` for `tier` (idempotent); a changed question starts afresh."""
132
+ fingerprint = question.fingerprint()
133
+ existing = self.get(question.id)
134
+ answers = existing.answers if existing and existing.fingerprint == fingerprint else {}
135
+ if (
136
+ existing is not None
137
+ and existing.fingerprint == fingerprint
138
+ and existing.status == "pending"
139
+ and existing.tier == tier
140
+ and existing.model == model
141
+ ):
142
+ return existing
143
+ return self._put(
144
+ PendingDecision(
145
+ question=question,
146
+ fingerprint=fingerprint,
147
+ tier=tier,
148
+ model=model,
149
+ status="pending",
150
+ answers={t: a for t, a in answers.items() if t != tier},
151
+ )
152
+ )
153
+
154
+ def answer(
155
+ self, question_id: str, value: str, confidence: float, reason: str = ""
156
+ ) -> PendingDecision:
157
+ """Record the answer to the pending tier of `question_id`.
158
+
159
+ Raises `DecisionStateError` for an unknown question, one not pending, a value
160
+ that is not one of its choices, or a confidence outside 0..1.
161
+ """
162
+ record = self.get(question_id)
163
+ if record is None:
164
+ raise DecisionStateError(
165
+ f"no queued question {question_id!r}; pending_decisions lists them"
166
+ )
167
+ if record.status != "pending":
168
+ raise DecisionStateError(
169
+ f"question {question_id!r} is not waiting for an answer (its {record.tier} "
170
+ "tier already answered)"
171
+ )
172
+ choice = record.question.match(value)
173
+ if choice is None:
174
+ raise DecisionStateError(
175
+ f"{value!r} is not a choice of question {question_id!r}; answer one of "
176
+ f"{list(record.question.choices)}"
177
+ )
178
+ if not 0 <= confidence <= 1:
179
+ raise DecisionStateError(f"confidence must be between 0 and 1, got {confidence}")
180
+ answers = dict(record.answers)
181
+ answers[record.tier] = RecordedAnswer(
182
+ value=choice, confidence=confidence, reason=reason, model=record.model
183
+ )
184
+ return self._put(record.model_copy(update={"status": "answered", "answers": answers}))
185
+
186
+
187
+ class ClaudeCodeDecideBackend:
188
+ """A `ModelBackend` for `decide()` that defers each question to the Claude Code session.
189
+
190
+ `models.tiers` (the profile's) may name the model per tier; otherwise `haiku` for
191
+ `small` and `opus` for `large`.
192
+ """
193
+
194
+ name = "claude-code"
195
+
196
+ def __init__(self, layout: StateLayout, models: ModelsCfg | None = None) -> None:
197
+ self.queue = DecisionQueue(layout)
198
+ self.models = models if models is not None else ModelsCfg()
199
+
200
+ def model_for(self, tier: DecideTier) -> str:
201
+ """The subagent model for `tier`."""
202
+ return self.models.tiers.get(tier) or DEFAULT_TIER_MODELS[tier]
203
+
204
+ async def ask(self, question: Question, tier: DecideTier) -> ModelAnswer | Deferred:
205
+ if "nda" in question.labels:
206
+ raise NdaBlocked(
207
+ f"question {question.id!r} is labeled 'nda' and runtime claude-code only "
208
+ "has cloud models; 'nda' data only goes to a self-hosted model"
209
+ )
210
+ record = self.queue.get(question.id)
211
+ if record is not None and record.fingerprint == question.fingerprint():
212
+ answered = record.answers.get(tier)
213
+ if answered is not None:
214
+ return ModelAnswer(
215
+ value=answered.value,
216
+ confidence=answered.confidence,
217
+ model=answered.model,
218
+ reason=answered.reason,
219
+ )
220
+ model = self.model_for(tier)
221
+ self.queue.request(question, tier, model)
222
+ return Deferred(
223
+ question_id=question.id,
224
+ tier=tier,
225
+ model=model,
226
+ message=(
227
+ f"queued for the {DECIDER_AGENT} subagent (model {model}): the plugin "
228
+ "reads it with pending_decisions and records the answer with "
229
+ "answer_decision; then ask again"
230
+ ),
231
+ )
232
+
233
+
234
+ # --- what the MCP tools do ---------------------------------------------------------------
235
+
236
+
237
+ def _entry(record: PendingDecision) -> dict[str, Any]:
238
+ question = record.question
239
+ return {
240
+ "question_id": question.id,
241
+ "tier": record.tier,
242
+ "model": record.model,
243
+ "agent": DECIDER_AGENT,
244
+ "description": f"chipgraph decide {question.id}",
245
+ "choices": list(question.choices),
246
+ "prompt": question_prompt(question),
247
+ }
248
+
249
+
250
+ def pending_decisions(layout: StateLayout) -> dict[str, Any]:
251
+ """The `pending_decisions` MCP tool: every question waiting for the decider."""
252
+ return {"decisions": [_entry(r) for r in DecisionQueue(layout).pending()]}
253
+
254
+
255
+ def answer_decision(
256
+ layout: StateLayout, question_id: str, value: str, confidence: float, reason: str = ""
257
+ ) -> dict[str, Any]:
258
+ """The `answer_decision` MCP tool: record the decider's answer for a pending question."""
259
+ record = DecisionQueue(layout).answer(question_id, value, confidence, reason)
260
+ answer = record.answers[record.tier]
261
+ return {
262
+ "question_id": question_id,
263
+ "tier": record.tier,
264
+ "value": answer.value,
265
+ "confidence": answer.confidence,
266
+ "status": record.status,
267
+ }
268
+
269
+
270
+ __all__ = [
271
+ "DECIDER_AGENT",
272
+ "DEFAULT_TIER_MODELS",
273
+ "ClaudeCodeDecideBackend",
274
+ "DecisionQueue",
275
+ "DecisionStateError",
276
+ "PendingDecision",
277
+ "RecordedAnswer",
278
+ "answer_decision",
279
+ "pending_decisions",
280
+ ]