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,1688 @@
1
+ """chipgraph CLI (task M0-12): commands that run a project's build graph.
2
+
3
+ A command is small on purpose: it parses options, builds an `AppContext` (and, when
4
+ needed, a `Scheduler` via `chipgraph.app.build.make_scheduler`), and prints the result.
5
+ All the wiring lives in `chipgraph.app` (DESIGN.md 3.3, 10.1, 10.4).
6
+
7
+ Exit codes: 0 ok; 1 a check/run failed or is waiting; 2 usage/config error.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import functools
14
+ import json
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from collections import Counter
20
+ from collections.abc import Callable, Mapping
21
+ from pathlib import Path
22
+ from typing import Annotated, Literal
23
+
24
+ import typer
25
+ import yaml
26
+
27
+ from chipgraph import __version__
28
+ from chipgraph.app import findings as findings_app
29
+ from chipgraph.app.baseline import (
30
+ current_branch,
31
+ main_branch,
32
+ plan_project_baseline,
33
+ run_baseline,
34
+ )
35
+ from chipgraph.app.build import make_scheduler, project_graph
36
+ from chipgraph.app.checks import ProfileCheckRunner
37
+ from chipgraph.app.context import AppContext, default_identity, find_repo_root
38
+ from chipgraph.app.errors import AppError
39
+ from chipgraph.core.config.errors import ConfigError
40
+ from chipgraph.core.config.loader import ConfigIssue
41
+ from chipgraph.core.contracts import ArtifactRef, CheckResult, Finding, RuleInstance
42
+ from chipgraph.core.engine import gate as gate_mod
43
+ from chipgraph.core.engine.graph import BuildGraph, GraphError
44
+ from chipgraph.core.engine.scheduler import RunSummary
45
+ from chipgraph.core.plugin_api.local import LocalPluginRecord
46
+ from chipgraph.core.state import journal as journal_mod
47
+ from chipgraph.core.state import trace as trace_mod
48
+ from chipgraph.core.state.findings import FindingStore, effective_status, waiver_gate_id
49
+ from chipgraph.core.state.layout import StateLayout
50
+ from chipgraph.core.state.trace import Tracer
51
+
52
+ TraceExporter = Literal["none", "console", "otlp"]
53
+
54
+ app = typer.Typer(no_args_is_help=True)
55
+ config_app = typer.Typer(no_args_is_help=True, help="Inspect the resolved profile.")
56
+ app.add_typer(config_app, name="config")
57
+
58
+
59
+ class CliState:
60
+ """The global options every command reads: `--profile`, `--json`, `-C/--dir`, `--trace`."""
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ profile_path: Path | None,
66
+ json_output: bool,
67
+ start: Path,
68
+ trace: TraceExporter,
69
+ ) -> None:
70
+ self.profile_path = profile_path
71
+ self.json_output = json_output
72
+ self.start = start
73
+ self.trace = trace
74
+
75
+
76
+ def _load_ctx(state: CliState) -> AppContext:
77
+ return AppContext.load(state.start, profile_path=state.profile_path)
78
+
79
+
80
+ def _handle_errors[F: Callable[..., None]](func: F) -> F:
81
+ """Catch `AppError`/`ConfigError`, print the message, and exit 2."""
82
+
83
+ @functools.wraps(func)
84
+ def wrapper(*args: object, **kwargs: object) -> None:
85
+ try:
86
+ func(*args, **kwargs)
87
+ except (AppError, ConfigError) as exc:
88
+ typer.echo(str(exc), err=True)
89
+ raise typer.Exit(code=2) from exc
90
+
91
+ return wrapper # type: ignore[return-value]
92
+
93
+
94
+ def _version_callback(value: bool) -> None:
95
+ if value:
96
+ typer.echo(f"chipgraph {__version__}")
97
+ raise typer.Exit(code=0)
98
+
99
+
100
+ def _default_trace() -> TraceExporter:
101
+ """`$CHIPGRAPH_TRACE`, if it names a known exporter, else `"none"`."""
102
+ value = os.environ.get("CHIPGRAPH_TRACE", "none").strip().lower()
103
+ if value in ("none", "console", "otlp"):
104
+ return value # type: ignore[return-value]
105
+ return "none"
106
+
107
+
108
+ @app.callback(invoke_without_command=True)
109
+ def main(
110
+ ctx: typer.Context,
111
+ version: Annotated[
112
+ bool,
113
+ typer.Option(
114
+ "--version",
115
+ callback=_version_callback,
116
+ is_eager=True,
117
+ help="Show the chipgraph version and exit.",
118
+ ),
119
+ ] = False,
120
+ profile: Annotated[
121
+ Path | None,
122
+ typer.Option("--profile", help="Use this profile file instead of discovering one."),
123
+ ] = None,
124
+ json_output: Annotated[
125
+ bool, typer.Option("--json", help="Print machine-readable JSON instead of text.")
126
+ ] = False,
127
+ directory: Annotated[
128
+ Path | None,
129
+ typer.Option("-C", "--dir", help="Start looking for a profile from this directory."),
130
+ ] = None,
131
+ trace: Annotated[
132
+ TraceExporter | None,
133
+ typer.Option(
134
+ "--trace",
135
+ help="Export OpenTelemetry spans for build/resume ('none'|'console'|'otlp'); "
136
+ "default from $CHIPGRAPH_TRACE, else 'none'.",
137
+ ),
138
+ ] = None,
139
+ ) -> None:
140
+ """chipgraph: an AI agent system for chip (IC) design, built on a build graph."""
141
+ start = directory if directory is not None else Path.cwd()
142
+ ctx.obj = CliState(
143
+ profile_path=profile,
144
+ json_output=json_output,
145
+ start=start,
146
+ trace=trace if trace is not None else _default_trace(),
147
+ )
148
+
149
+
150
+ # --- init --------------------------------------------------------------------------
151
+
152
+
153
+ def _init_content(project: str, preset: str | None) -> str:
154
+ lines = [
155
+ "# chipgraph project profile (see DESIGN.md 8.3-8.6)",
156
+ "# generated by `chipgraph init`; edit freely.",
157
+ f"project: {project}",
158
+ ]
159
+ if preset:
160
+ lines.append(f'extends: ["preset:{preset}"]')
161
+ lines += [
162
+ "packs: []",
163
+ "adapters: {}",
164
+ "# adapters:",
165
+ "# lint:",
166
+ "# use: cmd",
167
+ '# cmd: "make lint BLOCK={block}"',
168
+ "# parser: verilator",
169
+ "blocks: {}",
170
+ "",
171
+ ]
172
+ return "\n".join(lines) + "\n"
173
+
174
+
175
+ @app.command()
176
+ @_handle_errors
177
+ def init(
178
+ ctx: typer.Context,
179
+ project: Annotated[str | None, typer.Option("--project", help="Project name.")] = None,
180
+ preset: Annotated[
181
+ str | None, typer.Option("--preset", help="Extend an org/preset profile.")
182
+ ] = None,
183
+ force: Annotated[
184
+ bool, typer.Option("--force", help="Overwrite an existing .chipgraph.yml.")
185
+ ] = False,
186
+ from_learn: Annotated[
187
+ bool,
188
+ typer.Option(
189
+ "--from-learn",
190
+ help="Infer the profile from this repo (like `chipgraph learn`) instead of a stub.",
191
+ ),
192
+ ] = False,
193
+ ) -> None:
194
+ """Write a `.chipgraph.yml` at the repo root (a stub, or inferred with `--from-learn`)."""
195
+ state: CliState = ctx.obj
196
+ root = find_repo_root(state.start)
197
+ path = root / ".chipgraph.yml"
198
+ if path.is_file() and not force:
199
+ typer.echo(f"{path} already exists; pass --force to overwrite", err=True)
200
+ raise typer.Exit(code=2)
201
+
202
+ if from_learn:
203
+ from chipgraph.learn import InitFromLearnError, init_from_learn
204
+
205
+ if path.is_file() and force:
206
+ path.unlink()
207
+ try:
208
+ profile_path, naming_path = init_from_learn(root)
209
+ except InitFromLearnError as exc:
210
+ typer.echo(str(exc), err=True)
211
+ raise typer.Exit(code=2) from exc
212
+ typer.echo(f"wrote {profile_path} (inferred from {root})")
213
+ if naming_path is not None:
214
+ typer.echo(f"wrote {naming_path}")
215
+ typer.echo("review it, then:")
216
+ typer.echo(" chipgraph config check # validate the profile")
217
+ return
218
+
219
+ name = project or root.name
220
+ path.write_text(_init_content(name, preset), encoding="utf-8")
221
+ typer.echo(f"wrote {path}")
222
+ typer.echo("next steps:")
223
+ typer.echo(" chipgraph config check # validate the profile")
224
+ typer.echo(" chipgraph doctor # check tools are on PATH")
225
+ typer.echo(" chipgraph learn # fill this in from an existing repo (task M1-21)")
226
+
227
+
228
+ # --- learn / try --------------------------------------------------------------------
229
+
230
+
231
+ def _learn_json(result: object) -> str:
232
+ from chipgraph.learn import LearnResult
233
+
234
+ assert isinstance(result, LearnResult)
235
+ return json.dumps(result.model_dump(mode="json"), indent=2)
236
+
237
+
238
+ def _print_learn_text(result: object) -> None:
239
+ from chipgraph.learn import LearnResult
240
+
241
+ assert isinstance(result, LearnResult)
242
+ typer.echo(f"# learned {result.project} from {result.root}")
243
+ typer.echo(f"# blocks: {', '.join(result.blocks) or '(none, single block)'}")
244
+ typer.echo("")
245
+ typer.echo("layout (kind: template coverage):")
246
+ for lrule in result.layout:
247
+ typer.echo(f" {lrule.kind:9} {lrule.template:34} {lrule.coverage.percent:3}%")
248
+ typer.echo("naming (kind: pattern coverage emitted):")
249
+ for nrule in result.naming:
250
+ mark = "rule" if nrule.emitted else "obs "
251
+ typer.echo(
252
+ f" {mark} {nrule.kind:11} {nrule.pattern:30} {nrule.coverage.percent:3}% "
253
+ f"({nrule.coverage.matched}/{nrule.coverage.total})"
254
+ )
255
+ if result.observations:
256
+ typer.echo("observations:")
257
+ for obs in result.observations:
258
+ typer.echo(f" {obs.topic:9} {obs.summary}")
259
+ if result.vendor_paths:
260
+ typer.echo("vendor paths (checks off, learned):")
261
+ for glob in result.vendor_paths:
262
+ typer.echo(f" {glob}")
263
+
264
+
265
+ @app.command()
266
+ @_handle_errors
267
+ def learn(
268
+ ctx: typer.Context,
269
+ path: Annotated[
270
+ Path | None, typer.Argument(help="Repo to learn (default: the current directory).")
271
+ ] = None,
272
+ out: Annotated[
273
+ Path | None,
274
+ typer.Option("--out", help="Write chipgraph.draft.yml (+ naming rules) into this dir."),
275
+ ] = None,
276
+ threshold: Annotated[
277
+ float,
278
+ typer.Option("--threshold", min=0.0, max=1.0, help="Min coverage to emit a naming rule."),
279
+ ] = 0.9,
280
+ ) -> None:
281
+ """Infer a draft profile from an existing repo, with per-rule coverage (DESIGN 8.6 V1)."""
282
+ from chipgraph.learn import dump_profile_yaml, write_draft
283
+ from chipgraph.learn import learn as run_learn
284
+ from chipgraph.learn.draft import DRAFT_NAMING_RULES_NAME, build_naming_rules
285
+
286
+ state: CliState = ctx.obj
287
+ root = (path if path is not None else state.start).resolve()
288
+ if not root.is_dir():
289
+ typer.echo(f"not a directory: {root}", err=True)
290
+ raise typer.Exit(code=2)
291
+
292
+ result = run_learn(root, threshold=threshold)
293
+
294
+ if state.json_output:
295
+ typer.echo(_learn_json(result))
296
+ else:
297
+ _print_learn_text(result)
298
+ naming_ref = DRAFT_NAMING_RULES_NAME if build_naming_rules(result) is not None else None
299
+ typer.echo("")
300
+ typer.echo("draft profile:")
301
+ typer.echo(dump_profile_yaml(result, naming_rules_ref=naming_ref))
302
+
303
+ if out is not None:
304
+ profile_path, naming_path = write_draft(result, out)
305
+ typer.echo(f"wrote {profile_path}")
306
+ if naming_path is not None:
307
+ typer.echo(f"wrote {naming_path}")
308
+
309
+
310
+ @app.command("try")
311
+ @_handle_errors
312
+ def try_cmd(
313
+ ctx: typer.Context,
314
+ path: Annotated[
315
+ Path | None, typer.Argument(help="Repo to try (default: the current directory).")
316
+ ] = None,
317
+ profile: Annotated[
318
+ Path | None,
319
+ typer.Option("--profile", help="Use this draft profile (from `learn --out`) instead."),
320
+ ] = None,
321
+ threshold: Annotated[
322
+ float,
323
+ typer.Option("--threshold", min=0.0, max=1.0, help="Min coverage to emit a naming rule."),
324
+ ] = 0.9,
325
+ ) -> None:
326
+ """Run chipgraph read-only against a repo (ingest/check/audit), writing nothing into it."""
327
+ from chipgraph.learn.try_run import try_run
328
+
329
+ state: CliState = ctx.obj
330
+ root = (path if path is not None else state.start).resolve()
331
+ if not root.is_dir():
332
+ typer.echo(f"not a directory: {root}", err=True)
333
+ raise typer.Exit(code=2)
334
+
335
+ report = try_run(root, profile_path=profile, threshold=threshold)
336
+
337
+ if state.json_output:
338
+ payload = {
339
+ "project": report.result.project,
340
+ "ingest_ok": report.ingest_ok,
341
+ "ingest": report.ingest_summary,
342
+ "audit_available": report.audit_available,
343
+ "audit": report.audit_summary,
344
+ "files_with_issues": report.check_files_with_issues,
345
+ "checks": [
346
+ {"check": cid, "block": blk, "status": res.status, "issues": len(res.issues)}
347
+ for cid, blk, res in report.checks
348
+ ],
349
+ }
350
+ typer.echo(json.dumps(payload, indent=2))
351
+ else:
352
+ typer.echo(f"try {report.result.project} (read-only; nothing written to the repo)")
353
+ typer.echo(f" ingest: {report.ingest_summary}")
354
+ for cid, blk, res in report.checks:
355
+ typer.echo(f" {res.status.upper():6} {cid} {blk or '-'} {len(res.issues)} issues")
356
+ typer.echo(f" {report.audit_summary}")
357
+ typer.echo(f"files with issues: {report.check_files_with_issues}")
358
+
359
+
360
+ # --- config show / config check -----------------------------------------------------
361
+
362
+
363
+ @config_app.command("show")
364
+ @_handle_errors
365
+ def config_show(
366
+ ctx: typer.Context,
367
+ explain: Annotated[
368
+ bool, typer.Option("--explain", help="One line per leaf key, with its source.")
369
+ ] = False,
370
+ ) -> None:
371
+ """Print the merged profile."""
372
+ state: CliState = ctx.obj
373
+ app_ctx = _load_ctx(state)
374
+ resolved = app_ctx.require_profile()
375
+ if explain:
376
+ for key, value, source in resolved.explain():
377
+ typer.echo(f"{key} = {value!r} ({source.kind}:{source.location})")
378
+ return
379
+ typer.echo(yaml.safe_dump(resolved.profile.model_dump(mode="json"), sort_keys=True))
380
+
381
+
382
+ @config_app.command("check")
383
+ @_handle_errors
384
+ def config_check(ctx: typer.Context) -> None:
385
+ """Print consistency issues in the resolved profile."""
386
+ state: CliState = ctx.obj
387
+ app_ctx = _load_ctx(state)
388
+ resolved = app_ctx.require_profile()
389
+ # Drop the generic per-plugin info line from `check()`: the richer lines built from
390
+ # what actually loaded (below) replace it, keeping the "runs code" warning wording.
391
+ issues = [i for i in resolved.check() if not i.key.startswith("plugins[")]
392
+ plugins = [_plugin_issue(index, rec) for index, rec in enumerate(app_ctx.local_plugins)]
393
+
394
+ if state.json_output:
395
+ payload = [
396
+ {"severity": i.severity, "key": i.key, "message": i.message}
397
+ for i in (*issues, *plugins)
398
+ ]
399
+ typer.echo(json.dumps(payload, indent=2))
400
+ else:
401
+ if not issues and not plugins:
402
+ typer.echo("no issues")
403
+ for issue in (*issues, *plugins):
404
+ typer.echo(f"{issue.severity:7} {issue.key}: {issue.message}")
405
+ if any(issue.severity == "error" for issue in (*issues, *plugins)):
406
+ raise typer.Exit(code=1)
407
+
408
+
409
+ def _plugin_issue(index: int, record: LocalPluginRecord) -> ConfigIssue:
410
+ """One `config check` line per local plugin: loaded (with hash + registrations) or not."""
411
+ key = f"plugins[{index}]"
412
+ if not record.loaded:
413
+ return ConfigIssue(
414
+ severity="warning",
415
+ key=key,
416
+ message=f"{record.entry}: not loaded: {record.disabled_reason}",
417
+ )
418
+ registered = ", ".join(f"{kind}/{name}" for kind, name in record.registered) or "nothing"
419
+ return ConfigIssue(
420
+ severity="info",
421
+ key=key,
422
+ message=(
423
+ f"{record.path} (sha256 {(record.sha256 or '')[:8]}) registered {registered} "
424
+ "(runs code: review it like code)"
425
+ ),
426
+ )
427
+
428
+
429
+ # --- check -------------------------------------------------------------------------
430
+
431
+
432
+ def _fake_instance(check_id: str, block: str | None) -> RuleInstance:
433
+ """A throwaway `RuleInstance` carrying just the params `ProfileCheckRunner` reads."""
434
+ params = {"block": block} if block is not None else {}
435
+ rule_id = "chipgraph/check"
436
+ safe = "".join(c if c.isalnum() else "_" for c in check_id)
437
+ output_path = f".chipgraph/tmp/check-{safe}-{block or 'all'}.json"
438
+ return RuleInstance(
439
+ rule_id=rule_id,
440
+ params=params,
441
+ outputs=(ArtifactRef(kind="report", path=output_path),),
442
+ instance_id=RuleInstance.make_id(rule_id, params),
443
+ )
444
+
445
+
446
+ async def _run_checks(
447
+ runner: ProfileCheckRunner, combos: list[tuple[str, str | None]]
448
+ ) -> list[tuple[str, str | None, CheckResult]]:
449
+ results: list[tuple[str, str | None, CheckResult]] = []
450
+ for check_id, block in combos:
451
+ instance = _fake_instance(check_id, block)
452
+ result = await runner.run(check_id, instance)
453
+ results.append((check_id, block, result))
454
+ return results
455
+
456
+
457
+ @app.command()
458
+ @_handle_errors
459
+ def check(
460
+ ctx: typer.Context,
461
+ block: Annotated[
462
+ list[str], typer.Option("--block", help="Run only for this block (repeatable).")
463
+ ] = [], # noqa: B006
464
+ only: Annotated[
465
+ list[str], typer.Option("--only", help="Run only this check id (repeatable).")
466
+ ] = [], # noqa: B006
467
+ ) -> None:
468
+ """Run checks directly against the current working tree, without the build graph."""
469
+ state: CliState = ctx.obj
470
+ app_ctx = _load_ctx(state)
471
+ resolved = app_ctx.require_profile()
472
+
473
+ check_ids = list(only) if only else sorted(resolved.profile.adapters)
474
+ raw_blocks = list(block) if block else sorted(resolved.profile.blocks)
475
+ blocks: list[str | None] = list(raw_blocks) if raw_blocks else [None]
476
+
477
+ runner = ProfileCheckRunner(app_ctx)
478
+ # A chip-wide check (per_block = False) runs once for the whole project, unless the
479
+ # user scoped the run with --block; running it per block would repeat its issues and
480
+ # drop those that belong to no block.
481
+ combos = [
482
+ (cid, b)
483
+ for cid in check_ids
484
+ for b in (blocks if block or runner.runs_per_block(cid) else [None])
485
+ ]
486
+ results = asyncio.run(_run_checks(runner, combos))
487
+
488
+ if state.json_output:
489
+ typer.echo(json.dumps([r.model_dump(mode="json") for _, _, r in results], indent=2))
490
+ else:
491
+ for check_id, blk, result in results:
492
+ status = result.status.upper()
493
+ typer.echo(f"{status:6} {check_id} {blk or '-'} {len(result.issues)} issues")
494
+ for issue in result.issues:
495
+ loc = f"{issue.file or '-'}:{issue.line or '-'}"
496
+ typer.echo(f" {loc} [{issue.rule}] {issue.msg}")
497
+
498
+ if any(not r.ok for _, _, r in results):
499
+ raise typer.Exit(code=1)
500
+
501
+
502
+ # --- audit --------------------------------------------------------------------------
503
+
504
+
505
+ def _print_audit_text(report: object) -> None:
506
+ from chipgraph.packs.assist.audit import AuditReport
507
+
508
+ assert isinstance(report, AuditReport)
509
+ if report.ingest.ran:
510
+ by_sev = report.ingest.by_severity
511
+ summary = ", ".join(f"{sev}={by_sev[sev]}" for sev in sorted(by_sev)) or "none"
512
+ typer.echo(f"ingest issues: {summary}")
513
+ for message in report.ingest.errors:
514
+ typer.echo(f" error {message}")
515
+
516
+ typer.echo("checks:")
517
+ for check in report.checks:
518
+ typer.echo(
519
+ f" {check.status.upper():6} {check.check_id} {check.block or '-'} "
520
+ f"L{check.layer} {check.issues} issues"
521
+ )
522
+
523
+ if not report.layers:
524
+ typer.echo("findings: none")
525
+ for layer in report.layers:
526
+ sev = ", ".join(f"{s}={layer.by_severity[s]}" for s in sorted(layer.by_severity))
527
+ st = ", ".join(f"{s}={layer.by_status[s]}" for s in sorted(layer.by_status))
528
+ typer.echo(f"layer {layer.layer}: {sev} [{st}]")
529
+ for finding in layer.findings:
530
+ mark = "BLOCK" if finding.blocking else " "
531
+ typer.echo(
532
+ f" {mark} {finding.id} {finding.severity:9}{finding.status:10}"
533
+ f"{finding.source:16}{finding.evidence:40}{finding.claim}"
534
+ )
535
+ typer.echo(report.summary_line())
536
+
537
+
538
+ @app.command()
539
+ @_handle_errors
540
+ def audit(
541
+ ctx: typer.Context,
542
+ block: Annotated[
543
+ list[str], typer.Option("--block", help="Audit only this block (repeatable).")
544
+ ] = [], # noqa: B006
545
+ no_ingest: Annotated[
546
+ bool, typer.Option("--no-ingest", help="Skip the ingest step (reuse the model cache).")
547
+ ] = False,
548
+ strict: Annotated[
549
+ bool, typer.Option("--strict", help="Exit 1 if any blocking finding exists.")
550
+ ] = False,
551
+ ) -> None:
552
+ """Run every deterministic check over the whole project, grouped by layer (DESIGN 4.8)."""
553
+ from chipgraph.packs.assist.audit import run_audit
554
+
555
+ state: CliState = ctx.obj
556
+ app_ctx = _load_ctx(state)
557
+ app_ctx.require_profile()
558
+ report = run_audit(app_ctx, ingest=not no_ingest, blocks=list(block) if block else None)
559
+
560
+ if state.json_output:
561
+ typer.echo(json.dumps(report.model_dump(mode="json"), indent=2))
562
+ else:
563
+ _print_audit_text(report)
564
+
565
+ if strict and report.has_blocking:
566
+ raise typer.Exit(code=1)
567
+
568
+
569
+ # --- ingest -------------------------------------------------------------------------
570
+
571
+
572
+ @app.command()
573
+ @_handle_errors
574
+ def ingest(
575
+ ctx: typer.Context,
576
+ strict: Annotated[
577
+ bool, typer.Option("--strict", help="Exit 1 if any error-severity issue was found.")
578
+ ] = False,
579
+ ) -> None:
580
+ """Run every extractor, build the Design Model, and print stats."""
581
+ from chipgraph.app.ingest import run_ingest
582
+
583
+ state: CliState = ctx.obj
584
+ app_ctx = _load_ctx(state)
585
+ app_ctx.require_profile()
586
+ report = run_ingest(app_ctx)
587
+ stats = report.result.stats
588
+
589
+ if state.json_output:
590
+ payload = {
591
+ "db": str(report.db_path),
592
+ "build_inputs_hash": report.result.build_inputs_hash,
593
+ "stats": stats.model_dump(mode="json"),
594
+ "issues": [i.model_dump(mode="json") for i in report.issues],
595
+ }
596
+ typer.echo(json.dumps(payload, indent=2))
597
+ else:
598
+ _print_ingest_text(stats, report)
599
+
600
+ if strict and report.has_error:
601
+ raise typer.Exit(code=1)
602
+
603
+
604
+ def _print_ingest_text(stats: object, report: object) -> None:
605
+ from chipgraph.app.ingest import IngestReport
606
+ from chipgraph.core.model.ingest import IngestStats
607
+
608
+ assert isinstance(stats, IngestStats)
609
+ assert isinstance(report, IngestReport)
610
+ typer.echo(
611
+ f"inputs: {stats.inputs} entities: {stats.entities} relations: {stats.relations}"
612
+ )
613
+ if stats.entities_by_kind:
614
+ typer.echo("entities by kind:")
615
+ for kind, count in sorted(stats.entities_by_kind.items()):
616
+ typer.echo(f" {count:5} {kind}")
617
+ if stats.entities_by_block:
618
+ typer.echo("entities by block:")
619
+ for block, count in sorted(stats.entities_by_block.items()):
620
+ typer.echo(f" {count:5} {block or '(none)'}")
621
+ by_sev = stats.diagnostics_by_severity
622
+ if by_sev:
623
+ summary = ", ".join(f"{sev}={by_sev[sev]}" for sev in sorted(by_sev))
624
+ typer.echo(f"issues: {summary} conflicts: {stats.conflicts}")
625
+ else:
626
+ typer.echo(f"issues: none conflicts: {stats.conflicts}")
627
+ if report.documents is not None:
628
+ nda = len(report.documents.nda_skipped)
629
+ typer.echo(
630
+ f"documents indexed for /ask: {len(report.documents.indexed)} "
631
+ f"({report.documents.lines} lines; {nda} nda file(s) not indexed)"
632
+ )
633
+ shown = [i for i in report.issues if i.severity in ("warning", "error")][:20]
634
+ for issue in shown:
635
+ loc = f"{issue.file or '-'}:{issue.line or '-'}"
636
+ typer.echo(f" {issue.severity:7} [{issue.code}] {loc} {issue.message}")
637
+ typer.echo(f"db: {report.db_path}")
638
+
639
+
640
+ # --- build / resume / rewind --------------------------------------------------------
641
+
642
+
643
+ def _handoff_path(app_ctx: AppContext, run_id: str) -> Path:
644
+ return app_ctx.layout.run_dir(run_id) / "HANDOFF.md"
645
+
646
+
647
+ def _print_summary(summary: RunSummary, json_output: bool, handoff_path: Path) -> None:
648
+ if json_output:
649
+ payload = summary.model_dump(mode="json")
650
+ payload["handoff"] = str(handoff_path)
651
+ typer.echo(json.dumps(payload, indent=2))
652
+ return
653
+ typer.echo(f"run {summary.run_id}")
654
+ for label, ids in (
655
+ ("done", summary.done),
656
+ ("skipped (fresh)", summary.skipped_fresh),
657
+ ("failed", summary.failed),
658
+ ("waiting on a gate", summary.waiting_gate),
659
+ ("blocked", summary.blocked),
660
+ ):
661
+ if ids:
662
+ typer.echo(f" {label}: {', '.join(ids)}")
663
+ typer.echo(str(handoff_path))
664
+
665
+
666
+ def _build_tracer(state: CliState, *, offline: bool) -> Tracer:
667
+ """Build the tracer `build`/`resume` should use for this invocation."""
668
+ return trace_mod.configure(state.trace, offline=offline)
669
+
670
+
671
+ @app.command()
672
+ @_handle_errors
673
+ def build(
674
+ ctx: typer.Context,
675
+ target: Annotated[
676
+ str, typer.Argument(help="Rule id, 'id[k=v]', a named target ('rtl:timer'), or '*'.")
677
+ ],
678
+ concurrency: Annotated[int, typer.Option("--concurrency", min=1)] = 4,
679
+ ) -> None:
680
+ """Build TARGET with the scheduler."""
681
+ state: CliState = ctx.obj
682
+ app_ctx = _load_ctx(state)
683
+ resolved = app_ctx.require_profile()
684
+ tracer = _build_tracer(state, offline=resolved.profile.offline)
685
+ scheduler = make_scheduler(app_ctx, target, concurrency=concurrency, tracer=tracer)
686
+ try:
687
+ summary = asyncio.run(scheduler.run(target))
688
+ finally:
689
+ tracer.flush()
690
+ _print_summary(summary, state.json_output, _handoff_path(app_ctx, summary.run_id))
691
+ if not summary.ok:
692
+ raise typer.Exit(code=1)
693
+
694
+
695
+ @app.command()
696
+ @_handle_errors
697
+ def resume(ctx: typer.Context, run_id: Annotated[str, typer.Argument()]) -> None:
698
+ """Resume a run that was interrupted."""
699
+ state: CliState = ctx.obj
700
+ app_ctx = _load_ctx(state)
701
+ resolved = app_ctx.require_profile()
702
+ tracer = _build_tracer(state, offline=resolved.profile.offline)
703
+ scheduler = make_scheduler(app_ctx, "*", tracer=tracer)
704
+ if isinstance(scheduler.checks, ProfileCheckRunner):
705
+ scheduler.checks.run_id = run_id
706
+ try:
707
+ summary = asyncio.run(scheduler.resume(run_id))
708
+ finally:
709
+ tracer.flush()
710
+ _print_summary(summary, state.json_output, _handoff_path(app_ctx, summary.run_id))
711
+ if not summary.ok:
712
+ raise typer.Exit(code=1)
713
+
714
+
715
+ @app.command()
716
+ @_handle_errors
717
+ def rewind(ctx: typer.Context, instance_id: Annotated[str, typer.Argument()]) -> None:
718
+ """Delete a rule instance's record (and everything downstream), so it rebuilds."""
719
+ state: CliState = ctx.obj
720
+ app_ctx = _load_ctx(state)
721
+ app_ctx.require_profile()
722
+ scheduler = make_scheduler(app_ctx, "*")
723
+ ids = sorted(scheduler.rewind(instance_id))
724
+ if state.json_output:
725
+ typer.echo(json.dumps(ids))
726
+ else:
727
+ for iid in ids:
728
+ typer.echo(f"rewound {iid}")
729
+
730
+
731
+ # --- status --------------------------------------------------------------------------
732
+
733
+
734
+ def _latest_run_id(layout: StateLayout) -> str | None:
735
+ if not layout.runs_dir.is_dir():
736
+ return None
737
+ ids = sorted(p.name for p in layout.runs_dir.iterdir() if p.is_dir())
738
+ return ids[-1] if ids else None
739
+
740
+
741
+ @app.command()
742
+ @_handle_errors
743
+ def status(ctx: typer.Context, run_id: Annotated[str | None, typer.Argument()] = None) -> None:
744
+ """Show the status of the latest run, or a given one."""
745
+ state: CliState = ctx.obj
746
+ app_ctx = _load_ctx(state)
747
+ layout = app_ctx.layout
748
+ rid = run_id or _latest_run_id(layout)
749
+ if rid is None:
750
+ typer.echo("no runs found")
751
+ return
752
+
753
+ journal_read = journal_mod.read(layout.journal(rid))
754
+ run_state = journal_mod.replay(journal_read.events)
755
+ gate_of: dict[str, str] = {}
756
+ for event in journal_read.events:
757
+ if event.type == "gate_wait" and event.rule_instance is not None:
758
+ gate_of[event.rule_instance] = str(event.payload.get("gate", ""))
759
+ waiting = {
760
+ iid: gate_of.get(iid, "") for iid, st in run_state.rules.items() if st == "waiting_gate"
761
+ }
762
+ counts = Counter(run_state.rules.values())
763
+
764
+ if state.json_output:
765
+ typer.echo(
766
+ json.dumps(
767
+ {
768
+ "run_id": rid,
769
+ "started": run_state.started,
770
+ "stopped": run_state.stopped,
771
+ "rules": run_state.rules,
772
+ "counts": dict(counts),
773
+ "waiting_gates": waiting,
774
+ "chain": _chain_rows(app_ctx, rid, run_state.rules),
775
+ },
776
+ indent=2,
777
+ )
778
+ )
779
+ return
780
+
781
+ typer.echo(f"run {rid} started={run_state.started} stopped={run_state.stopped}")
782
+ for iid, st in sorted(run_state.rules.items()):
783
+ typer.echo(f" {st:13} {iid}")
784
+ typer.echo("counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
785
+ if waiting:
786
+ typer.echo("waiting on gates:")
787
+ for iid, gate_id in sorted(waiting.items()):
788
+ typer.echo(f" {iid} -> {gate_id}")
789
+ chain = _target_chain(app_ctx, rid, run_state.rules)
790
+ if chain:
791
+ typer.echo(f"chain of {chain[0]}:")
792
+ typer.echo(chain[1])
793
+
794
+
795
+ def _chain_rows(app_ctx: AppContext, rid: str, states: Mapping[str, str]) -> list[dict[str, str]]:
796
+ """The instances of the run's named target, in build order, with their state.
797
+
798
+ Empty when the run's target is not a named target (`rtl:<block>`), or the graph
799
+ cannot be built now.
800
+ """
801
+ if app_ctx.resolved is None:
802
+ return []
803
+ try:
804
+ target = journal_mod.read_manifest(app_ctx.layout, rid).target
805
+ graph = project_graph(app_ctx)
806
+ if graph.targets is None or not graph.targets.is_target(target):
807
+ return []
808
+ selected = graph.select(target)
809
+ except (AppError, GraphError, OSError, ValueError):
810
+ return []
811
+ rows: list[dict[str, str]] = []
812
+ for iid in graph.topo_order():
813
+ if iid in selected:
814
+ rows.append({"instance": iid, "state": states.get(iid, "not run")})
815
+ return rows
816
+
817
+
818
+ def _target_chain(
819
+ app_ctx: AppContext, rid: str, states: Mapping[str, str]
820
+ ) -> tuple[str, str] | None:
821
+ rows = _chain_rows(app_ctx, rid, states)
822
+ if not rows:
823
+ return None
824
+ target = journal_mod.read_manifest(app_ctx.layout, rid).target
825
+ lines = [f" {i + 1:2}. {r['state']:13} {r['instance']}" for i, r in enumerate(rows)]
826
+ return target, "\n".join(lines)
827
+
828
+
829
+ # --- approve -------------------------------------------------------------------------
830
+
831
+
832
+ @app.command()
833
+ @_handle_errors
834
+ def approve(
835
+ ctx: typer.Context,
836
+ gate_id: Annotated[str, typer.Argument()],
837
+ instance: Annotated[
838
+ str | None,
839
+ typer.Option(
840
+ "--instance",
841
+ help="The rule instance id; optional when exactly one instance has the gate.",
842
+ ),
843
+ ] = None,
844
+ reject: Annotated[bool, typer.Option("--reject", help="Reject instead of approve.")] = False,
845
+ by: Annotated[str | None, typer.Option("--by")] = None,
846
+ note: Annotated[str, typer.Option("--note")] = "",
847
+ ) -> None:
848
+ """Record an approve/reject decision for a gate over a rule instance's inputs."""
849
+ state: CliState = ctx.obj
850
+ app_ctx = _load_ctx(state)
851
+ app_ctx.require_profile()
852
+ scheduler = make_scheduler(app_ctx, "*")
853
+ if instance is None:
854
+ instance = _gated_instance(scheduler.graph, gate_id)
855
+ rule_instance = scheduler.graph.instances.get(instance)
856
+ if rule_instance is None:
857
+ raise AppError(
858
+ f"no such rule instance {instance!r}; known instances: "
859
+ f"{sorted(scheduler.graph.instances)}"
860
+ )
861
+ decision: Literal["approve", "reject"] = "reject" if reject else "approve"
862
+ who = by or default_identity()
863
+ try:
864
+ approval = gate_mod.approve(
865
+ app_ctx.review,
866
+ app_ctx.store,
867
+ gate_id,
868
+ rule_instance,
869
+ by=who,
870
+ decision=decision,
871
+ note=note,
872
+ )
873
+ except gate_mod.GateError as exc:
874
+ raise AppError(str(exc)) from exc
875
+ if state.json_output:
876
+ typer.echo(approval.model_dump_json())
877
+ else:
878
+ typer.echo(f"{decision} recorded for gate {gate_id!r} by {who!r}")
879
+
880
+
881
+ def _gated_instance(graph: BuildGraph, gate_id: str) -> str:
882
+ """The one instance whose rule's gate is `gate_id`; an `AppError` if not exactly one."""
883
+ found = sorted(
884
+ iid
885
+ for iid, inst in graph.instances.items()
886
+ if (rule := graph.rules[inst.rule_id]).gate
887
+ and _format_gate_id(rule.gate, inst.params) == gate_id
888
+ )
889
+ if len(found) != 1:
890
+ known = sorted(
891
+ {
892
+ _format_gate_id(rule.gate, inst.params)
893
+ for inst in graph.instances.values()
894
+ if (rule := graph.rules[inst.rule_id]).gate
895
+ }
896
+ )
897
+ what = "no instance has" if not found else f"{len(found)} instances have"
898
+ raise AppError(
899
+ f"{what} gate {gate_id!r}: pass --instance; gates: {', '.join(known) or 'none'}"
900
+ )
901
+ return found[0]
902
+
903
+
904
+ def _format_gate_id(template: str, params: dict[str, str]) -> str:
905
+ try:
906
+ return gate_mod.gate_id_for(template, params)
907
+ except gate_mod.GateError:
908
+ return template
909
+
910
+
911
+ # --- plan, rtl: the RTL target of a block --------------------------------------------
912
+
913
+
914
+ def _plan_show(state: CliState, block: str) -> None:
915
+ from chipgraph.packs.digital_rtl.plan.show import plan_summary, render_text
916
+
917
+ app_ctx = _load_ctx(state)
918
+ summary = plan_summary(app_ctx, block)
919
+ if state.json_output:
920
+ typer.echo(json.dumps(summary, indent=2))
921
+ else:
922
+ typer.echo(render_text(summary), nl=False)
923
+ if not summary["exists"] or not summary["ok"]:
924
+ raise typer.Exit(code=1)
925
+
926
+
927
+ def _build_target(state: CliState, target: str, concurrency: int) -> None:
928
+ """Build a named target, print the run and what is next; exit like `build`."""
929
+ from chipgraph.cli.next_steps import next_steps, write_chain
930
+
931
+ app_ctx = _load_ctx(state)
932
+ resolved = app_ctx.require_profile()
933
+ tracer = _build_tracer(state, offline=resolved.profile.offline)
934
+ scheduler = make_scheduler(app_ctx, target, concurrency=concurrency, tracer=tracer)
935
+ try:
936
+ summary = asyncio.run(scheduler.run(target))
937
+ finally:
938
+ tracer.flush()
939
+ steps = next_steps(app_ctx, scheduler.graph, summary)
940
+ handoff = _handoff_path(app_ctx, summary.run_id)
941
+ steps["chain"] = [
942
+ {"instance": iid, "state": st}
943
+ for iid, st in write_chain(handoff, scheduler.graph, target, summary)
944
+ ]
945
+ if state.json_output:
946
+ payload = summary.model_dump(mode="json")
947
+ payload["handoff"] = str(handoff)
948
+ payload["target"] = target
949
+ payload["next"] = steps
950
+ typer.echo(json.dumps(payload, indent=2))
951
+ else:
952
+ _print_summary(summary, False, handoff)
953
+ typer.echo("next:")
954
+ for step in steps["lines"]:
955
+ typer.echo(f" {step}")
956
+ if not summary.ok:
957
+ raise typer.Exit(code=1)
958
+
959
+
960
+ @app.command("plan")
961
+ @_handle_errors
962
+ def plan_cmd(
963
+ ctx: typer.Context,
964
+ words: Annotated[
965
+ list[str],
966
+ typer.Argument(help="BLOCK: build its plan up to the gate; 'show BLOCK': read it."),
967
+ ],
968
+ concurrency: Annotated[int, typer.Option("--concurrency", min=1)] = 4,
969
+ ) -> None:
970
+ """Build a block's plan up to and including its gate `plan:<block>`, or show it.
971
+
972
+ `chipgraph plan <block>` builds target `plan:<block>` (the planner writes the plan,
973
+ `plan_check` checks it, the build waits on the gate) and prints what is next: the
974
+ gate to approve, or the tasks waiting for `/chipgraph:run`. Exit 0 when the gate is
975
+ approved, 1 while something waits or failed, 2 on a usage or config error.
976
+
977
+ `chipgraph plan show <block>` prints the plan for its approver: modules, REQ
978
+ coverage, write sets, questions; exit 1 when there is none yet or it fails
979
+ `plan_check`.
980
+ """
981
+ state: CliState = ctx.obj
982
+ if len(words) == 2 and words[0] == "show":
983
+ _plan_show(state, words[1])
984
+ return
985
+ if len(words) != 1:
986
+ raise AppError("usage: chipgraph plan <block> | chipgraph plan show <block>")
987
+ _build_target(state, f"plan:{words[0]}", concurrency)
988
+
989
+
990
+ @app.command("rtl")
991
+ @_handle_errors
992
+ def rtl_cmd(
993
+ ctx: typer.Context,
994
+ block: Annotated[str, typer.Argument(help="The block.")],
995
+ concurrency: Annotated[int, typer.Option("--concurrency", min=1)] = 4,
996
+ ) -> None:
997
+ """Build target `rtl:<block>`: plan, gate, RTL per module, testbench, review, PR, gate.
998
+
999
+ Prints what is next: the gate to approve (`plan:<block>`, then `pr:<block>` and the
1000
+ PR's URL), or the agent tasks waiting for `/chipgraph:run`. Exit 0 when the PR is
1001
+ approved, 1 while something waits or failed, 2 on a usage or config error.
1002
+ """
1003
+ _build_target(ctx.obj, f"rtl:{block}", concurrency)
1004
+
1005
+
1006
+ # --- baseline ------------------------------------------------------------------------
1007
+
1008
+
1009
+ def _baseline_plan_json(plan: object) -> dict[str, object]:
1010
+ from chipgraph.core.engine.baseline import BaselineArtifact, BaselineGate, BaselinePlan
1011
+
1012
+ assert isinstance(plan, BaselinePlan)
1013
+
1014
+ def _artifact(a: BaselineArtifact) -> dict[str, object]:
1015
+ commit = (
1016
+ {
1017
+ "short_sha": a.last_commit.short_sha,
1018
+ "date": a.last_commit.date,
1019
+ "subject": a.last_commit.subject,
1020
+ "is_merge": a.last_commit.is_merge,
1021
+ }
1022
+ if a.last_commit is not None
1023
+ else None
1024
+ )
1025
+ return {
1026
+ "path": a.path,
1027
+ "sha256": a.sha256,
1028
+ "short_sha256": a.short_sha256,
1029
+ "status": a.status,
1030
+ "baselineable": a.baselineable,
1031
+ "last_commit": commit,
1032
+ }
1033
+
1034
+ def _gate(g: BaselineGate) -> dict[str, object]:
1035
+ return {
1036
+ "gate_id": g.gate_id,
1037
+ "instance_id": g.instance_id,
1038
+ "already_decided": g.already_decided,
1039
+ "will_baseline": g.will_baseline,
1040
+ "artifacts": [_artifact(a) for a in g.artifacts],
1041
+ }
1042
+
1043
+ return {
1044
+ "gates": [_gate(g) for g in plan.gates],
1045
+ "gates_to_baseline": [g.gate_id for g in plan.gates_to_baseline],
1046
+ "skipped_gates": [g.gate_id for g in plan.skipped_gates],
1047
+ "dirty_artifacts": [_artifact(a) for a in plan.dirty_artifacts],
1048
+ "open_findings": plan.open_findings,
1049
+ }
1050
+
1051
+
1052
+ def _print_baseline_text(plan: object) -> None:
1053
+ from chipgraph.core.engine.baseline import BaselinePlan
1054
+
1055
+ assert isinstance(plan, BaselinePlan)
1056
+ if not plan.gates:
1057
+ typer.echo("no gates found: nothing to baseline")
1058
+ for gate in plan.gates:
1059
+ state = "decided (skipped)" if gate.already_decided else "waiting"
1060
+ typer.echo(f"gate {gate.gate_id} [{gate.instance_id}] {state}")
1061
+ for artifact in gate.artifacts:
1062
+ commit = artifact.last_commit
1063
+ where = (
1064
+ f"{commit.short_sha} {commit.date} {commit.subject}"
1065
+ if commit is not None
1066
+ else "(no commit)"
1067
+ )
1068
+ typer.echo(f" {artifact.status:9} {artifact.short_sha256} {artifact.path} {where}")
1069
+ if plan.dirty_artifacts:
1070
+ typer.echo("not baselined (modified or untracked in the working tree):")
1071
+ for artifact in plan.dirty_artifacts:
1072
+ typer.echo(f" {artifact.status:9} {artifact.path}")
1073
+ typer.echo(f"open findings: {plan.open_findings}")
1074
+ to_do = plan.gates_to_baseline
1075
+ if to_do:
1076
+ typer.echo(f"would baseline {len(to_do)} gate(s): {', '.join(g.gate_id for g in to_do)}")
1077
+ else:
1078
+ typer.echo("would baseline 0 gates")
1079
+
1080
+
1081
+ @app.command("baseline")
1082
+ @_handle_errors
1083
+ def baseline_cmd(
1084
+ ctx: typer.Context,
1085
+ confirm: Annotated[
1086
+ bool, typer.Option("--confirm", help="Record the baseline decisions (default: dry run).")
1087
+ ] = False,
1088
+ by: Annotated[str | None, typer.Option("--by")] = None,
1089
+ note: Annotated[str, typer.Option("--note")] = "",
1090
+ allow_branch: Annotated[
1091
+ bool,
1092
+ typer.Option("--allow-branch", help="Allow running on a branch other than main."),
1093
+ ] = False,
1094
+ ) -> None:
1095
+ """List the artifacts on the main branch and, with --confirm, baseline them (DESIGN 6.4)."""
1096
+ state: CliState = ctx.obj
1097
+ app_ctx = _load_ctx(state)
1098
+ app_ctx.require_profile()
1099
+
1100
+ branch = current_branch(app_ctx.root)
1101
+ main = main_branch(app_ctx.root)
1102
+ if not allow_branch and branch is not None and branch != main:
1103
+ typer.echo(
1104
+ f"refusing to baseline on branch {branch!r}: not the main branch {main!r} "
1105
+ "(pass --allow-branch to override)",
1106
+ err=True,
1107
+ )
1108
+ raise typer.Exit(code=1)
1109
+
1110
+ plan, refs_by_path = plan_project_baseline(app_ctx)
1111
+
1112
+ if not confirm:
1113
+ if state.json_output:
1114
+ typer.echo(json.dumps(_baseline_plan_json(plan), indent=2))
1115
+ else:
1116
+ _print_baseline_text(plan)
1117
+ return
1118
+
1119
+ who = by or default_identity()
1120
+ outcome = run_baseline(app_ctx, plan, refs_by_path, by=who, note=note)
1121
+ if state.json_output:
1122
+ typer.echo(
1123
+ json.dumps(
1124
+ {
1125
+ "recorded_gates": [a.gate_id for a in outcome.approvals],
1126
+ "findings_baselined": list(outcome.findings_baselined),
1127
+ },
1128
+ indent=2,
1129
+ )
1130
+ )
1131
+ else:
1132
+ if outcome.approvals:
1133
+ typer.echo(f"recorded baseline for {len(outcome.approvals)} gate(s):")
1134
+ for approval in outcome.approvals:
1135
+ typer.echo(f" {approval.gate_id}")
1136
+ else:
1137
+ typer.echo("recorded baseline for 0 gates (nothing new)")
1138
+ if outcome.findings_baselined:
1139
+ typer.echo(f"baselined {len(outcome.findings_baselined)} open finding(s)")
1140
+
1141
+
1142
+ # --- doctor ----------------------------------------------------------------------
1143
+
1144
+
1145
+ def _writable(path: Path) -> bool:
1146
+ try:
1147
+ path.mkdir(parents=True, exist_ok=True)
1148
+ probe = path / ".chipgraph-doctor-write-test"
1149
+ probe.write_text("ok", encoding="utf-8")
1150
+ probe.unlink()
1151
+ except OSError:
1152
+ return False
1153
+ return True
1154
+
1155
+
1156
+ def _first_word(cmd: object) -> str | None:
1157
+ if isinstance(cmd, str):
1158
+ parts = cmd.split()
1159
+ return parts[0] if parts else None
1160
+ if isinstance(cmd, list) and cmd:
1161
+ return str(cmd[0])
1162
+ return None
1163
+
1164
+
1165
+ _GUARD_MIN_PYTHON = (3, 9)
1166
+
1167
+
1168
+ def _guard_python() -> tuple[bool, str]:
1169
+ """Whether the plugin's write guard can start: a `python3` >= 3.9 on PATH.
1170
+
1171
+ Claude Code runs the guard as `python3 guard.py`. Without it the hook cannot start and
1172
+ Claude Code lets the tool call through, so the write guard would be off without any
1173
+ warning (`submit` still rejects changes outside a task's outputs afterwards).
1174
+ """
1175
+ found = shutil.which("python3")
1176
+ if found is None:
1177
+ return False, "not found on PATH: the plugin's write guard cannot run"
1178
+ try:
1179
+ out = subprocess.run(
1180
+ [found, "-c", "import sys; print('%d.%d' % sys.version_info[:2])"],
1181
+ capture_output=True,
1182
+ text=True,
1183
+ timeout=10,
1184
+ check=True,
1185
+ ).stdout.strip()
1186
+ major, minor = (int(x) for x in out.split("."))
1187
+ except (OSError, subprocess.SubprocessError, ValueError):
1188
+ return False, f"{found} does not run: the plugin's write guard cannot run"
1189
+ if (major, minor) < _GUARD_MIN_PYTHON:
1190
+ return False, f"{found} is {out}; the write guard needs 3.9 or newer"
1191
+ return True, f"{found} ({out})"
1192
+
1193
+
1194
+ @app.command()
1195
+ @_handle_errors
1196
+ def doctor(ctx: typer.Context) -> None:
1197
+ """Report whether the environment is set up to run chipgraph."""
1198
+ state: CliState = ctx.obj
1199
+ lines: list[tuple[str, bool, str]] = []
1200
+
1201
+ py_ok = sys.version_info >= (3, 13)
1202
+ lines.append(("python", py_ok, sys.version.split()[0]))
1203
+
1204
+ git_path = shutil.which("git")
1205
+ lines.append(("git", git_path is not None, git_path or "not found on PATH"))
1206
+
1207
+ app_ctx: AppContext | None
1208
+ try:
1209
+ app_ctx = _load_ctx(state)
1210
+ except (AppError, ConfigError) as exc:
1211
+ app_ctx = None
1212
+ lines.append(("profile", False, str(exc)))
1213
+ else:
1214
+ if app_ctx.profile is not None:
1215
+ lines.append(("profile", True, str(app_ctx.root / ".chipgraph.yml")))
1216
+ else:
1217
+ lines.append(("profile", False, "no .chipgraph.yml found"))
1218
+
1219
+ runtime = app_ctx.profile.runtime if app_ctx is not None and app_ctx.profile else None
1220
+ if runtime in (None, "claude-code"):
1221
+ lines.append(("python3 for the write guard", *_guard_python()))
1222
+
1223
+ if app_ctx is not None and app_ctx.profile is not None:
1224
+ state_dir = app_ctx.layout.state_dir
1225
+ lines.append(("state dir writable", _writable(state_dir), str(state_dir)))
1226
+ for check_id, cfg in sorted(app_ctx.profile.adapters.items()):
1227
+ if cfg.use != "cmd":
1228
+ continue
1229
+ first = _first_word(cfg.model_dump().get("cmd"))
1230
+ found = shutil.which(first) if first else None
1231
+ detail = f"{first!r} -> {found}" if first else "no 'cmd' configured"
1232
+ lines.append((f"tool for {check_id}", found is not None, detail))
1233
+
1234
+ ok = all(passed for _, passed, _ in lines)
1235
+ if state.json_output:
1236
+ typer.echo(json.dumps([{"check": n, "ok": p, "detail": d} for n, p, d in lines], indent=2))
1237
+ else:
1238
+ for name, passed, detail in lines:
1239
+ mark = "ok" if passed else "MISSING"
1240
+ typer.echo(f"{mark:7} {name}: {detail}")
1241
+ if not ok:
1242
+ raise typer.Exit(code=1)
1243
+
1244
+
1245
+ # --- findings / waive ---------------------------------------------------------------
1246
+
1247
+ _FINDING_STATUS_CHOICES = ("open", "waived", "fixed", "all")
1248
+
1249
+
1250
+ def _finding_first_evidence(finding: Finding) -> str:
1251
+ ev = finding.evidence[0]
1252
+ if ev.file is not None:
1253
+ return f"{ev.file}:{ev.line}" if ev.line is not None else ev.file
1254
+ return f"model:{ev.model_key}"
1255
+
1256
+
1257
+ def _findings_with_effective_status(
1258
+ app_ctx: AppContext, *, status: str, layer: int | None
1259
+ ) -> list[Finding]:
1260
+ """Every stored finding matching `layer`, with `.status` set to its *effective*
1261
+ status (resolving waiver currency against the finding's current artifact hashes),
1262
+ filtered by `status` unless it is `"all"`.
1263
+ """
1264
+ store = FindingStore(app_ctx.layout)
1265
+ rows: list[Finding] = []
1266
+ for finding in store.list(layer=layer):
1267
+ waivers = app_ctx.review.approvals(waiver_gate_id(finding.id))
1268
+ current_hashes = findings_app.finding_current_hashes(app_ctx.store, finding, waivers)
1269
+ eff = effective_status(finding, waivers, current_hashes)
1270
+ if status != "all" and eff != status:
1271
+ continue
1272
+ display = finding if eff == finding.status else finding.model_copy(update={"status": eff})
1273
+ rows.append(display)
1274
+ return rows
1275
+
1276
+
1277
+ @app.command("findings")
1278
+ @_handle_errors
1279
+ def findings_cmd(
1280
+ ctx: typer.Context,
1281
+ status: Annotated[
1282
+ str, typer.Option("--status", help="open|waived|fixed|all (default: open).")
1283
+ ] = "open",
1284
+ layer: Annotated[
1285
+ int | None, typer.Option("--layer", min=1, max=5, help="Only this DESIGN.md layer.")
1286
+ ] = None,
1287
+ ) -> None:
1288
+ """List recorded findings, with their effective status (DESIGN.md 4.8)."""
1289
+ state: CliState = ctx.obj
1290
+ if status not in _FINDING_STATUS_CHOICES:
1291
+ raise AppError(
1292
+ f"--status must be one of {'|'.join(_FINDING_STATUS_CHOICES)}, got {status!r}"
1293
+ )
1294
+ app_ctx = _load_ctx(state)
1295
+ rows = _findings_with_effective_status(app_ctx, status=status, layer=layer)
1296
+
1297
+ if state.json_output:
1298
+ typer.echo(json.dumps([f.model_dump(mode="json") for f in rows], indent=2))
1299
+ return
1300
+ if not rows:
1301
+ typer.echo("no findings")
1302
+ return
1303
+ for finding in rows:
1304
+ typer.echo(
1305
+ f"{finding.id} L{finding.layer} {finding.severity:9}{finding.source:16}"
1306
+ f"{_finding_first_evidence(finding):40}{finding.status:7}{finding.claim}"
1307
+ )
1308
+
1309
+
1310
+ @app.command("waive")
1311
+ @_handle_errors
1312
+ def waive_cmd(
1313
+ ctx: typer.Context,
1314
+ finding_id: Annotated[str, typer.Argument(help="A finding id, e.g. 'F-1a2b3c4d'.")],
1315
+ reason: Annotated[str, typer.Option("--reason", help="Why this finding is waived.")],
1316
+ by: Annotated[str | None, typer.Option("--by")] = None,
1317
+ bind: Annotated[
1318
+ list[str],
1319
+ typer.Option(
1320
+ "--bind",
1321
+ help=(
1322
+ "Also bind the waiver to this repo file (repeatable; may not exist yet). "
1323
+ "Needed for a finding with no file; the waiver ends when a bound file "
1324
+ "appears or changes."
1325
+ ),
1326
+ ),
1327
+ ] = [], # noqa: B006
1328
+ ) -> None:
1329
+ """Waive a finding, binding the waiver to its artifacts' current hashes."""
1330
+ state: CliState = ctx.obj
1331
+ app_ctx = _load_ctx(state)
1332
+ store = FindingStore(app_ctx.layout)
1333
+ finding = store.get_by_id(finding_id)
1334
+ if finding is None:
1335
+ typer.echo(f"no such finding: {finding_id!r}", err=True)
1336
+ raise typer.Exit(code=2)
1337
+
1338
+ who = by or default_identity()
1339
+ current_hashes = app_ctx.store.current_hashes(finding.artifacts)
1340
+ current_hashes.update(findings_app.bound_hashes(app_ctx.store, bind))
1341
+ try:
1342
+ waived = findings_app.waive(
1343
+ finding,
1344
+ by=who,
1345
+ reason=reason,
1346
+ store=store,
1347
+ review=app_ctx.review,
1348
+ current_hashes=current_hashes,
1349
+ )
1350
+ except ValueError as exc:
1351
+ raise AppError(str(exc)) from exc
1352
+
1353
+ if state.json_output:
1354
+ typer.echo(waived.model_dump_json())
1355
+ else:
1356
+ typer.echo(f"waived {waived.id} by {who!r}: {reason}")
1357
+
1358
+
1359
+ # --- agents-md ----------------------------------------------------------------------
1360
+
1361
+
1362
+ @app.command("agents-md")
1363
+ @_handle_errors
1364
+ def agents_md_cmd(
1365
+ ctx: typer.Context,
1366
+ write: Annotated[
1367
+ bool, typer.Option("--write", help="Write the file (only if its content changes).")
1368
+ ] = False,
1369
+ check_only: Annotated[
1370
+ bool,
1371
+ typer.Option(
1372
+ "--check", help="Exit 1 if the file on disk is not what --write would write (CI)."
1373
+ ),
1374
+ ] = False,
1375
+ path: Annotated[
1376
+ Path,
1377
+ typer.Option("--path", help="The file to manage, relative to the project root."),
1378
+ ] = Path("AGENTS.md"),
1379
+ ) -> None:
1380
+ """Generate chipgraph's part of AGENTS.md from the profile and org rules (optional, no AI).
1381
+
1382
+ Only the text between `<!-- chipgraph:begin -->` and `<!-- chipgraph:end -->` is
1383
+ chipgraph's; the rest of the file is the team's and is kept byte for byte. A file
1384
+ without the markers gets the block appended. By default the full resulting file is
1385
+ printed and nothing is written. Opening a PR is out of scope: review and commit the
1386
+ file yourself.
1387
+ """
1388
+ from chipgraph.core.config import load as load_config
1389
+ from chipgraph.packs.spec_core.gen.agents_md import AgentsMdError, agents_md_content
1390
+
1391
+ state: CliState = ctx.obj
1392
+ if write and check_only:
1393
+ typer.echo("--write and --check cannot be used together", err=True)
1394
+ raise typer.Exit(code=2)
1395
+ resolved = load_config(state.start, profile_path=state.profile_path)
1396
+ if resolved is None:
1397
+ raise AppError("no .chipgraph.yml: only read-only commands work; run `chipgraph init`")
1398
+ root = find_repo_root(state.start)
1399
+ target = path if path.is_absolute() else root / path
1400
+
1401
+ try:
1402
+ existing = target.read_bytes().decode("utf-8") if target.is_file() else None
1403
+ content = agents_md_content(existing, resolved, root)
1404
+ except (AgentsMdError, OSError, UnicodeDecodeError) as exc:
1405
+ typer.echo(f"{path}: {exc}", err=True)
1406
+ raise typer.Exit(code=2) from exc
1407
+
1408
+ if check_only:
1409
+ if content != existing:
1410
+ typer.echo(f"{path} is out of date; run `chipgraph agents-md --write`", err=True)
1411
+ raise typer.Exit(code=1)
1412
+ typer.echo(f"{path} is up to date")
1413
+ elif write:
1414
+ if content == existing:
1415
+ typer.echo(f"{path} is up to date")
1416
+ return
1417
+ try:
1418
+ target.write_bytes(content.encode("utf-8"))
1419
+ except OSError as exc:
1420
+ typer.echo(f"{path}: {exc}", err=True)
1421
+ raise typer.Exit(code=2) from exc
1422
+ typer.echo(f"wrote {path}")
1423
+ else:
1424
+ typer.echo(content, nl=False)
1425
+
1426
+
1427
+ # --- ask ---------------------------------------------------------------------------
1428
+
1429
+
1430
+ @app.command("ask")
1431
+ @_handle_errors
1432
+ def ask_cmd(
1433
+ ctx: typer.Context,
1434
+ question: Annotated[str, typer.Argument(help="The question, in quotes.")],
1435
+ limit: Annotated[
1436
+ int, typer.Option("--limit", min=1, max=50, help="At most this many sources.")
1437
+ ] = 12,
1438
+ provider: Annotated[
1439
+ str | None,
1440
+ typer.Option("--provider", help="The models.providers entry to use (if several)."),
1441
+ ] = None,
1442
+ ) -> None:
1443
+ """Answer a question from the Design Model and the project's documents, with citations.
1444
+
1445
+ Every citation is checked (an indexed `path:line` or a `model:<key>`); an answer that
1446
+ cannot be verified is not shown. Uses the profile's `models.providers` (API runtime);
1447
+ with none configured it prints the sources: in Claude Code, use `/chipgraph:ask`.
1448
+ Exit 1 when the model's answer was rejected.
1449
+ """
1450
+ from chipgraph.packs.assist.ask import run_ask
1451
+
1452
+ state: CliState = ctx.obj
1453
+ app_ctx = _load_ctx(state)
1454
+ app_ctx.require_profile()
1455
+ result = run_ask(app_ctx, question, limit=limit, provider_name=provider)
1456
+
1457
+ if state.json_output:
1458
+ typer.echo(json.dumps(result.model_dump(mode="json"), indent=2))
1459
+ elif result.status == "no_provider":
1460
+ sources = result.context.sources
1461
+ typer.echo(f"sources for: {question}" if sources else "no source matches the question")
1462
+ for source in sources:
1463
+ typer.echo(f" {source.citation} {source.text}")
1464
+ typer.echo(
1465
+ "no model provider is configured (models.providers); in Claude Code, run "
1466
+ f'/chipgraph:ask "{question}" for a checked answer'
1467
+ )
1468
+ elif result.answer is not None:
1469
+ typer.echo(result.answer.answer)
1470
+ checks = {c.normalized: c for c in (result.check.citations if result.check else ())}
1471
+ if result.answer.citations:
1472
+ typer.echo("sources:")
1473
+ for citation in result.answer.citations:
1474
+ cited = checks.get(citation)
1475
+ snippet = cited.text.splitlines()[0] if cited and cited.text else ""
1476
+ typer.echo(f" {citation} {snippet}")
1477
+ if result.status == "rejected" and result.check is not None:
1478
+ typer.echo("the model's answer was rejected:", err=True)
1479
+ for reason in result.check.reasons:
1480
+ typer.echo(f" {reason}", err=True)
1481
+
1482
+ if result.status == "rejected":
1483
+ raise typer.Exit(code=1)
1484
+
1485
+
1486
+ # --- triage ------------------------------------------------------------------------
1487
+
1488
+
1489
+ @app.command("triage")
1490
+ @_handle_errors
1491
+ def triage_cmd(
1492
+ ctx: typer.Context,
1493
+ log: Annotated[
1494
+ str, typer.Argument(help="The failing log file, or - to read it from stdin.")
1495
+ ] = "-",
1496
+ check: Annotated[
1497
+ str | None, typer.Option("--check", help="The check id that produced the log.")
1498
+ ] = None,
1499
+ json_flag: Annotated[bool, typer.Option("--json", help="Print the report as JSON.")] = False,
1500
+ ) -> None:
1501
+ """Classify a failing lint/sim/check log as infra, rtl, tb or spec, and say what to do.
1502
+
1503
+ Deterministic rules answer first; otherwise decide() asks a model: the API runtime uses
1504
+ `models.providers`; runtime claude-code queues the question for Claude Code (run
1505
+ `/chipgraph:triage LOG` there). A model's label is advice and never blocks.
1506
+ Exit 1 when no label was decided.
1507
+ """
1508
+ from chipgraph.packs.assist.triage import run_triage
1509
+
1510
+ state: CliState = ctx.obj
1511
+ app_ctx = _load_ctx(state)
1512
+ app_ctx.require_profile()
1513
+ if log == "-":
1514
+ text, source = sys.stdin.read(), None
1515
+ else:
1516
+ path = Path(log)
1517
+ try:
1518
+ text = path.read_text(encoding="utf-8", errors="replace")
1519
+ except OSError as exc:
1520
+ raise AppError(f"cannot read the log {log}: {exc}") from exc
1521
+ source = log
1522
+ resolved_log = path.resolve()
1523
+ if resolved_log.is_relative_to(app_ctx.root.resolve()):
1524
+ source = resolved_log.relative_to(app_ctx.root.resolve()).as_posix()
1525
+ report = run_triage(app_ctx, text, source=source, check_id=check)
1526
+
1527
+ if json_flag or state.json_output:
1528
+ typer.echo(json.dumps(report.model_dump(mode="json"), indent=2))
1529
+ elif report.status == "decided":
1530
+ how = f"rule {report.rule}" if report.backend == "rule" else f"{report.backend} model"
1531
+ low = ", low confidence: advice only" if report.low_confidence else ""
1532
+ typer.echo(f"label: {report.label} ({how}, confidence {report.confidence:.2f}{low})")
1533
+ typer.echo(f"summary: {report.summary}")
1534
+ typer.echo(f"suggestion: {report.suggestion}")
1535
+ if report.evidence:
1536
+ typer.echo("evidence:")
1537
+ for item in report.evidence:
1538
+ typer.echo(f" {item}")
1539
+ else:
1540
+ typer.echo(f"summary: {report.summary}")
1541
+ typer.echo(report.message, err=True)
1542
+
1543
+ if report.status != "decided":
1544
+ raise typer.Exit(code=1)
1545
+
1546
+
1547
+ # --- eval --------------------------------------------------------------------------
1548
+
1549
+ _EVAL_PACKAGE = "chipgraph_evals"
1550
+
1551
+
1552
+ def _evals_harness() -> object:
1553
+ """`evals/harness/` of this chipgraph checkout, loaded as the package `chipgraph_evals`.
1554
+
1555
+ The evals are not part of the installed package; they are looked up next to the
1556
+ source tree (an editable install), then upward from the current directory.
1557
+ """
1558
+ import importlib.util
1559
+
1560
+ if _EVAL_PACKAGE in sys.modules:
1561
+ return sys.modules[_EVAL_PACKAGE]
1562
+ checkout = Path(__file__).resolve().parents[3]
1563
+ for root in (checkout, Path.cwd().resolve(), *Path.cwd().resolve().parents):
1564
+ harness = root / "evals" / "harness"
1565
+ if (harness / "__init__.py").is_file():
1566
+ break
1567
+ else:
1568
+ raise AppError("chipgraph eval runs in a chipgraph checkout: evals/harness/ not found")
1569
+ spec = importlib.util.spec_from_file_location(
1570
+ _EVAL_PACKAGE, harness / "__init__.py", submodule_search_locations=[str(harness)]
1571
+ )
1572
+ assert spec is not None and spec.loader is not None
1573
+ module = importlib.util.module_from_spec(spec)
1574
+ sys.modules[_EVAL_PACKAGE] = module
1575
+ try:
1576
+ spec.loader.exec_module(module)
1577
+ except BaseException:
1578
+ del sys.modules[_EVAL_PACKAGE]
1579
+ raise
1580
+ return module
1581
+
1582
+
1583
+ @app.command("eval")
1584
+ @_handle_errors
1585
+ def eval_cmd(
1586
+ ctx: typer.Context,
1587
+ suite: Annotated[
1588
+ str,
1589
+ typer.Argument(
1590
+ help=(
1591
+ "ask, triage, triage-holdout, triage-holdout2, review, review-holdout2, rtl or tb."
1592
+ )
1593
+ ),
1594
+ ],
1595
+ runtime: Annotated[
1596
+ Literal["fake", "claude-code"],
1597
+ typer.Option("--runtime", help="fake: scripted, no model (CI); claude-code: real."),
1598
+ ] = "fake",
1599
+ main_model: Annotated[
1600
+ str, typer.Option("--main-model", help="claude-code: the main session's model.")
1601
+ ] = "sonnet",
1602
+ small_model: Annotated[
1603
+ str, typer.Option("--small-model", help="claude-code triage: the small decider tier.")
1604
+ ] = "haiku",
1605
+ large_model: Annotated[
1606
+ str,
1607
+ typer.Option(
1608
+ "--large-model",
1609
+ help="claude-code: the large tier (triage decider, review critic, rtl/tb escalation).",
1610
+ ),
1611
+ ] = "opus",
1612
+ budget_usd: Annotated[
1613
+ float | None,
1614
+ typer.Option(
1615
+ "--budget-usd",
1616
+ min=0.01,
1617
+ help="claude-code: stop once the suite cost reaches this (default 3; rtl, tb: 10).",
1618
+ ),
1619
+ ] = None,
1620
+ time_limit: Annotated[
1621
+ int, typer.Option("--time-limit", min=1, help="claude-code: suite time limit, seconds.")
1622
+ ] = 3600,
1623
+ out: Annotated[
1624
+ Path | None,
1625
+ typer.Option("--out", help="Report directory (default: a new temporary directory)."),
1626
+ ] = None,
1627
+ only: Annotated[
1628
+ str | None, typer.Option("--only", help="Only these ids, comma-separated.")
1629
+ ] = None,
1630
+ ) -> None:
1631
+ """Run an eval suite (Inspect AI) and grade it with its deterministic grader.
1632
+
1633
+ Writes the Inspect log, answers.jsonl and summary.json/summary.md to --out. The fake
1634
+ runtime calls no model. claude-code runs one headless `claude -p` per sample on a
1635
+ tinysoc copy with the dev plugin, under --budget-usd and --time-limit; auth is
1636
+ CLAUDE_CODE_OAUTH_TOKEN when set, else the logged-in Claude Code account.
1637
+ Exit 0 pass (or no data yet), 1 fail, 2 error.
1638
+ """
1639
+ import tempfile
1640
+ from typing import Any
1641
+
1642
+ try:
1643
+ import inspect_ai # noqa: F401
1644
+ except ImportError as exc:
1645
+ raise AppError(
1646
+ "chipgraph eval needs Inspect AI: run `uv sync` (dev group) or `uv sync --extra evals`"
1647
+ ) from exc
1648
+ harness: Any = _evals_harness()
1649
+ state: CliState = ctx.obj
1650
+ out_dir = out if out is not None else Path(tempfile.mkdtemp(prefix=f"cg-eval-{suite}-"))
1651
+ options = harness.EvalOptions(
1652
+ runtime=runtime,
1653
+ main_model=main_model,
1654
+ small_model=small_model,
1655
+ large_model=large_model,
1656
+ budget_usd=budget_usd,
1657
+ time_limit_s=time_limit,
1658
+ only=tuple(i for i in (only or "").replace(",", " ").split() if i),
1659
+ )
1660
+ try:
1661
+ result = harness.run_suite(
1662
+ suite, out_dir, options, progress=lambda line: typer.echo(line, err=True)
1663
+ )
1664
+ except harness.EvalError as exc:
1665
+ raise AppError(str(exc)) from exc
1666
+ if state.json_output:
1667
+ typer.echo(json.dumps(result.summary, indent=2))
1668
+ else:
1669
+ typer.echo((out_dir / "summary.md").read_text(encoding="utf-8").rstrip())
1670
+ typer.echo(f"report: {out_dir}")
1671
+ if result.exit_code:
1672
+ raise typer.Exit(code=result.exit_code)
1673
+
1674
+
1675
+ # --- mcp ---------------------------------------------------------------------------
1676
+
1677
+
1678
+ @app.command("mcp")
1679
+ @_handle_errors
1680
+ def mcp_cmd(ctx: typer.Context) -> None:
1681
+ """Start the MCP server over stdio, for Claude Code or another MCP client."""
1682
+ from chipgraph.mcp import run_stdio
1683
+
1684
+ state: CliState = ctx.obj
1685
+ asyncio.run(run_stdio(state.start, profile_path=state.profile_path))
1686
+
1687
+
1688
+ __all__ = ["app"]