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,232 @@
1
+ """`RoleSpec`: one agent role as data (DESIGN.md 5.1, D8).
2
+
3
+ A role is a system prompt plus a **tool table**: the abstract capabilities it is granted,
4
+ what it may write, what it may read, and its model tier with the tier it escalates to.
5
+ An agent runtime maps the capabilities to its own tools (the runtime `claude-code`
6
+ renders them into its subagent definitions); the engine and the runtime's guard enforce
7
+ the write scope and the read policy.
8
+
9
+ A role file is markdown with a YAML frontmatter (the fields below, without `prompt`);
10
+ the body is the prompt. The prompt names tools only through placeholders,
11
+ `{tool:<capability>}`, which the runtime replaces with its own tool names, so this data
12
+ stays free of any harness's tool names.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Annotated, Literal, Self
20
+
21
+ import yaml
22
+ from pydantic import (
23
+ BaseModel,
24
+ ConfigDict,
25
+ Field,
26
+ StringConstraints,
27
+ ValidationError,
28
+ model_validator,
29
+ )
30
+
31
+ from chipgraph.core.contracts import ArtifactKind, ModelTier
32
+
33
+ Capability = Literal[
34
+ "read_files",
35
+ "search_files",
36
+ "write_outputs",
37
+ "engine_context",
38
+ "engine_decisions",
39
+ ]
40
+ """An abstract tool capability a role may be granted.
41
+
42
+ - `read_files`: read a project file by path.
43
+ - `search_files`: list files by pattern and search their content.
44
+ - `write_outputs`: create and edit files (only the task's outputs: the guard enforces it).
45
+ - `engine_context`: ask the engine for the task's context (`get_context`).
46
+ - `engine_decisions`: read the engine's pending multiple-choice questions.
47
+ """
48
+
49
+ CAPABILITIES: tuple[Capability, ...] = (
50
+ "read_files",
51
+ "search_files",
52
+ "write_outputs",
53
+ "engine_context",
54
+ "engine_decisions",
55
+ )
56
+ """Every capability, in the order runtimes list their tools."""
57
+
58
+ WriteScope = Literal["outputs", "plan", "proposal", "none"]
59
+ """What a role may write: its rule's outputs, only its plan file, only its proposal file,
60
+ or nothing. `plan` and `proposal` rules have exactly one output (the rule validator checks
61
+ it); every write is still bounded by the task's outputs."""
62
+
63
+ RoleId = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")]
64
+ """A role id: lower case words joined by `-`, e.g. `author`, `tb-author`."""
65
+
66
+ _PLACEHOLDER_RE = re.compile(r"\{tool:([a-z_]+)\}")
67
+
68
+ _TIER_ORDER: dict[ModelTier, int] = {"small": 0, "medium": 1, "large": 2}
69
+
70
+
71
+ class RoleError(Exception):
72
+ """An unknown role, a broken role file, or a rule that breaks its role's tool table."""
73
+
74
+
75
+ class ReadPolicy(BaseModel):
76
+ """What a role may read with its own tools (the engine's context is checked apart)."""
77
+
78
+ model_config = ConfigDict(extra="forbid", frozen=True)
79
+
80
+ mode: Literal["any", "deny"] = Field(
81
+ default="any",
82
+ description="'any': no read restriction; 'deny': never read the kinds and globs below.",
83
+ )
84
+ deny_kinds: tuple[ArtifactKind, ...] = Field(
85
+ default=(),
86
+ description=(
87
+ "Artifact kinds the role must not see: every artifact of these kinds in the "
88
+ "build graph, and the profile's layout paths for them."
89
+ ),
90
+ )
91
+ deny_globs: tuple[str, ...] = Field(
92
+ default=(), description="Extra repo-relative path globs the role must not read."
93
+ )
94
+
95
+ @model_validator(mode="after")
96
+ def _check_mode(self) -> Self:
97
+ denies = bool(self.deny_kinds or self.deny_globs)
98
+ if self.mode == "any" and denies:
99
+ raise ValueError("read_policy mode 'any' takes no deny_kinds or deny_globs")
100
+ if self.mode == "deny" and not denies:
101
+ raise ValueError("read_policy mode 'deny' needs deny_kinds or deny_globs")
102
+ return self
103
+
104
+
105
+ class RoleSpec(BaseModel):
106
+ """One agent role: its prompt and its tool table (DESIGN.md 5.1)."""
107
+
108
+ model_config = ConfigDict(extra="forbid", frozen=True)
109
+
110
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
111
+ id: RoleId = Field(description="The role id rules name in `role:`, e.g. 'author'.")
112
+ description: str = Field(
113
+ min_length=1,
114
+ description="One line: what the role does and how it is started. No line breaks.",
115
+ )
116
+ prompt: str = Field(
117
+ min_length=1,
118
+ description="The role's system prompt; names tools only as {tool:<capability>}.",
119
+ )
120
+ default_tier: ModelTier = Field(description="Model tier of the first attempt.")
121
+ escalate_to: ModelTier | None = Field(
122
+ default=None, description="Model tier of later attempts, after a rejected one."
123
+ )
124
+ tools: tuple[Capability, ...] = Field(
125
+ description="Abstract capabilities granted; the runtime maps them to its tools."
126
+ )
127
+ write_scope: WriteScope = Field(description="What the role may write.")
128
+ read_policy: ReadPolicy = Field(
129
+ default_factory=ReadPolicy, description="What the role may read with its own tools."
130
+ )
131
+ shell: Literal[False] = Field(
132
+ default=False, description="Roles never get a shell (the guard refuses one)."
133
+ )
134
+ dispatch: bool = Field(
135
+ default=True,
136
+ description=(
137
+ "Whether agent rules may use this role (handed out as tasks); false for a role "
138
+ "served by another path, such as the fast decision layer."
139
+ ),
140
+ )
141
+ engine_writes: tuple[ArtifactKind, ...] = Field(
142
+ default=(),
143
+ description=(
144
+ "For a role that writes no files (write_scope 'none'): the artifact kinds of the "
145
+ "one output the engine writes from the role's structured reply. A rule of this "
146
+ "role then declares exactly that one output; the role itself never writes it."
147
+ ),
148
+ )
149
+
150
+ @model_validator(mode="after")
151
+ def _check_table(self) -> Self:
152
+ if "\n" in self.description:
153
+ raise ValueError("description must be one line")
154
+ if self.engine_writes and self.write_scope != "none":
155
+ raise ValueError("engine_writes is only for a role whose write_scope is 'none'")
156
+ if len(set(self.engine_writes)) != len(self.engine_writes):
157
+ raise ValueError("engine_writes lists a kind twice")
158
+ if len(set(self.tools)) != len(self.tools):
159
+ raise ValueError("tools lists a capability twice")
160
+ if self.escalate_to is not None and (
161
+ _TIER_ORDER[self.escalate_to] <= _TIER_ORDER[self.default_tier]
162
+ ):
163
+ raise ValueError(
164
+ f"escalate_to {self.escalate_to!r} must be higher than "
165
+ f"default_tier {self.default_tier!r}"
166
+ )
167
+ writes = "write_outputs" in self.tools
168
+ if writes != (self.write_scope != "none"):
169
+ raise ValueError("write_outputs is granted exactly when write_scope is not 'none'")
170
+ for name in _PLACEHOLDER_RE.findall(self.prompt):
171
+ if name not in self.tools:
172
+ raise ValueError(
173
+ f"prompt names {{tool:{name}}}, a capability the role is not granted"
174
+ )
175
+ return self
176
+
177
+ @property
178
+ def can_read_files(self) -> bool:
179
+ """Whether the role has its own file-reading or file-search tools."""
180
+ return "read_files" in self.tools or "search_files" in self.tools
181
+
182
+ def render_prompt(self, tool_names: dict[str, tuple[str, ...]]) -> str:
183
+ """The prompt with each `{tool:<capability>}` replaced by the runtime's tool names."""
184
+
185
+ def _replace(match: re.Match[str]) -> str:
186
+ names = tool_names.get(match.group(1))
187
+ if not names:
188
+ raise RoleError(f"role {self.id!r}: no tool for capability {match.group(1)!r}")
189
+ return ", ".join(f"`{name}`" for name in names)
190
+
191
+ return _PLACEHOLDER_RE.sub(_replace, self.prompt)
192
+
193
+
194
+ def split_frontmatter(text: str, *, source: str) -> tuple[dict[str, object], str]:
195
+ """Split a markdown file into its YAML frontmatter mapping and its body."""
196
+ if not text.startswith("---\n"):
197
+ raise ValueError(f"{source}: the frontmatter must open with '---' on the first line")
198
+ head, sep, body = text[4:].partition("\n---\n")
199
+ if not sep:
200
+ raise ValueError(f"{source}: the frontmatter is not closed with '---'")
201
+ data = yaml.safe_load(head)
202
+ if not isinstance(data, dict):
203
+ raise ValueError(f"{source}: the frontmatter is not a mapping")
204
+ text = body.strip("\n")
205
+ return {str(k): v for k, v in data.items()}, (text + "\n") if text.strip() else ""
206
+
207
+
208
+ def parse_role_file(path: Path) -> RoleSpec:
209
+ """Load one role file (frontmatter + prompt body). Raises `RoleError`."""
210
+ try:
211
+ data, body = split_frontmatter(path.read_text(encoding="utf-8"), source=str(path))
212
+ if "prompt" in data:
213
+ raise ValueError(f"{path}: the prompt is the file's body, not a frontmatter key")
214
+ role = RoleSpec.model_validate({**data, "prompt": body})
215
+ except (OSError, ValueError, ValidationError, yaml.YAMLError) as exc:
216
+ raise RoleError(f"invalid role file {path}: {exc}") from exc
217
+ if role.id != path.stem:
218
+ raise RoleError(f"role file {path} defines role {role.id!r}; name it {role.id}.md")
219
+ return role
220
+
221
+
222
+ __all__ = [
223
+ "CAPABILITIES",
224
+ "Capability",
225
+ "ReadPolicy",
226
+ "RoleError",
227
+ "RoleId",
228
+ "RoleSpec",
229
+ "WriteScope",
230
+ "parse_role_file",
231
+ "split_frontmatter",
232
+ ]
@@ -0,0 +1 @@
1
+ """Engine state: journal, run directories, locks, and the artifact store."""
@@ -0,0 +1,156 @@
1
+ """Content hashing, data labels, and the file-based artifact store.
2
+
3
+ Pure Python: no subprocess calls and no knowledge of git or any other VCS. The store
4
+ only ever hashes bytes it reads itself; `chipgraph.adapters.vcs` is what talks to git.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from collections.abc import Iterable, Mapping
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ from chipgraph.core.contracts.artifact import Artifact, ArtifactRef, ProducedBy
14
+ from chipgraph.core.contracts.types import ArtifactKind, DataLabel, Sha256
15
+
16
+ _CHUNK_SIZE = 1024 * 1024
17
+
18
+
19
+ def hash_bytes(data: bytes) -> str:
20
+ """Return the lowercase hex sha256 digest of `data`."""
21
+ return hashlib.sha256(data).hexdigest()
22
+
23
+
24
+ def hash_file(path: Path) -> str:
25
+ """Return the lowercase hex sha256 digest of the file at `path`, read in chunks."""
26
+ digest = hashlib.sha256()
27
+ with path.open("rb") as handle:
28
+ while chunk := handle.read(_CHUNK_SIZE):
29
+ digest.update(chunk)
30
+ return digest.hexdigest()
31
+
32
+
33
+ def hash_inputs(hashes: Iterable[tuple[str, str]]) -> str:
34
+ """Return a stable sha256 over `(key, hash)` pairs, independent of iteration order.
35
+
36
+ Each pair is typically `("<repo>:<path or model_key>", content_hash)`. Pairs are
37
+ sorted before hashing so the same set of inputs always yields the same digest,
38
+ regardless of the order they were produced or discovered in.
39
+ """
40
+ digest = hashlib.sha256()
41
+ for key, value in sorted(hashes):
42
+ digest.update(key.encode("utf-8"))
43
+ digest.update(b"\0")
44
+ digest.update(value.encode("utf-8"))
45
+ digest.update(b"\n")
46
+ return digest.hexdigest()
47
+
48
+
49
+ class LabelRules:
50
+ """Maps a repo-relative path to a `DataLabel` by matching POSIX globs.
51
+
52
+ Built from an ordered mapping of `glob -> DataLabel` plus a default label used
53
+ when nothing matches. The most specific matching glob wins: the longest pattern
54
+ string; ties are broken by preferring the entry that appears later in `globs`.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ globs: Mapping[str, DataLabel] | None = None,
60
+ *,
61
+ default: DataLabel = "internal",
62
+ ) -> None:
63
+ self._globs: tuple[tuple[str, DataLabel], ...] = tuple((globs or {}).items())
64
+ self._default = default
65
+
66
+ @property
67
+ def default(self) -> DataLabel:
68
+ """The label used when no glob matches."""
69
+ return self._default
70
+
71
+ def label_for(self, rel_path: str) -> DataLabel:
72
+ """Return the label for `rel_path`, applying the most specific matching glob."""
73
+ path = PurePosixPath(rel_path)
74
+ best_pattern: str | None = None
75
+ best_label: DataLabel = self._default
76
+ for pattern, label in self._globs:
77
+ if not _glob_matches(path, pattern):
78
+ continue
79
+ if best_pattern is None or len(pattern) >= len(best_pattern):
80
+ best_pattern = pattern
81
+ best_label = label
82
+ return best_label
83
+
84
+
85
+ def _glob_matches(path: PurePosixPath, pattern: str) -> bool:
86
+ """Match `path` against a POSIX glob `pattern`, where `**` spans directories."""
87
+ return path.full_match(pattern)
88
+
89
+
90
+ class ArtifactStore:
91
+ """Reads and hashes file-based artifacts under `root`, applying `labels`."""
92
+
93
+ def __init__(self, root: Path, labels: LabelRules, repo: str = ".") -> None:
94
+ self.root = root
95
+ self.labels = labels
96
+ self.repo = repo
97
+
98
+ def ref(self, rel_path: str, kind: ArtifactKind) -> ArtifactRef:
99
+ """Build an `ArtifactRef` for `rel_path`, applying the configured data label."""
100
+ return ArtifactRef(
101
+ repo=self.repo,
102
+ kind=kind,
103
+ path=rel_path,
104
+ label=self.labels.label_for(rel_path),
105
+ )
106
+
107
+ def artifact(
108
+ self,
109
+ rel_path: str,
110
+ kind: ArtifactKind,
111
+ produced_by: ProducedBy | None = None,
112
+ inputs_hash: Sha256 | None = None,
113
+ ) -> Artifact:
114
+ """Build an `Artifact` for `rel_path`, hashing its current content.
115
+
116
+ Raises `FileNotFoundError` with a clear message if the file does not exist
117
+ under `root`.
118
+ """
119
+ ref = self.ref(rel_path, kind)
120
+ full_path = self.root / rel_path
121
+ if not full_path.is_file():
122
+ raise FileNotFoundError(
123
+ f"cannot hash artifact {rel_path!r}: no such file under {self.root}"
124
+ )
125
+ return Artifact(
126
+ ref=ref,
127
+ content_hash=hash_file(full_path),
128
+ produced_by=produced_by,
129
+ inputs_hash=inputs_hash,
130
+ )
131
+
132
+ def exists(self, ref: ArtifactRef) -> bool:
133
+ """Return whether `ref` currently exists on disk (path-based refs only)."""
134
+ rel_path = self._require_path(ref)
135
+ return (self.root / rel_path).is_file()
136
+
137
+ def current_hashes(self, refs: Iterable[ArtifactRef]) -> dict[str, str]:
138
+ """Return `{"<repo>:<path>": content_hash}` for every `ref` that exists.
139
+
140
+ Refs whose file is missing are skipped (not included in the result), rather
141
+ than mapped to an empty-string hash.
142
+ """
143
+ result: dict[str, str] = {}
144
+ for ref in refs:
145
+ rel_path = self._require_path(ref)
146
+ full_path = self.root / rel_path
147
+ if not full_path.is_file():
148
+ continue
149
+ result[f"{ref.repo}:{rel_path}"] = hash_file(full_path)
150
+ return result
151
+
152
+ @staticmethod
153
+ def _require_path(ref: ArtifactRef) -> str:
154
+ if ref.path is None:
155
+ raise ValueError("model artifacts are hashed by the Design Model")
156
+ return ref.path
@@ -0,0 +1,117 @@
1
+ """State backends: where `.chipgraph/state/` actually lives (DESIGN.md 6.1).
2
+
3
+ This task implements only the `local` backend, which keeps state on the local disk under
4
+ the project repo, hidden from git via `.git/info/exclude` (never by editing a tracked
5
+ `.gitignore`). The `branch:<name>` (and `repo:...`) backends, which push run state to an
6
+ orphan branch or a separate state repository so a run can be resumed or shared from
7
+ another machine, need git operations and are left to a later task in the adapters layer —
8
+ `chipgraph.core` must not shell out to git (see AGENTS.md: "no subprocess, no git commands
9
+ in this code").
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Protocol, runtime_checkable
16
+
17
+ from chipgraph.core.state.layout import StateLayout
18
+
19
+ _EXCLUDE_LINE = "/.chipgraph/state/"
20
+
21
+
22
+ @runtime_checkable
23
+ class StateBackend(Protocol):
24
+ """Where a project's run state is stored, and how to make sure it's ready to use."""
25
+
26
+ name: str
27
+
28
+ def prepare(self) -> None:
29
+ """Make sure the backend is ready to use (e.g. create directories, hide from git)."""
30
+ ...
31
+
32
+ @property
33
+ def layout(self) -> StateLayout:
34
+ """The paths this backend stores state under."""
35
+ ...
36
+
37
+
38
+ def _git_common_dir(root: Path) -> Path | None:
39
+ """Resolve `root/.git`'s common git dir, without shelling out to git.
40
+
41
+ Handles both a plain repo (`.git` is a directory) and a worktree (`.git` is a file
42
+ containing `gitdir: <path>`, whose target directory has a `commondir` file pointing at
43
+ the real common git dir, relative to that `gitdir` path).
44
+ """
45
+ git_path = root / ".git"
46
+ if git_path.is_dir():
47
+ return git_path
48
+ if not git_path.is_file():
49
+ return None
50
+
51
+ content = git_path.read_text(encoding="utf-8").strip()
52
+ prefix = "gitdir:"
53
+ if not content.startswith(prefix):
54
+ return None
55
+ gitdir = Path(content[len(prefix) :].strip())
56
+ if not gitdir.is_absolute():
57
+ gitdir = (root / gitdir).resolve()
58
+
59
+ commondir_file = gitdir / "commondir"
60
+ if not commondir_file.exists():
61
+ # Not a linked worktree: gitdir *is* the common dir.
62
+ return gitdir
63
+
64
+ common = Path(commondir_file.read_text(encoding="utf-8").strip())
65
+ if not common.is_absolute():
66
+ common = (gitdir / common).resolve()
67
+ return common
68
+
69
+
70
+ class LocalBackend:
71
+ """Keeps run state on local disk under the project repo, hidden from git.
72
+
73
+ `prepare()` creates the state directory and, if the project is a git repo, appends
74
+ `/.chipgraph/state/` to that repo's `.git/info/exclude` — never to a tracked
75
+ `.gitignore` — so state never shows up in `git status` for anyone. It is idempotent:
76
+ calling it again does not duplicate the exclude line or otherwise change tracked files.
77
+ """
78
+
79
+ name = "local"
80
+
81
+ def __init__(self, root: Path) -> None:
82
+ self.root = root
83
+ self._layout = StateLayout(root)
84
+
85
+ @property
86
+ def layout(self) -> StateLayout:
87
+ return self._layout
88
+
89
+ def prepare(self) -> None:
90
+ self._layout.state_dir.mkdir(parents=True, exist_ok=True)
91
+ common_dir = _git_common_dir(self.root)
92
+ if common_dir is None:
93
+ return
94
+ exclude_path = common_dir / "info" / "exclude"
95
+ exclude_path.parent.mkdir(parents=True, exist_ok=True)
96
+ existing = ""
97
+ if exclude_path.exists():
98
+ existing = exclude_path.read_text(encoding="utf-8")
99
+ lines = existing.splitlines()
100
+ if _EXCLUDE_LINE in lines:
101
+ return
102
+ with open(exclude_path, "a", encoding="utf-8") as f:
103
+ if existing and not existing.endswith("\n"):
104
+ f.write("\n")
105
+ f.write(_EXCLUDE_LINE + "\n")
106
+
107
+
108
+ def backend_from_config(value: str, root: Path) -> StateBackend:
109
+ """Build a `StateBackend` from a `.chipgraph.yml` `state.backend` value."""
110
+ if value == "local":
111
+ return LocalBackend(root)
112
+ if value.startswith("branch:") or value.startswith("repo:"):
113
+ raise NotImplementedError(f"state backend {value!r} lands with the git state backend task")
114
+ raise ValueError(f"unknown state backend: {value!r}")
115
+
116
+
117
+ __all__ = ["LocalBackend", "StateBackend", "backend_from_config"]
@@ -0,0 +1,151 @@
1
+ """The finding store, and how a waiver's currency is computed (DESIGN.md 4.8, 6.1).
2
+
3
+ Findings are machine-local state, not a repo artifact (DESIGN.md 6.1 "State chạy máy"):
4
+ one JSON file per `fingerprint` under ``<state_dir>/findings/``. A waiver, by contrast,
5
+ is a person's decision and lives in ``.chipgraph/decisions/`` as an `Approval` (through
6
+ the `file` review adapter); this module only defines what a waiver *is* -- an
7
+ `Approval` with `decision == "waive"`, bound by `gate_id` to a finding's id -- and how to
8
+ tell whether one is still current. Actually writing a waiver decision (which needs the
9
+ `ReviewAdapter`) is an app-layer concern (`chipgraph.app.findings.waive`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import builtins
15
+ import os
16
+ from collections.abc import Iterable, Mapping
17
+ from pathlib import Path
18
+
19
+ from chipgraph.core.contracts import Approval
20
+ from chipgraph.core.contracts.finding import Finding, FindingSeverity, FindingStatus
21
+ from chipgraph.core.state.layout import StateLayout
22
+
23
+
24
+ def waiver_gate_id(finding_id: str) -> str:
25
+ """The `Approval.gate_id` a waiver for `finding_id` is recorded (and looked up) under."""
26
+ return f"finding:{finding_id}"
27
+
28
+
29
+ def effective_status(
30
+ finding: Finding, waivers: Iterable[Approval], current_hashes: Mapping[str, str]
31
+ ) -> FindingStatus:
32
+ """The finding's status right now, resolving waiver currency against `current_hashes`.
33
+
34
+ - `"fixed"` if the stored finding is already marked fixed.
35
+ - `"waived"` only if at least one of `waivers` has `decision == "waive"` and is
36
+ still current: every artifact hash it is bound to still matches `current_hashes`
37
+ (`Approval.is_current`). A waiver bound to a hash that no longer matches -- the
38
+ artifact changed -- no longer counts: the finding is reported `"open"` again, and
39
+ the expiry is visible to the caller by simply not finding a current waiver.
40
+ - `"open"` otherwise.
41
+ """
42
+ if finding.status == "fixed":
43
+ return "fixed"
44
+ for waiver in waivers:
45
+ if waiver.decision == "waive" and waiver.is_current(current_hashes):
46
+ return "waived"
47
+ return "open"
48
+
49
+
50
+ class FindingStore:
51
+ """Stores `Finding`s as one JSON file per fingerprint, under the state backend."""
52
+
53
+ def __init__(self, layout: StateLayout) -> None:
54
+ self.layout = layout
55
+ self._dir = layout.state_dir / "findings"
56
+
57
+ def _path(self, fingerprint: str) -> Path:
58
+ return self._dir / f"{fingerprint}.json"
59
+
60
+ def get(self, fingerprint: str) -> Finding | None:
61
+ """Return the stored finding for `fingerprint`, or `None` if there is none."""
62
+ path = self._path(fingerprint)
63
+ if not path.is_file():
64
+ return None
65
+ return Finding.model_validate_json(path.read_text(encoding="utf-8"))
66
+
67
+ def get_by_id(self, finding_id: str) -> Finding | None:
68
+ """Return the stored finding whose `id` is `finding_id`, or `None`."""
69
+ for finding in self.list():
70
+ if finding.id == finding_id:
71
+ return finding
72
+ return None
73
+
74
+ def _put(self, finding: Finding) -> None:
75
+ self._dir.mkdir(parents=True, exist_ok=True)
76
+ path = self._path(finding.fingerprint)
77
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
78
+ tmp_path.write_text(finding.model_dump_json(indent=2), encoding="utf-8")
79
+ os.replace(tmp_path, path)
80
+
81
+ def upsert(self, findings: Iterable[Finding]) -> list[Finding]:
82
+ """Insert or refresh `findings`, keyed by `fingerprint`.
83
+
84
+ `last_seen` is taken from each incoming `finding` as given (the caller decides
85
+ what "now" means, e.g. a run's start time). For a finding already on disk:
86
+ `first_seen` is kept from the existing record, and `status` is kept as
87
+ `"waived"` if it already was (a waiver survives re-detection; its currency is
88
+ decided separately by `effective_status`), otherwise the incoming finding's
89
+ status is used (so a `"fixed"` finding that is detected again goes back to
90
+ `"open"`).
91
+ """
92
+ stored: list[Finding] = []
93
+ for finding in findings:
94
+ existing = self.get(finding.fingerprint)
95
+ if existing is not None:
96
+ status = "waived" if existing.status == "waived" else finding.status
97
+ finding = finding.model_copy(
98
+ update={"first_seen": existing.first_seen, "status": status}
99
+ )
100
+ self._put(finding)
101
+ stored.append(finding)
102
+ return stored
103
+
104
+ def list(
105
+ self,
106
+ *,
107
+ status: FindingStatus | None = None,
108
+ layer: int | None = None,
109
+ severity: FindingSeverity | None = None,
110
+ source: str | None = None,
111
+ ) -> list[Finding]:
112
+ """Return every stored finding matching the given filters, sorted by `id`."""
113
+ if not self._dir.is_dir():
114
+ return []
115
+ results: list[Finding] = []
116
+ for path in sorted(self._dir.glob("*.json")):
117
+ finding = Finding.model_validate_json(path.read_text(encoding="utf-8"))
118
+ if status is not None and finding.status != status:
119
+ continue
120
+ if layer is not None and finding.layer != layer:
121
+ continue
122
+ if severity is not None and finding.severity != severity:
123
+ continue
124
+ if source is not None and finding.source != source:
125
+ continue
126
+ results.append(finding)
127
+ results.sort(key=lambda f: f.id)
128
+ return results
129
+
130
+ def mark_fixed(
131
+ self, seen_fingerprints: Iterable[str], *, source: str
132
+ ) -> builtins.list[Finding]:
133
+ """Mark open findings from `source` as `"fixed"` when not in `seen_fingerprints`.
134
+
135
+ Meant to be called after a full run of `source` over the whole project (e.g.
136
+ `/audit`, M1-20): any of that source's open findings not rediscovered in this
137
+ run are no longer present, so they are fixed. Returns the findings that were
138
+ just marked fixed.
139
+ """
140
+ seen = set(seen_fingerprints)
141
+ fixed: builtins.list[Finding] = []
142
+ for finding in self.list(source=source, status="open"):
143
+ if finding.fingerprint in seen:
144
+ continue
145
+ updated = finding.model_copy(update={"status": "fixed"})
146
+ self._put(updated)
147
+ fixed.append(updated)
148
+ return fixed
149
+
150
+
151
+ __all__ = ["FindingStore", "effective_status", "waiver_gate_id"]