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,845 @@
1
+ """The `Profile` model: a project's layered configuration, and the personal `UserConfig`.
2
+
3
+ See DESIGN.md 8.3, 8.4 and 8.6: a project is configured by exactly one `.chipgraph.yml`,
4
+ which may `extend` organization rules, presets and shared git rule repos; a personal
5
+ `~/.config/chipgraph/user.yml` may only adjust individual experience, never artifact
6
+ conventions (layout, naming, templates, checks).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ from typing import Any, Literal, Self
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
16
+
17
+ from chipgraph.core.contracts.types import DataLabel, ModelTier
18
+
19
+ Level = Literal["L1", "L2", "L3", "L4", "L5"]
20
+ """An autonomy level from L1 (human does everything) to L5 (never allowed, see DESIGN 8.3)."""
21
+
22
+
23
+ def _reject_level_5(autonomy: dict[str, Level]) -> None:
24
+ for area, level in autonomy.items():
25
+ if level == "L5":
26
+ raise ValueError(f"autonomy[{area!r}] = 'L5' is never allowed")
27
+
28
+
29
+ class SourceCfg(BaseModel):
30
+ """A single spec source: a file path and its format."""
31
+
32
+ model_config = ConfigDict(extra="forbid", frozen=True)
33
+
34
+ path: str = Field(description="Path to the source file, relative to the project root.")
35
+ format: str = Field(description="Format of the source, e.g. 'qsoc-contract'.")
36
+
37
+
38
+ _DEFAULT_REQ_ID_PATTERN = r"REQ-[A-Z][A-Z0-9_]*-\d+"
39
+
40
+
41
+ class RequirementsCfg(BaseModel):
42
+ """How requirement IDs are found in text specs (DECISIONS D37).
43
+
44
+ By default REQ-IDs are declared in the spec and matched by `id_pattern`. The temporary
45
+ `infer: verification` mode also treats each numbered item of a spec's "Verification"
46
+ section as a requirement, keyed by block and content hash rather than by its number.
47
+ """
48
+
49
+ model_config = ConfigDict(extra="forbid", frozen=True)
50
+
51
+ id_pattern: str = Field(
52
+ default=_DEFAULT_REQ_ID_PATTERN,
53
+ description=(
54
+ "Regex a declared REQ-ID must match (whole ID). '{block}' and '{BLOCK}' are "
55
+ "replaced by the block name, lower- and upper-case, before compiling."
56
+ ),
57
+ )
58
+ infer: Literal["off", "verification"] = Field(
59
+ default="off",
60
+ description=(
61
+ "Temporary mode: 'verification' infers one requirement per numbered item of the "
62
+ "spec section titled `infer_heading`, for specs without REQ-IDs."
63
+ ),
64
+ )
65
+ infer_heading: str = Field(
66
+ default="Verification",
67
+ description="Title of the section items are inferred from, without its number.",
68
+ )
69
+
70
+ @field_validator("id_pattern")
71
+ @classmethod
72
+ def _pattern_compiles(cls, value: str) -> str:
73
+ probe = value.replace("{block}", "x").replace("{BLOCK}", "X")
74
+ try:
75
+ re.compile(probe)
76
+ except re.error as exc:
77
+ raise ValueError(f"id_pattern is not a valid regex: {exc}") from exc
78
+ return value
79
+
80
+ def id_regex(self, block: str | None = None) -> re.Pattern[str]:
81
+ """The compiled `id_pattern`, with `{block}`/`{BLOCK}` filled in for `block`."""
82
+ pattern = self.id_pattern
83
+ if block is not None:
84
+ pattern = pattern.replace("{block}", re.escape(block.lower()))
85
+ pattern = pattern.replace("{BLOCK}", re.escape(block.upper()))
86
+ elif "{block}" in pattern or "{BLOCK}" in pattern:
87
+ raise ValueError("id_pattern uses '{block}'/'{BLOCK}'; a block name is required")
88
+ return re.compile(pattern)
89
+
90
+
91
+ _DEFAULT_REGISTER_ACCESS = ("RW", "RO", "WO", "W1C", "RSVD")
92
+
93
+
94
+ class SpecCfg(BaseModel):
95
+ """Where the chip-level spec and per-IP specs live."""
96
+
97
+ model_config = ConfigDict(extra="forbid", frozen=True)
98
+
99
+ chip: SourceCfg | None = Field(default=None, description="The chip-level spec source.")
100
+ ip_dir: str | None = Field(default=None, description="Directory containing per-IP specs.")
101
+ requirements: RequirementsCfg = Field(
102
+ default_factory=RequirementsCfg,
103
+ description="How requirement IDs are found in text specs (D37).",
104
+ )
105
+ register_access: tuple[str, ...] = Field(
106
+ default=_DEFAULT_REGISTER_ACCESS,
107
+ description=(
108
+ "Access modes a register-map cell may use (case-insensitive). The MAS "
109
+ "extractor rejects any other mode. Extend it for IP with extra modes, e.g. "
110
+ "'RW0C'/'RW1C' for OpenTitan-derived blocks."
111
+ ),
112
+ )
113
+
114
+ @field_validator("register_access")
115
+ @classmethod
116
+ def _access_non_empty(cls, value: tuple[str, ...]) -> tuple[str, ...]:
117
+ if not value:
118
+ raise ValueError("register_access must list at least one access mode")
119
+ return value
120
+
121
+
122
+ class AdapterCfg(BaseModel):
123
+ """An adapter selection for a capability; extra keys are the adapter's own options."""
124
+
125
+ model_config = ConfigDict(extra="allow", frozen=True)
126
+
127
+ use: str = Field(description="The adapter implementation to use, e.g. 'cmd', 'edalize'.")
128
+
129
+
130
+ class NamingCfg(BaseModel):
131
+ """The project's naming rules and the checker that enforces them."""
132
+
133
+ model_config = ConfigDict(extra="forbid", frozen=True)
134
+
135
+ rules: str | None = Field(
136
+ default=None, description="Reference to naming rules, e.g. 'org:qnsc/naming-v1.yml'."
137
+ )
138
+ checker: AdapterCfg | None = Field(default=None, description="Adapter that checks naming.")
139
+
140
+
141
+ class TargetCfg(BaseModel):
142
+ """What kind of thing is being built, and on which process."""
143
+
144
+ model_config = ConfigDict(extra="forbid", frozen=True)
145
+
146
+ kind: Literal["asic", "fpga", "ip"] = Field(
147
+ default="asic", description="The kind of target being built."
148
+ )
149
+ pdk: str | None = Field(default=None, description="The process design kit, if any.")
150
+
151
+
152
+ class ModelsCfg(BaseModel):
153
+ """LLM providers and the model assigned to each tier."""
154
+
155
+ model_config = ConfigDict(extra="forbid", frozen=True)
156
+
157
+ providers: dict[str, dict[str, str]] = Field(
158
+ default_factory=dict, description="LLM provider configuration by provider name."
159
+ )
160
+ tiers: dict[ModelTier, str] = Field(
161
+ default_factory=dict, description="Model id used for each capability/cost tier."
162
+ )
163
+ alt: dict[ModelTier, str] = Field(
164
+ default_factory=dict, description="Alternate model or provider per tier (see DESIGN D24)."
165
+ )
166
+
167
+
168
+ class DataCfg(BaseModel):
169
+ """Default data sensitivity and how NDA content is isolated."""
170
+
171
+ model_config = ConfigDict(extra="forbid", frozen=True)
172
+
173
+ default: DataLabel = Field(default="internal", description="Default data sensitivity label.")
174
+ nda_paths: tuple[str, ...] = Field(
175
+ default=(), description="Glob patterns of paths labeled 'nda'."
176
+ )
177
+ nda_model: Literal["block", "local"] = Field(
178
+ default="block", description="How NDA content is kept away from cloud models."
179
+ )
180
+
181
+
182
+ class StateCfg(BaseModel):
183
+ """Where run state (journal, cache, trace) is kept."""
184
+
185
+ model_config = ConfigDict(extra="forbid", frozen=True)
186
+
187
+ backend: str = Field(
188
+ default="local",
189
+ description="State backend: 'local', 'branch:<name>', or 'repo:<url>'.",
190
+ )
191
+
192
+
193
+ class DecisionsCfg(BaseModel):
194
+ """Where gate approvals and waivers are kept."""
195
+
196
+ model_config = ConfigDict(extra="forbid", frozen=True)
197
+
198
+ store: Literal["repo", "state"] = Field(
199
+ default="repo", description="Where gate/waiver decisions are stored."
200
+ )
201
+
202
+
203
+ FANOUT_DEFAULT_MAX_PARALLEL = 4
204
+ """The most fan-out branches run at once when the profile does not say (capped by CPUs)."""
205
+
206
+
207
+ class FanoutCfg(BaseModel):
208
+ """How a fan-out runs its branches (DESIGN 5.3): how many at once, how long each may take.
209
+
210
+ `max_parallel` unset means `min(4, cpu count)`; a profile sets it lower for an API rate
211
+ limit or an EDA license count. `branch_timeout_s` unset means no limit.
212
+
213
+ `isolate` (default **false**) puts each dispatched agent task in its own workspace
214
+ outside the user's tree: a worktree at HEAD with the task's declared inputs copied
215
+ in. The subagent writes there, not the user's files. Nothing a subagent does can
216
+ touch the user's tree until the engine accepts the task and copies **its outputs
217
+ only** back. This is the F2/F3 isolation rules for parallel agent tasks.
218
+ """
219
+
220
+ model_config = ConfigDict(extra="forbid", frozen=True)
221
+
222
+ isolate: bool = Field(
223
+ default=False,
224
+ description=(
225
+ "Run each agent task in an isolated workspace. When true, a task gets its own "
226
+ "worktree at HEAD, seeded with the task's inputs; the subagent writes there, "
227
+ "and outputs are copied back only on acceptance. Default false: tasks write "
228
+ "the user's tree directly, as before."
229
+ ),
230
+ )
231
+ max_parallel: int | None = Field(
232
+ default=None,
233
+ ge=1,
234
+ description="Branches run at once; unset means min(4, cpu count).",
235
+ )
236
+ branch_timeout_s: float | None = Field(
237
+ default=None,
238
+ gt=0,
239
+ description="Seconds a branch may run before it is stopped; unset means no limit.",
240
+ )
241
+
242
+ def effective_max_parallel(self, cpu_count: int | None = None) -> int:
243
+ """`max_parallel`, or `min(4, cpu_count)` (the machine's CPUs when not given)."""
244
+ if self.max_parallel is not None:
245
+ return self.max_parallel
246
+ cpus = cpu_count if cpu_count is not None else (os.cpu_count() or 1)
247
+ return max(1, min(FANOUT_DEFAULT_MAX_PARALLEL, cpus))
248
+
249
+
250
+ class SelectCfg(BaseModel):
251
+ """Optional best-of-N (DESIGN 5.3, F6): N candidates of one rule, chosen by its checks.
252
+
253
+ A rule opts in by its id in `rules` (or an instance parameter `best_of`); every other
254
+ rule runs once, as before. `n` is the default N for an opted-in rule, `max_n` the hard
255
+ cap on any N. The N candidates share ONE budget: `budget_factor` times the rule's
256
+ single-run budget (tries, tokens and cost), not N times it.
257
+ """
258
+
259
+ model_config = ConfigDict(extra="forbid", frozen=True)
260
+
261
+ n: int = Field(default=2, ge=1, description="Candidates for an opted-in rule (default N).")
262
+ max_n: int = Field(default=3, ge=1, description="Hard cap on N, whatever asks for more.")
263
+ budget_factor: float = Field(
264
+ default=1.5,
265
+ gt=0,
266
+ description="The candidates' shared budget, as a multiple of one run's budget.",
267
+ )
268
+ rules: dict[str, int] = Field(
269
+ default_factory=dict,
270
+ description="Rule id -> N for the rules that opt in to best-of-N.",
271
+ )
272
+
273
+ @field_validator("rules")
274
+ @classmethod
275
+ def _check_rules(cls, value: dict[str, int]) -> dict[str, int]:
276
+ for rule_id, n in value.items():
277
+ if n < 1:
278
+ raise ValueError(f"rules[{rule_id!r}] = {n}: N must be >= 1")
279
+ return value
280
+
281
+ def effective_n(self, requested: int | None = None) -> int:
282
+ """`requested` (or `n`), capped at `max_n`; at least 1."""
283
+ wanted = requested if requested is not None else self.n
284
+ return max(1, min(wanted, self.max_n))
285
+
286
+
287
+ DecideTier = Literal["small", "large"]
288
+ """A model tier `decide()` may ask: the small model first, the large one when unsure."""
289
+
290
+ _DECIDE_TIER_ORDER: tuple[DecideTier, ...] = ("small", "large")
291
+
292
+
293
+ def _check_tiers(value: tuple[DecideTier, ...] | None) -> tuple[DecideTier, ...] | None:
294
+ if value is None:
295
+ return None
296
+ if len(set(value)) != len(value):
297
+ raise ValueError(f"enabled_tiers lists a tier more than once: {list(value)}")
298
+ # Always ask in escalation order, whatever order the profile lists them in.
299
+ return tuple(t for t in _DECIDE_TIER_ORDER if t in value)
300
+
301
+
302
+ class DecideOverride(BaseModel):
303
+ """`decide()` settings for the questions whose id starts with one prefix.
304
+
305
+ Only the fields that are set replace the project's (or a shorter prefix's) values.
306
+ """
307
+
308
+ model_config = ConfigDict(extra="forbid", frozen=True)
309
+
310
+ small_min_confidence: float | None = Field(
311
+ default=None, ge=0, le=1, description="Overrides `small_min_confidence`."
312
+ )
313
+ large_min_confidence: float | None = Field(
314
+ default=None, ge=0, le=1, description="Overrides `large_min_confidence`."
315
+ )
316
+ enabled_tiers: tuple[DecideTier, ...] | None = Field(
317
+ default=None, description="Overrides `enabled_tiers`."
318
+ )
319
+
320
+ @field_validator("enabled_tiers")
321
+ @classmethod
322
+ def _tiers_unique(cls, value: tuple[DecideTier, ...] | None) -> tuple[DecideTier, ...] | None:
323
+ return _check_tiers(value)
324
+
325
+
326
+ class DecideSettings(BaseModel):
327
+ """The effective `decide()` settings for one question (see `DecideCfg.for_question`)."""
328
+
329
+ model_config = ConfigDict(extra="forbid", frozen=True)
330
+
331
+ small_min_confidence: float = Field(ge=0, le=1)
332
+ large_min_confidence: float = Field(ge=0, le=1)
333
+ enabled_tiers: tuple[DecideTier, ...]
334
+
335
+ def threshold(self, tier: DecideTier) -> float:
336
+ """The confidence an answer from `tier` needs to be accepted without escalating."""
337
+ return self.small_min_confidence if tier == "small" else self.large_min_confidence
338
+
339
+
340
+ class DecideCfg(BaseModel):
341
+ """Thresholds of the fast decision layer `decide()` (DESIGN 5.4).
342
+
343
+ A question no rule answers goes to the small model; an answer under
344
+ `small_min_confidence` goes on to the large model; a large answer under
345
+ `large_min_confidence` is still returned, but logged as low confidence (AI never
346
+ blocks: the caller decides). `overrides` adjust this per question-id prefix, e.g.
347
+ `overrides: { "triage.": { small_min_confidence: 0.9 } }`; every matching prefix
348
+ applies, shortest first, so the longest prefix wins.
349
+ """
350
+
351
+ model_config = ConfigDict(extra="forbid", frozen=True)
352
+
353
+ small_min_confidence: float = Field(
354
+ default=0.8,
355
+ ge=0,
356
+ le=1,
357
+ description="Confidence a small-model answer needs; under it, ask the large model.",
358
+ )
359
+ large_min_confidence: float = Field(
360
+ default=0.5,
361
+ ge=0,
362
+ le=1,
363
+ description="Confidence under which a large-model answer is logged as low confidence.",
364
+ )
365
+ enabled_tiers: tuple[DecideTier, ...] = Field(
366
+ default=_DECIDE_TIER_ORDER,
367
+ description="Model tiers decide() may ask, e.g. ['small'] to never use the large one.",
368
+ )
369
+ overrides: dict[str, DecideOverride] = Field(
370
+ default_factory=dict,
371
+ description="Per question-id prefix overrides, e.g. 'triage.'.",
372
+ )
373
+
374
+ @field_validator("enabled_tiers")
375
+ @classmethod
376
+ def _tiers_unique(cls, value: tuple[DecideTier, ...]) -> tuple[DecideTier, ...]:
377
+ checked = _check_tiers(value)
378
+ return checked if checked is not None else ()
379
+
380
+ @field_validator("overrides")
381
+ @classmethod
382
+ def _prefixes_non_empty(cls, value: dict[str, DecideOverride]) -> dict[str, DecideOverride]:
383
+ if "" in value:
384
+ raise ValueError("an overrides prefix must not be empty; set the top-level fields")
385
+ return value
386
+
387
+ def for_question(self, question_id: str) -> DecideSettings:
388
+ """The settings for `question_id`: the defaults, then each matching prefix's override."""
389
+ small = self.small_min_confidence
390
+ large = self.large_min_confidence
391
+ tiers = self.enabled_tiers
392
+ for prefix in sorted(p for p in self.overrides if question_id.startswith(p)):
393
+ override = self.overrides[prefix]
394
+ if override.small_min_confidence is not None:
395
+ small = override.small_min_confidence
396
+ if override.large_min_confidence is not None:
397
+ large = override.large_min_confidence
398
+ if override.enabled_tiers is not None:
399
+ tiers = override.enabled_tiers
400
+ return DecideSettings(
401
+ small_min_confidence=small, large_min_confidence=large, enabled_tiers=tiers
402
+ )
403
+
404
+
405
+ class PathRule(BaseModel):
406
+ """An exception for one path glob: checks toggled off, writes denied, and why."""
407
+
408
+ model_config = ConfigDict(extra="forbid", frozen=True)
409
+
410
+ checks: dict[str, Literal["on", "off"]] = Field(
411
+ default_factory=dict, description="Per-check on/off override for this path."
412
+ )
413
+ write: Literal["allow", "deny"] = Field(
414
+ default="allow", description="Whether agents may write files under this path."
415
+ )
416
+ reason: str | None = Field(
417
+ default=None,
418
+ description="Why this exception exists; required when a check is off or write is denied.",
419
+ )
420
+
421
+ @field_validator("checks", mode="before")
422
+ @classmethod
423
+ def _yaml_booleans_to_on_off(cls, value: object) -> object:
424
+ # YAML 1.1 (PyYAML) reads an unquoted `off` / `on` as a boolean. Accept both
425
+ # spellings, so `checks: { naming: off }` works as the examples in DESIGN.md show.
426
+ if isinstance(value, dict):
427
+ return {
428
+ k: ("on" if v is True else "off" if v is False else v) for k, v in value.items()
429
+ }
430
+ return value
431
+
432
+ @model_validator(mode="after")
433
+ def _require_reason_when_relaxed(self) -> Self:
434
+ relaxed = self.write == "deny" or any(v == "off" for v in self.checks.values())
435
+ if relaxed and not self.reason:
436
+ raise ValueError(
437
+ "a PathRule that turns a check off or denies write must give a 'reason'"
438
+ )
439
+ return self
440
+
441
+
442
+ class BlockSpecOverride(BaseModel):
443
+ """The part of `spec` a block may override: how its requirement IDs are found.
444
+
445
+ Only the fields that are set replace the project's, e.g. a block whose spec also
446
+ carries another prefix: `blocks.rom.spec.requirements.id_pattern: '(?:ROM|BOOT)_\\d{3}'`
447
+ keeps the project's `infer` mode.
448
+ """
449
+
450
+ model_config = ConfigDict(extra="forbid", frozen=True)
451
+
452
+ requirements: RequirementsCfg | None = Field(
453
+ default=None, description="Requirement-ID settings for this block (D37)."
454
+ )
455
+
456
+
457
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
458
+
459
+
460
+ class WrapperCfg(BaseModel):
461
+ """A wrapper module around a third-party IP of a block, written as a source the
462
+ project's generator expands (DESIGN 8.2; task M2-12).
463
+
464
+ The IP's files are never edited: the wrapper instantiates `vendor_module`, declared in
465
+ `vendor_files`, and maps its ports to the project's names. `exemplar` is an existing
466
+ wrapper source whose structure a new wrapper imitates.
467
+ """
468
+
469
+ model_config = ConfigDict(extra="forbid", frozen=True)
470
+
471
+ name: str = Field(description="The wrapper module's name (also its file names).")
472
+ vendor_module: str = Field(description="The IP's module the wrapper instantiates.")
473
+ vendor_files: tuple[str, ...] = Field(
474
+ min_length=1, description="Repo-relative files that declare the IP's module."
475
+ )
476
+ exemplar: str | None = Field(
477
+ default=None, description="A repo-relative wrapper source to imitate, if any."
478
+ )
479
+
480
+ @field_validator("name", "vendor_module")
481
+ @classmethod
482
+ def _check_identifier(cls, value: str) -> str:
483
+ if not _IDENTIFIER_RE.match(value):
484
+ raise ValueError(f"{value!r} is not a module name")
485
+ return value
486
+
487
+ @field_validator("vendor_files")
488
+ @classmethod
489
+ def _check_files(cls, value: tuple[str, ...]) -> tuple[str, ...]:
490
+ for path in value:
491
+ _check_repo_path(path)
492
+ if len(set(value)) != len(value):
493
+ raise ValueError("a vendor file is listed more than once")
494
+ return value
495
+
496
+ @field_validator("exemplar")
497
+ @classmethod
498
+ def _check_exemplar(cls, value: str | None) -> str | None:
499
+ return None if value is None else _check_repo_path(value)
500
+
501
+ @model_validator(mode="after")
502
+ def _check_names(self) -> Self:
503
+ if self.name == self.vendor_module:
504
+ raise ValueError(
505
+ f"wrapper {self.name!r} has the name of the module it wraps; give it its own"
506
+ )
507
+ return self
508
+
509
+
510
+ def _check_repo_path(path: str) -> str:
511
+ parts = path.replace("\\", "/").split("/")
512
+ if not path or path.startswith("/") or re.match(r"^[A-Za-z]:", path) or ".." in parts:
513
+ raise ValueError(f"{path!r} is not a repo-relative path")
514
+ return path
515
+
516
+
517
+ class BlockOverride(BaseModel):
518
+ """Per-block overrides: adapters, layout, autonomy, paths, spec requirements, instances,
519
+ and the block's wrappers.
520
+
521
+ `instances` names the memory-map instances of this block, treated as an IP (D38):
522
+ e.g. `blocks.timer.instances: [timer_0, timer_1]`. Each name is a single model-key
523
+ part (non-empty, no ':' or '.') and must be unique within the block. With no
524
+ `instances`, the block matches only a same-named instance in the chip contract.
525
+
526
+ `wrappers` lists the block's wrappers around third-party IP (`WrapperCfg`); like
527
+ `instances` it is a block-level fact, not merged into the block's profile.
528
+ """
529
+
530
+ model_config = ConfigDict(extra="forbid", frozen=True)
531
+
532
+ adapters: dict[str, AdapterCfg] = Field(
533
+ default_factory=dict, description="Per-block adapter overrides."
534
+ )
535
+ layout: dict[str, str | tuple[str, ...]] = Field(
536
+ default_factory=dict, description="Per-block layout overrides."
537
+ )
538
+ autonomy: dict[str, Level] = Field(
539
+ default_factory=dict, description="Per-block autonomy overrides."
540
+ )
541
+ paths: dict[str, PathRule] = Field(
542
+ default_factory=dict, description="Per-block path rule overrides."
543
+ )
544
+ spec: BlockSpecOverride = Field(
545
+ default_factory=BlockSpecOverride,
546
+ description="Per-block requirement-ID settings; only the fields set override.",
547
+ )
548
+ instances: tuple[str, ...] = Field(
549
+ default=(),
550
+ description=(
551
+ "Memory-map instances of this block-as-IP (D38); each a valid model-key part "
552
+ "(non-empty, no ':' or '.'), unique within the block."
553
+ ),
554
+ )
555
+ wrappers: tuple[WrapperCfg, ...] = Field(
556
+ default=(),
557
+ description="Wrappers of this block around third-party IP, each with a unique name.",
558
+ )
559
+
560
+ @field_validator("wrappers")
561
+ @classmethod
562
+ def _check_wrappers(cls, value: tuple[WrapperCfg, ...]) -> tuple[WrapperCfg, ...]:
563
+ names = [w.name for w in value]
564
+ repeated = sorted({n for n in names if names.count(n) > 1})
565
+ if repeated:
566
+ raise ValueError(f"wrapper {repeated[0]!r} is listed more than once")
567
+ return value
568
+
569
+ @field_validator("instances")
570
+ @classmethod
571
+ def _check_instances(cls, value: tuple[str, ...]) -> tuple[str, ...]:
572
+ seen: set[str] = set()
573
+ for name in value:
574
+ if not name:
575
+ raise ValueError("an instance name must not be empty")
576
+ if ":" in name or "." in name:
577
+ raise ValueError(f"instance name {name!r} must not contain ':' or '.'")
578
+ if name in seen:
579
+ raise ValueError(f"instance {name!r} is listed more than once")
580
+ seen.add(name)
581
+ return value
582
+
583
+ @model_validator(mode="after")
584
+ def _check_autonomy(self) -> Self:
585
+ _reject_level_5(dict(self.autonomy))
586
+ return self
587
+
588
+
589
+ class TemplatesCfg(BaseModel):
590
+ """Per-block-name template overrides (DESIGN 8.6 V2)."""
591
+
592
+ model_config = ConfigDict(extra="forbid", frozen=True)
593
+
594
+ override: dict[str, str] = Field(
595
+ default_factory=dict, description="Template block name to override file path."
596
+ )
597
+
598
+
599
+ StyleKey = Literal[
600
+ "indent",
601
+ "begin",
602
+ "ports",
603
+ "reset",
604
+ "reset_kind",
605
+ "sequential",
606
+ "suffix",
607
+ "header",
608
+ "blank_lines",
609
+ "line_length",
610
+ "case",
611
+ ]
612
+ """A feature of the code style the `style` check derives from the exemplars."""
613
+
614
+ STYLE_KEYS: tuple[StyleKey, ...] = (
615
+ "indent",
616
+ "begin",
617
+ "ports",
618
+ "reset",
619
+ "reset_kind",
620
+ "sequential",
621
+ "suffix",
622
+ "header",
623
+ "blank_lines",
624
+ "line_length",
625
+ "case",
626
+ )
627
+ """Every `StyleKey`, in the order the `style` check reports them."""
628
+
629
+
630
+ class StyleCfg(BaseModel):
631
+ """Code style exemplars and the formatter run after generation (DESIGN 8.6 V3)."""
632
+
633
+ model_config = ConfigDict(extra="forbid", frozen=True)
634
+
635
+ exemplars: tuple[str, ...] = Field(
636
+ default=(), description="Paths to files whose style agents should imitate."
637
+ )
638
+ formatter: AdapterCfg | None = Field(
639
+ default=None, description="Adapter that formats generated code after it is written."
640
+ )
641
+ enforce: tuple[StyleKey, ...] = Field(
642
+ default=(),
643
+ description=(
644
+ "Style features whose deviations from the exemplars are errors (the others are "
645
+ "warnings): indent, begin, ports, reset, sequential, suffix, header, "
646
+ "blank_lines, line_length, case."
647
+ ),
648
+ )
649
+
650
+
651
+ class EnvCfg(BaseModel):
652
+ """Environment modules and variables needed to run project tools (DESIGN 12.6)."""
653
+
654
+ model_config = ConfigDict(extra="forbid", frozen=True)
655
+
656
+ modules: tuple[str, ...] = Field(
657
+ default=(), description="Environment Modules/Lmod modules to load."
658
+ )
659
+ vars: dict[str, str] = Field(
660
+ default_factory=dict, description="Environment variables to set, e.g. license servers."
661
+ )
662
+
663
+
664
+ class PolicyCfg(BaseModel):
665
+ """Organization/project policy switches (DESIGN 8.6 layer authority table).
666
+
667
+ `local_plugins` is tighten-only across layers: an outer layer (e.g. the organization)
668
+ may set `deny` and no later layer may re-`allow` it (enforced in the loader).
669
+ """
670
+
671
+ model_config = ConfigDict(extra="forbid", frozen=True)
672
+
673
+ local_plugins: Literal["allow", "deny"] = Field(
674
+ default="allow",
675
+ description="Whether the project may load local plugins from `.chipgraph/plugins/`.",
676
+ )
677
+
678
+
679
+ class PlanCfg(BaseModel):
680
+ """Limits on a block's plan: how many dynamic nodes a Planner may propose (DESIGN 5.3).
681
+
682
+ The plan check rejects a plan over any limit, and the graph never expands one: the
683
+ limits bound how many rule instances an approved plan can add.
684
+ """
685
+
686
+ model_config = ConfigDict(extra="forbid", frozen=True)
687
+
688
+ max_modules: int = Field(default=12, ge=1, description="Most modules one plan may list.")
689
+ max_depth: int = Field(
690
+ default=4,
691
+ ge=1,
692
+ description="Longest chain of module dependencies, counted in modules (1: none).",
693
+ )
694
+ max_total_tries: int = Field(
695
+ default=36, ge=1, description="Most tries the modules' budgets may add up to."
696
+ )
697
+
698
+
699
+ class Profile(BaseModel):
700
+ """A project's full, merged configuration (DESIGN 8.3-8.4, 8.6, 12.6)."""
701
+
702
+ model_config = ConfigDict(extra="forbid", frozen=True)
703
+
704
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
705
+ project: str = Field(description="The project's name.")
706
+ extends: tuple[str, ...] = Field(
707
+ default=(),
708
+ description="Profiles this one extends: 'org:', 'preset:', 'path:', or 'git+'.",
709
+ )
710
+ packs: tuple[str, ...] = Field(default=(), description="Domain packs used by this project.")
711
+ spec: SpecCfg = Field(
712
+ default_factory=SpecCfg, description="Where the chip-level and per-IP specs live."
713
+ )
714
+ adapters: dict[str, AdapterCfg] = Field(
715
+ default_factory=dict, description="Capability name (e.g. 'lint', 'sim') to adapter config."
716
+ )
717
+ naming: NamingCfg = Field(default_factory=NamingCfg, description="Naming rules and checker.")
718
+ layout: dict[str, str | tuple[str, ...]] = Field(
719
+ default_factory=dict, description="Artifact kind to output path template(s)."
720
+ )
721
+ target: TargetCfg = Field(default_factory=TargetCfg, description="What is being built.")
722
+ autonomy: dict[str, Level] = Field(
723
+ default_factory=dict, description="Autonomy level per area, e.g. 'spec', 'rtl', 'verify'."
724
+ )
725
+ models: ModelsCfg = Field(
726
+ default_factory=ModelsCfg, description="LLM providers and tier assignments."
727
+ )
728
+ runtime: Literal["claude-code", "claude-agent-sdk", "generic"] = Field(
729
+ default="claude-code", description="Which agent runtime executes agent rules."
730
+ )
731
+ data: DataCfg = Field(
732
+ default_factory=DataCfg, description="Data sensitivity defaults and NDA handling."
733
+ )
734
+ reviewers: tuple[str, ...] = Field(default=(), description="Default reviewers for gates.")
735
+ state: StateCfg = Field(default_factory=StateCfg, description="Where run state is kept.")
736
+ decisions: DecisionsCfg = Field(
737
+ default_factory=DecisionsCfg, description="Where gate/waiver decisions are kept."
738
+ )
739
+ decide: DecideCfg = Field(
740
+ default_factory=DecideCfg,
741
+ description="Thresholds of the fast decision layer decide(): rule, small, large model.",
742
+ )
743
+ plan: PlanCfg = Field(
744
+ default_factory=PlanCfg,
745
+ description="Limits on a block's plan: modules, dependency depth, total tries.",
746
+ )
747
+ fanout: FanoutCfg = Field(
748
+ default_factory=FanoutCfg,
749
+ description="Fan-out limits: branches at once, time per branch (DESIGN 5.3).",
750
+ )
751
+ select: SelectCfg = Field(
752
+ default_factory=SelectCfg,
753
+ description="Optional best-of-N: N, its cap, the shared budget, opted-in rules (F6).",
754
+ )
755
+ paths: dict[str, PathRule] = Field(
756
+ default_factory=dict, description="Glob pattern to path-specific exception rule."
757
+ )
758
+ blocks: dict[str, BlockOverride] = Field(
759
+ default_factory=dict, description="Per-block overrides of adapters/layout/autonomy/paths."
760
+ )
761
+ templates: TemplatesCfg = Field(
762
+ default_factory=TemplatesCfg, description="Template block overrides."
763
+ )
764
+ style: StyleCfg = Field(
765
+ default_factory=StyleCfg, description="Code style exemplars and formatter."
766
+ )
767
+ plugins: tuple[str, ...] = Field(
768
+ default=(), description="Local plugin script paths (DESIGN 8.6 V5)."
769
+ )
770
+ policy: PolicyCfg = Field(
771
+ default_factory=PolicyCfg, description="Organization/project policy switches (DESIGN 8.6)."
772
+ )
773
+ workspace: str | None = Field(
774
+ default=None, description="Path to a workspace manifest, if part of one (DESIGN 8.7)."
775
+ )
776
+ offline: bool = Field(
777
+ default=False, description="Disable all outbound network use (DESIGN 12.6)."
778
+ )
779
+ env: EnvCfg = Field(
780
+ default_factory=EnvCfg, description="Environment modules and variables needed by tools."
781
+ )
782
+ vcs: AdapterCfg | None = Field(
783
+ default=None, description="Version control adapter, if not plain git."
784
+ )
785
+ review: AdapterCfg | None = Field(
786
+ default=None, description="Review/gate adapter, if not GitHub PRs."
787
+ )
788
+ runner: AdapterCfg | None = Field(
789
+ default=None, description="Job runner adapter, e.g. 'ssh', 'lsf', 'slurm'."
790
+ )
791
+ workflow_overrides: dict[str, dict[str, object]] = Field(
792
+ default_factory=dict, description="Per-workflow field overrides."
793
+ )
794
+ freeze: dict[str, str] = Field(
795
+ default_factory=dict, description="Pinned versions for reproducibility."
796
+ )
797
+
798
+ @model_validator(mode="after")
799
+ def _check_autonomy(self) -> Self:
800
+ _reject_level_5(dict(self.autonomy))
801
+ return self
802
+
803
+
804
+ _USER_ALLOWED_KEYS = frozenset(
805
+ {"schema_version", "autonomy", "notify", "answer_language", "prefer_models", "editor"}
806
+ )
807
+
808
+
809
+ class UserConfig(BaseModel):
810
+ """A personal layer (`~/.config/chipgraph/user.yml`): experience only, never conventions."""
811
+
812
+ model_config = ConfigDict(extra="forbid", frozen=True)
813
+
814
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
815
+ autonomy: dict[str, Level] = Field(
816
+ default_factory=dict,
817
+ description="Personal autonomy override per area; may only lower the project's level.",
818
+ )
819
+ notify: str | None = Field(default=None, description="Notification channel, e.g. 'slack'.")
820
+ answer_language: str | None = Field(
821
+ default=None, description="Preferred language for agent answers."
822
+ )
823
+ prefer_models: dict[ModelTier, str] = Field(
824
+ default_factory=dict,
825
+ description="Preferred model per tier; must be one the project allows.",
826
+ )
827
+ editor: str | None = Field(default=None, description="Preferred editor to open files in.")
828
+
829
+ @model_validator(mode="before")
830
+ @classmethod
831
+ def _reject_project_conventions(cls, data: Any) -> Any:
832
+ if isinstance(data, dict):
833
+ unknown = sorted(set(data) - _USER_ALLOWED_KEYS)
834
+ if unknown:
835
+ raise ValueError(
836
+ f"{unknown[0]!r} is a project artifact convention (naming, layout, "
837
+ "template, check, ...); the user layer may not change project "
838
+ "conventions (DESIGN 8.6), only personal experience settings"
839
+ )
840
+ return data
841
+
842
+ @model_validator(mode="after")
843
+ def _check_autonomy(self) -> Self:
844
+ _reject_level_5(dict(self.autonomy))
845
+ return self