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,492 @@
1
+ """The context of a review task, and the reply it is checked against (DESIGN.md 4.8, 5.1).
2
+
3
+ A task whose role writes no files but has `engine_writes` (the Critic) reviews a change.
4
+ `get_context` gives it, with a fresh context (no conversation, no author reasoning, no
5
+ earlier review), a `review` section built here:
6
+
7
+ - `diff`: the target's files, base ref vs the working tree (tracked changes and new
8
+ untracked files), each line led by its number in the new file (blank for a removed
9
+ line), capped at `DIFF_CAP` characters with a note naming the files left out;
10
+ - `spec`: the target block's requirements, interface, registers and open items from
11
+ the Design Model, each with its `file:line`;
12
+ - `model`: the block's Design Model slice (`model.block(name)`);
13
+ - `base`, `head`, `target`: what the reply must name.
14
+
15
+ The base ref is the instance param `base` when the rule's instance has one; otherwise the
16
+ merge-base of HEAD with the default branch (`origin/HEAD`, else `main`, else `master`),
17
+ which is HEAD itself on the default branch (the diff is then the uncommitted change). The
18
+ target's files are the paths of the build graph's other instances for the block, the
19
+ Design Model's module files and spec sources for it, and the profile's layout paths for
20
+ it; the review rule's own outputs are never among them.
21
+
22
+ What the task was shown is kept as a `ReviewScope` under the state directory
23
+ (`<state>/runtime/reviews/`), per dispatch, so `submit` checks the reply against exactly
24
+ that diff (`load_scope`).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import posixpath
31
+ import re
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from chipgraph.adapters.tool.filelist import FilelistError, read_filelist
36
+ from chipgraph.app.context import AppContext
37
+ from chipgraph.app.errors import AppError
38
+ from chipgraph.core.contracts import RuleInstance, RuleSpec
39
+ from chipgraph.core.engine.graph import BuildGraph
40
+ from chipgraph.core.model import ModelQuery, ModelStore, QueryError, default_model_db_path
41
+ from chipgraph.core.runtime.queue import state_key
42
+ from chipgraph.core.runtime.roles import ReviewReport, ReviewScope, diff_hunks
43
+
44
+ DIFF_CAP = 60_000
45
+ """Longest diff text `get_context` returns; the files after the cut are named instead."""
46
+
47
+ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
48
+ """git's empty tree: the base of a repo with no commit yet."""
49
+
50
+ _DEFAULT_BRANCHES = ("main", "master")
51
+ _PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}")
52
+ _GLOB_CHARS = frozenset("*?[")
53
+ _SPEC_KINDS = ("requirement", "register", "field", "port", "open_item")
54
+
55
+
56
+ def is_review_role(role: Any) -> bool:
57
+ """Whether a role replies with a review the engine writes (`engine_writes`)."""
58
+ return role is not None and role.write_scope == "none" and bool(role.engine_writes)
59
+
60
+
61
+ # --- git ----------------------------------------------------------------------------------
62
+
63
+
64
+ async def _git(ctx: AppContext, *args: str, ok: tuple[int, ...] = (0,)) -> str | None:
65
+ """`git <args>` in the project; its stdout, or `None` when it exits with another code."""
66
+ runner = ctx.registry.get("runner", "local")
67
+ result = await runner.run(["git", "-c", "core.quotePath=false", *args], cwd=ctx.root)
68
+ return result.stdout if result.returncode in ok else None
69
+
70
+
71
+ async def _default_branch(ctx: AppContext) -> str | None:
72
+ remote = await _git(ctx, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
73
+ if remote and remote.strip():
74
+ return remote.strip().removeprefix("refs/remotes/")
75
+ for name in _DEFAULT_BRANCHES:
76
+ if await _git(ctx, "rev-parse", "--verify", "--quiet", f"refs/heads/{name}") is not None:
77
+ return name
78
+ return None
79
+
80
+
81
+ async def resolve_refs(ctx: AppContext, instance: RuleInstance) -> tuple[str, str, str]:
82
+ """`(base, how, head)`: the base commit, how it was chosen, and the HEAD commit."""
83
+ head_out = await _git(ctx, "rev-parse", "--verify", "--quiet", "HEAD")
84
+ head = head_out.strip() if head_out else ""
85
+ param = instance.params.get("base")
86
+ if param:
87
+ found = await _git(ctx, "rev-parse", "--verify", "--quiet", f"{param}^{{commit}}")
88
+ if not found:
89
+ raise AppError(f"task {instance.instance_id!r}: base ref {param!r} is not a commit")
90
+ return found.strip(), f"rule param base={param}", head or EMPTY_TREE
91
+ if not head:
92
+ return EMPTY_TREE, "no commit yet: the empty tree", EMPTY_TREE
93
+ branch = await _default_branch(ctx)
94
+ if branch is None:
95
+ return head, "HEAD (no default branch found)", head
96
+ base = await _git(ctx, "merge-base", "HEAD", branch)
97
+ if not base:
98
+ return head, f"HEAD (no merge-base with {branch})", head
99
+ return base.strip(), f"merge-base of HEAD and {branch}", head
100
+
101
+
102
+ # --- the target's files -------------------------------------------------------------------
103
+
104
+
105
+ def _clean(path: str | None) -> str | None:
106
+ if not path:
107
+ return None
108
+ text = path.replace("\\", "/").strip()
109
+ if not text or text.startswith("/") or _GLOB_CHARS & set(text):
110
+ return None
111
+ norm = posixpath.normpath(text)
112
+ if norm in (".", "..") or norm.startswith("../"):
113
+ return None
114
+ return norm
115
+
116
+
117
+ def _fill(template: str, block: str) -> str | None:
118
+ def _replace(match: re.Match[str]) -> str:
119
+ key = match.group(1)
120
+ if key == "block":
121
+ return block
122
+ if key == "BLOCK":
123
+ return block.upper()
124
+ return "*"
125
+
126
+ return _clean(_PLACEHOLDER_RE.sub(_replace, template))
127
+
128
+
129
+ def _model(ctx: AppContext) -> tuple[Any, Any] | None:
130
+ db = default_model_db_path(ctx.layout.root)
131
+ if not db.is_file():
132
+ return None
133
+ store = ModelStore(db)
134
+ return store.read(), store
135
+
136
+
137
+ def _entity_block(entity: Any) -> str | None:
138
+ block = getattr(entity, "block", None)
139
+ if isinstance(block, str):
140
+ return block
141
+ attr = entity.attrs.get("block")
142
+ return attr if isinstance(attr, str) else None
143
+
144
+
145
+ def target_files(
146
+ ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
147
+ ) -> list[str]:
148
+ """The repo-relative files a review of `instance` covers, sorted."""
149
+ own = {
150
+ ref.path
151
+ for inst in graph.instances.values()
152
+ if inst.rule_id == rule.id
153
+ for ref in inst.outputs
154
+ if ref.path is not None
155
+ }
156
+ paths: set[str | None] = {ref.path for ref in instance.inputs}
157
+ block = instance.params.get("block")
158
+ if block:
159
+ for inst in graph.instances.values():
160
+ if inst.rule_id != rule.id and inst.params.get("block") == block:
161
+ paths.update(ref.path for ref in (*inst.inputs, *inst.outputs))
162
+ profile = ctx.require_profile().profile
163
+ layout: dict[str, Any] = dict(profile.layout)
164
+ override = profile.blocks.get(block)
165
+ if override is not None:
166
+ layout.update(override.layout)
167
+ for value in layout.values():
168
+ for template in (value,) if isinstance(value, str) else value:
169
+ paths.add(_fill(template, block))
170
+ loaded = _model(ctx)
171
+ if loaded is not None:
172
+ model, _ = loaded
173
+ key = f"block:{block}"
174
+ modules = set()
175
+ for entity in model.by_kind("module"):
176
+ if getattr(entity, "block", None) == key:
177
+ modules.add(entity.key)
178
+ paths.add(getattr(entity, "file", None))
179
+ for kind in _SPEC_KINDS:
180
+ for entity in model.by_kind(kind):
181
+ if _entity_block(entity) == key or getattr(entity, "module", None) in modules:
182
+ paths.add(entity.source.file)
183
+ cleaned = {c for c in (_clean(p) for p in paths) if c is not None}
184
+ cleaned |= _filelist_sources(ctx.root, sorted(p for p in cleaned if p.endswith(".f")))
185
+ return sorted(cleaned - own)
186
+
187
+
188
+ def _filelist_sources(root: Path, filelists: list[str]) -> set[str]:
189
+ """The repo files the filelists among the target's files list (new files too).
190
+
191
+ Paths are tried both relative to the filelist and to the project root, as ingest does;
192
+ only those inside the project are kept.
193
+ """
194
+ found: set[str] = set()
195
+ top = root.resolve()
196
+ for rel in filelists:
197
+ path = root / rel
198
+ if not path.is_file():
199
+ continue
200
+ for style in ("filelist", "cwd"):
201
+ try:
202
+ listed = read_filelist(path, relative_to=style, root=root)
203
+ except FilelistError:
204
+ continue
205
+ for source in listed.sources:
206
+ if not source.is_file():
207
+ continue
208
+ try:
209
+ found.add(source.resolve().relative_to(top).as_posix())
210
+ except ValueError:
211
+ continue
212
+ return {c for c in (_clean(p) for p in found) if c is not None}
213
+
214
+
215
+ # --- the diff -----------------------------------------------------------------------------
216
+
217
+
218
+ async def full_diff(ctx: AppContext, base: str, files: list[str]) -> str:
219
+ """Unified diff of `files`, `base` vs the working tree, new untracked files included."""
220
+ if not files:
221
+ return ""
222
+ tracked = await _git(
223
+ ctx, "diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3", base, "--", *files
224
+ )
225
+ parts = [tracked or ""]
226
+ untracked = await _git(ctx, "ls-files", "-z", "--others", "--exclude-standard", "--", *files)
227
+ for path in sorted(p for p in (untracked or "").split("\0") if p):
228
+ added = await _git(
229
+ ctx, "diff", "--no-color", "--no-index", "--", "/dev/null", path, ok=(0, 1)
230
+ )
231
+ parts.append(added or "")
232
+ return "".join(p if p.endswith("\n") or not p else p + "\n" for p in parts)
233
+
234
+
235
+ def _sections(diff: str) -> list[tuple[str, list[str]]]:
236
+ """The diff split per file: (path, lines), in order."""
237
+ sections: list[tuple[str, list[str]]] = []
238
+ for line in diff.splitlines():
239
+ if line.startswith("diff --git ") or not sections:
240
+ sections.append(("", []))
241
+ name, lines = sections[-1]
242
+ lines.append(line)
243
+ if line.startswith("+++ ") and not name:
244
+ new = line[4:].strip()
245
+ sections[-1] = (new[2:] if new.startswith("b/") else new, lines)
246
+ elif line.startswith("--- ") and not name:
247
+ old = line[4:].strip()
248
+ if old != "/dev/null":
249
+ sections[-1] = (old[2:] if old.startswith("a/") else old, lines)
250
+ return sections
251
+
252
+
253
+ def annotate(diff: str) -> list[tuple[str, str]]:
254
+ """Per file: (path, text), each hunk line led by its line number in the new file."""
255
+ out: list[tuple[str, str]] = []
256
+ for path, lines in _sections(diff):
257
+ shown: list[str] = []
258
+ new_line = 0
259
+ in_hunk = False
260
+ for line in lines:
261
+ match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
262
+ if match:
263
+ new_line = int(match.group(1))
264
+ in_hunk = True
265
+ shown.append(line)
266
+ elif in_hunk and line[:1] in ("+", " "):
267
+ shown.append(f"{new_line:>5} {line}")
268
+ new_line += 1
269
+ elif in_hunk and line[:1] == "-":
270
+ shown.append(f"{'':>5} {line}")
271
+ else:
272
+ if line.startswith("diff --git "):
273
+ in_hunk = False
274
+ shown.append(line)
275
+ out.append((path, "\n".join(shown) + "\n"))
276
+ return out
277
+
278
+
279
+ def capped(sections: list[tuple[str, str]], cap: int | None = None) -> tuple[str, list[str]]:
280
+ """The annotated diff within `cap` (default `DIFF_CAP`) characters, and the files left
281
+ out (or cut)."""
282
+ cap = DIFF_CAP if cap is None else cap
283
+ text = ""
284
+ omitted: list[str] = []
285
+ for path, body in sections:
286
+ if omitted or len(text) + len(body) > cap:
287
+ if not text:
288
+ text = body[:cap]
289
+ omitted.append(path)
290
+ continue
291
+ text += body
292
+ return text, omitted
293
+
294
+
295
+ # --- the spec and model slices ------------------------------------------------------------
296
+
297
+
298
+ def _where(entity: Any) -> str | None:
299
+ src = entity.source
300
+ if not src.file:
301
+ return None
302
+ return f"{src.file}:{src.line}" if src.line else src.file
303
+
304
+
305
+ def spec_slice(ctx: AppContext, block: str | None) -> dict[str, Any]:
306
+ """The block's requirements, interface, registers and open items, from the model."""
307
+ if not block:
308
+ return {"note": "the task has no block param: no spec slice"}
309
+ loaded = _model(ctx)
310
+ if loaded is None:
311
+ return {"note": "no Design Model (run `chipgraph ingest` first)"}
312
+ model, _ = loaded
313
+ key = f"block:{block}"
314
+ modules = {e.key for e in model.by_kind("module") if getattr(e, "block", None) == key}
315
+ requirements = [
316
+ {"id": e.name, "text": getattr(e, "text", None), "source": _where(e)}
317
+ for e in model.by_kind("requirement")
318
+ if _entity_block(e) == key
319
+ ]
320
+ interface = [
321
+ {
322
+ "name": e.name,
323
+ "direction": e.direction,
324
+ "width": e.width,
325
+ "description": e.attrs.get("description"),
326
+ "source": _where(e),
327
+ }
328
+ for e in model.by_kind("port")
329
+ if _entity_block(e) == key and e.attrs.get("origin") == "spec"
330
+ ]
331
+ rtl_ports = [
332
+ {
333
+ "module": e.module,
334
+ "name": e.name,
335
+ "direction": e.direction,
336
+ "width": e.width,
337
+ "source": _where(e),
338
+ }
339
+ for e in model.by_kind("port")
340
+ if e.module in modules
341
+ ]
342
+ fields: dict[str, list[dict[str, Any]]] = {}
343
+ for e in model.by_kind("field"):
344
+ fields.setdefault(str(getattr(e, "register", "")), []).append(
345
+ {
346
+ "name": e.name,
347
+ "msb": e.msb,
348
+ "lsb": e.lsb,
349
+ "access": e.access,
350
+ "reset_value": e.reset_value,
351
+ "source": _where(e),
352
+ }
353
+ )
354
+ registers = [
355
+ {
356
+ "name": e.name,
357
+ "offset": e.offset,
358
+ "access": e.access,
359
+ "reset_value": e.reset_value,
360
+ "source": _where(e),
361
+ "fields": fields.get(e.key, []),
362
+ }
363
+ for e in model.by_kind("register")
364
+ if getattr(e, "block", None) == key
365
+ ]
366
+ open_items = [
367
+ {"name": e.name, "status": getattr(e, "status", None), "source": _where(e)}
368
+ for e in model.by_kind("open_item")
369
+ if _entity_block(e) == key
370
+ ]
371
+ return {
372
+ "requirements": sorted(requirements, key=lambda r: str(r["id"])),
373
+ "interface": interface,
374
+ "rtl_ports": rtl_ports,
375
+ "registers": registers,
376
+ "open_items": open_items,
377
+ }
378
+
379
+
380
+ def model_slice(ctx: AppContext, block: str | None) -> dict[str, Any]:
381
+ """`model.block(name)` as JSON, or a note."""
382
+ loaded = _model(ctx) if block else None
383
+ if loaded is None:
384
+ return {"note": "not available from the Design Model"}
385
+ model, store = loaded
386
+ try:
387
+ info = ModelQuery(model, store).block(str(block))
388
+ except QueryError as exc:
389
+ return {"note": str(exc)}
390
+ result: dict[str, Any] = info.model_dump(mode="json")
391
+ return result
392
+
393
+
394
+ # --- the scope, kept per dispatch ---------------------------------------------------------
395
+
396
+
397
+ def _scope_path(ctx: AppContext, task_id: str) -> Path:
398
+ return ctx.layout.state_dir / "runtime" / "reviews" / f"{state_key(task_id)}.json"
399
+
400
+
401
+ def save_scope(ctx: AppContext, task_id: str, dispatch: int, scope: ReviewScope) -> None:
402
+ path = _scope_path(ctx, task_id)
403
+ path.parent.mkdir(parents=True, exist_ok=True)
404
+ data = {"task_id": task_id, "dispatch": dispatch, "scope": scope.model_dump(mode="json")}
405
+ tmp = path.with_suffix(".tmp")
406
+ tmp.write_text(json.dumps(data, indent=1, sort_keys=True) + "\n", encoding="utf-8")
407
+ tmp.replace(path)
408
+
409
+
410
+ def load_scope(ctx: AppContext, task_id: str, dispatch: int) -> ReviewScope | None:
411
+ """The scope `get_context` showed this dispatch of the task, if it did."""
412
+ path = _scope_path(ctx, task_id)
413
+ try:
414
+ data = json.loads(path.read_text(encoding="utf-8"))
415
+ except (OSError, json.JSONDecodeError):
416
+ return None
417
+ if data.get("task_id") != task_id or data.get("dispatch") != dispatch:
418
+ return None
419
+ return ReviewScope.model_validate(data["scope"])
420
+
421
+
422
+ async def _scope_and_diff(
423
+ ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
424
+ ) -> tuple[ReviewScope, str, str, list[str]]:
425
+ base, how, head = await resolve_refs(ctx, instance)
426
+ files = target_files(ctx, graph, rule, instance)
427
+ diff = await full_diff(ctx, base, files)
428
+ target = instance.params.get("block") or instance.instance_id
429
+ scope = ReviewScope(target=target, base=base, head=head, hunks=diff_hunks(diff))
430
+ return scope, diff, how, files
431
+
432
+
433
+ async def review_scope(
434
+ ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
435
+ ) -> ReviewScope:
436
+ """The scope of a review task as it is now (when `get_context` kept none)."""
437
+ scope, _, _, _ = await _scope_and_diff(ctx, graph, rule, instance)
438
+ return scope
439
+
440
+
441
+ async def build_review(
442
+ ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
443
+ ) -> tuple[ReviewScope, dict[str, Any]]:
444
+ """The scope of a review task and the `review` section of its context."""
445
+ scope, diff, how, files = await _scope_and_diff(ctx, graph, rule, instance)
446
+ base, head = scope.base, scope.head
447
+ block = instance.params.get("block")
448
+ text, omitted = capped(annotate(diff))
449
+ note = (
450
+ "Each hunk line starts with its line number in the new file (blank for a removed "
451
+ "line); comment on those numbers. The diff is the base vs the working tree."
452
+ )
453
+ if omitted:
454
+ note += (
455
+ f" The diff was cut at {DIFF_CAP} characters; not shown (read them with your "
456
+ f"read tools): {', '.join(omitted)}."
457
+ )
458
+ if not scope.hunks:
459
+ note += " The diff is empty: nothing of this target changed since the base."
460
+ view: dict[str, Any] = {
461
+ "target": scope.target,
462
+ "base": base,
463
+ "base_from": how,
464
+ "head": head,
465
+ "files": files,
466
+ "changed": list(scope.files),
467
+ "diff": text,
468
+ "truncated": bool(omitted),
469
+ "omitted": omitted,
470
+ "note": note,
471
+ "spec": spec_slice(ctx, block),
472
+ "model": model_slice(ctx, block),
473
+ "reply_schema": ReviewReport.model_json_schema(),
474
+ }
475
+ return scope, view
476
+
477
+
478
+ __all__ = [
479
+ "DIFF_CAP",
480
+ "EMPTY_TREE",
481
+ "annotate",
482
+ "build_review",
483
+ "capped",
484
+ "full_diff",
485
+ "is_review_role",
486
+ "load_scope",
487
+ "resolve_refs",
488
+ "review_scope",
489
+ "save_scope",
490
+ "spec_slice",
491
+ "target_files",
492
+ ]
@@ -0,0 +1,192 @@
1
+ """`ClaudeCodeRuntime`: the `AgentRuntime` for `runtime: claude-code` (DESIGN.md 5.5, D35).
2
+
3
+ chipgraph never calls a model in this runtime. `run_task` only puts the task in the
4
+ queue (`chipgraph.core.runtime.TaskQueue`) and reports where it is:
5
+
6
+ - accepted, and its outputs still hash as accepted -> `done`;
7
+ - `budget_exhausted` -> `budget_exhausted`, with the last rejection as the message;
8
+ - `needs_human` -> `needs_human`, with the agent's (or the engine's) open questions;
9
+ - anything else -> `needs_human` with one question: run the plugin command in Claude
10
+ Code, which fetches the task with `next_task`, runs it in a role subagent, and hands
11
+ it back with `submit` (see `chipgraph.adapters.runtime.claude_code.service`).
12
+
13
+ So a `chipgraph build` stops at an agent rule like it stops at a `human` rule, and goes
14
+ on once the task is accepted.
15
+
16
+ `ClaudeCodeExecutor` is the build's executor for agent rules in this runtime. A task
17
+ whose loop stopped on a submit (its budget ran out, or the checks point at the spec:
18
+ `chipgraph.adapters.runtime.claude_code.loop`) fails with the label of its last failure
19
+ and the loop's `agent` payload, so HANDOFF.md says how many tries were used, why it
20
+ stopped and what failed last. It stays stopped until an input changes or
21
+ `chipgraph rewind` reopens it; a rewind also puts back the output files that existed
22
+ before the task, for a role whose outputs were moved aside (`previous`).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from chipgraph.adapters.runtime.claude_code.loop import (
28
+ MAX_INFRA_RETRIES,
29
+ max_dispatches,
30
+ stop_info,
31
+ stop_message,
32
+ )
33
+ from chipgraph.adapters.runtime.claude_code.previous import PreviousOutputs
34
+ from chipgraph.core.contracts import AgentResult, RuleInstance, RuleSpec
35
+ from chipgraph.core.engine.scheduler import ExecOutcome
36
+ from chipgraph.core.plugin_api.types import AgentTask
37
+ from chipgraph.core.runtime import (
38
+ ACTIVE_STATUSES,
39
+ DEFAULT_TOOL_CALL_CAP,
40
+ AgentRuntimeExecutor,
41
+ AgentTaskRecord,
42
+ TaskQueue,
43
+ instance_inputs_hash,
44
+ outcome_for,
45
+ task_for,
46
+ )
47
+ from chipgraph.core.state.artifacts import ArtifactStore
48
+
49
+ PLUGIN_NAME = "chipgraph"
50
+ """The Claude Code plugin's name: agents are `chipgraph:<role>`, commands `/chipgraph:<name>`."""
51
+
52
+ RUN_COMMAND = f"/{PLUGIN_NAME}:run"
53
+ """The plugin command that runs the task loop."""
54
+
55
+
56
+ def output_hashes(store: ArtifactStore, task: AgentTask) -> dict[str, str]:
57
+ """Current content hash of each of the task's outputs that exists, keyed by path."""
58
+ refs = [ref for ref in task.instance.outputs if ref.path is not None]
59
+ current = store.current_hashes(refs)
60
+ return {
61
+ ref.path: current[f"{ref.repo}:{ref.path}"]
62
+ for ref in refs
63
+ if ref.path is not None and f"{ref.repo}:{ref.path}" in current
64
+ }
65
+
66
+
67
+ def result_for(record: AgentTaskRecord) -> AgentResult:
68
+ """What the queue record means for the build, as an `AgentResult`."""
69
+ if record.status == "accepted":
70
+ return AgentResult(status="done", files_written=record.allowed_writes)
71
+ if record.status == "budget_exhausted":
72
+ last = "; ".join(record.reasons) or "no reason recorded"
73
+ return AgentResult(
74
+ status="budget_exhausted",
75
+ open_questions=(
76
+ f"agent task {record.task_id!r} used all {record.tries} tries "
77
+ f"(last rejection: {last}); fix its inputs or raise budget.tries, "
78
+ "then build again (see HANDOFF.md)",
79
+ ),
80
+ )
81
+ if record.status == "needs_human":
82
+ questions = record.last_result.open_questions if record.last_result else ()
83
+ return AgentResult(
84
+ status="needs_human",
85
+ open_questions=questions or (f"agent task {record.task_id!r} needs a person",),
86
+ )
87
+ return AgentResult(
88
+ status="needs_human",
89
+ open_questions=(
90
+ f"waiting for an agent: run `{RUN_COMMAND}` in Claude Code "
91
+ f"(task {record.task_id!r} is {record.status})",
92
+ ),
93
+ )
94
+
95
+
96
+ class ClaudeCodeRuntime:
97
+ """Queues agent tasks for the user's Claude Code session; never calls a model."""
98
+
99
+ name = "claude-code"
100
+
101
+ def __init__(
102
+ self,
103
+ queue: TaskQueue,
104
+ store: ArtifactStore,
105
+ *,
106
+ tool_call_cap: int = DEFAULT_TOOL_CALL_CAP,
107
+ max_infra_retries: int = MAX_INFRA_RETRIES,
108
+ ) -> None:
109
+ self.queue = queue
110
+ self.store = store
111
+ self.tool_call_cap = tool_call_cap
112
+ self.max_infra_retries = max_infra_retries
113
+
114
+ def enqueue(self, task: AgentTask) -> AgentTaskRecord:
115
+ """Queue `task` (idempotent, see `TaskQueue.enqueue`) and return its record."""
116
+ return self.queue.enqueue(
117
+ task,
118
+ inputs_hash=instance_inputs_hash(self.store, task.instance),
119
+ output_hashes=output_hashes(self.store, task),
120
+ tool_call_cap=self.tool_call_cap,
121
+ max_dispatches=max_dispatches(task.budget.tries, self.max_infra_retries),
122
+ )
123
+
124
+ async def run_task(self, task: AgentTask) -> AgentResult:
125
+ return result_for(self.enqueue(task))
126
+
127
+
128
+ def stopped_outcome(record: AgentTaskRecord) -> ExecOutcome | None:
129
+ """The outcome of a task whose loop stopped on a submit, or `None` if it did not."""
130
+ state = record.budget_state
131
+ if state is None or state.stop is None or state.label is None:
132
+ return None
133
+ if record.status not in ("budget_exhausted", "needs_human"):
134
+ return None
135
+ info = stop_info(record, stopped_earlier=True)
136
+ message = (
137
+ f"{stop_message(info)} (stopped in an earlier run; see HANDOFF.md; change an "
138
+ f"input or run `chipgraph rewind {record.task_id}` to try again)"
139
+ )
140
+ payload: dict[str, object] = {"agent": info}
141
+ questions = record.last_result.open_questions if record.last_result else ()
142
+ if questions:
143
+ payload["open_questions"] = list(questions)
144
+ return ExecOutcome(ok=False, failure_label=state.label, message=message, payload=payload)
145
+
146
+
147
+ class ClaudeCodeExecutor(AgentRuntimeExecutor):
148
+ """The executor for `kind: agent` rules in runtime claude-code.
149
+
150
+ Like `AgentRuntimeExecutor` over a `ClaudeCodeRuntime`, except that a task whose
151
+ loop stopped reports its label and the loop's `agent` payload (`stopped_outcome`),
152
+ and that `Scheduler.rewind` reopens a stopped task with a fresh budget (`forget`).
153
+ """
154
+
155
+ def __init__(self, runtime: ClaudeCodeRuntime) -> None:
156
+ super().__init__(runtime)
157
+ self.cc_runtime = runtime
158
+
159
+ async def execute(self, rule: RuleSpec, instance: RuleInstance) -> ExecOutcome:
160
+ try:
161
+ task = task_for(rule, instance)
162
+ except ValueError as exc:
163
+ return ExecOutcome(ok=False, failure_label="planning", message=str(exc))
164
+ record = self.cc_runtime.enqueue(task)
165
+ stopped = stopped_outcome(record)
166
+ if stopped is not None:
167
+ return stopped
168
+ return outcome_for(result_for(record), task_id=instance.instance_id)
169
+
170
+ def forget(self, instance_id: str) -> None:
171
+ """Reopen a stopped task (`chipgraph rewind`): a person said to try again.
172
+
173
+ A task no agent is working on gets back every output file that existed before
174
+ its budget cycle (`previous`); one that is dispatched or being checked is left
175
+ to finish its attempt.
176
+ """
177
+ queue = self.cc_runtime.queue
178
+ record = queue.get(instance_id)
179
+ if record is not None and record.status not in ACTIVE_STATUSES:
180
+ PreviousOutputs(queue, instance_id).restore(dispatch=record.dispatches)
181
+ queue.reopen(instance_id)
182
+
183
+
184
+ __all__ = [
185
+ "PLUGIN_NAME",
186
+ "RUN_COMMAND",
187
+ "ClaudeCodeExecutor",
188
+ "ClaudeCodeRuntime",
189
+ "output_hashes",
190
+ "result_for",
191
+ "stopped_outcome",
192
+ ]