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,411 @@
1
+ """HANDOFF.md and the status summary (DESIGN.md 6.2).
2
+
3
+ Every agent session starts by reading ``HANDOFF.md``: what was done, who is waited on,
4
+ open questions, and the next step. This module derives that summary — and the compact
5
+ ``chipgraph status`` table — purely from a run's journal events, using
6
+ :func:`chipgraph.core.state.journal.replay` for per-instance status and reading messages,
7
+ gate ids and open questions straight out of the event payloads the scheduler writes
8
+ (``core/engine/scheduler.py``).
9
+
10
+ Payload keys relied on (as written by ``Scheduler._process_instance``/``_execute``):
11
+
12
+ - ``rule_done``: ``payload["skipped"] == "fresh"`` marks a fresh (not rebuilt) instance.
13
+ - ``rule_fail``: ``payload["message"]`` is the human-readable failure message;
14
+ ``event.failure_label`` carries the :class:`FailureLabel`.
15
+ - ``gate_wait``: ``payload["gate"]`` is the (already-formatted) gate id.
16
+ - ``rule_fail``/``agent_turn``: an optional ``payload["open_questions"]`` list of strings.
17
+ - ``rule_fail`` of an agent rule (``core/engine/agent_rule.py``): ``payload["agent"]``, the
18
+ loop's summary (status, tries, max_tries, infra_failures, reason, label, failed_checks,
19
+ failures, tokens, cost, stopped_earlier); read into :class:`AgentStopInfo` so HANDOFF.md
20
+ says how many tries were used, why the loop stopped and what failed last.
21
+ - ``run_stop``: only carries *counts* today (``done``, ``failed``, ...), not instance ids.
22
+ The scheduler currently emits no per-instance event for a *blocked* instance (one whose
23
+ dependency failed/waited/was itself blocked), so ``build_handoff`` reads blocked instance
24
+ ids from an optional ``payload["blocked"]`` list on the last ``run_stop`` event if present,
25
+ and otherwise reports no blocked instances. This is forward-compatible with the scheduler
26
+ later being extended to record that list; it is not a change to the scheduler itself.
27
+
28
+ A HANDOFF.md is a snapshot of the run that wrote it. When an agent task stops, the file is
29
+ written at once, for the run that dispatched the task's last try; a task accepted after that
30
+ still shows as waiting there. The next ``next_task`` or ``build`` writes its own, accurate one.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ from collections.abc import Mapping, Sequence
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, ValidationError
42
+
43
+ from chipgraph.core.contracts.event import Event
44
+ from chipgraph.core.contracts.types import FailureLabel
45
+ from chipgraph.core.state.journal import RunState, replay
46
+ from chipgraph.core.state.layout import StateLayout
47
+
48
+ _FAILURE_NEXT_STEP: dict[FailureLabel, str] = {
49
+ "constraint": "revert the hand edit or approve it, then `chipgraph build {target}`",
50
+ "context": "add the missing input or output, then `chipgraph resume {run_id}`",
51
+ "verification": "fix the failing check, then `chipgraph resume {run_id}`",
52
+ "infra": "check the tool or connection (`chipgraph doctor`), then `chipgraph resume {run_id}`",
53
+ "planning": "answer the open questions, then `chipgraph resume {run_id}`",
54
+ }
55
+ _DEFAULT_NEXT_STEP = "`chipgraph resume {run_id}`"
56
+ _AGENT_STEP_KEY = {"needs_human": "needs_human", "planning": "needs_human", "infra": "infra"}
57
+ """An agent stop reason -> its `_AGENT_NEXT_STEP` key; any other reason is `exhausted`."""
58
+ _AGENT_NEXT_STEP = {
59
+ "needs_human": (
60
+ "answer the open questions for `{instance}` (update its inputs), then "
61
+ "`chipgraph build {target}`"
62
+ ),
63
+ "infra": (
64
+ "check the tool or connection (`chipgraph doctor`), then `chipgraph rewind {instance}` "
65
+ "and `chipgraph build {target}`: the stop is remembered until the instance is rewound "
66
+ "or an input changes, so `resume` would not run it again"
67
+ ),
68
+ "exhausted": (
69
+ "read the last failures of `{instance}`; fix the cause (its inputs, the rule or "
70
+ "its budget) or write the output by hand, then `chipgraph rewind {instance}` and "
71
+ "`chipgraph build {target}`"
72
+ ),
73
+ }
74
+ _NOTHING_TO_DO = "nothing to do: the target is up to date."
75
+
76
+
77
+ class AgentStopInfo(BaseModel):
78
+ """How an agent rule's loop stopped, from its ``rule_fail`` payload's ``agent`` key."""
79
+
80
+ model_config = ConfigDict(frozen=True, extra="ignore")
81
+
82
+ status: str = ""
83
+ reason: str | None = None
84
+ tries: int = 0
85
+ max_tries: int = 0
86
+ infra_failures: int = 0
87
+ label: FailureLabel | None = None
88
+ failed_checks: tuple[str, ...] = ()
89
+ failures: tuple[str, ...] = ()
90
+ tokens: int | None = None
91
+ cost: float | None = None
92
+ stopped_earlier: bool = False
93
+
94
+
95
+ class FailedItem(BaseModel):
96
+ """One failed rule instance."""
97
+
98
+ model_config = ConfigDict(frozen=True, extra="forbid")
99
+
100
+ instance: str
101
+ label: FailureLabel | None = None
102
+ message: str = ""
103
+ agent: AgentStopInfo | None = None
104
+
105
+
106
+ def _agent_info(payload: dict[str, Any]) -> AgentStopInfo | None:
107
+ raw = payload.get("agent")
108
+ if not isinstance(raw, dict):
109
+ return None
110
+ try:
111
+ return AgentStopInfo.model_validate(raw)
112
+ except ValidationError:
113
+ return None
114
+
115
+
116
+ class WaitingItem(BaseModel):
117
+ """One rule instance waiting on a human gate."""
118
+
119
+ model_config = ConfigDict(frozen=True, extra="forbid")
120
+
121
+ instance: str
122
+ gate_id: str
123
+ who: tuple[str, ...] = ()
124
+
125
+
126
+ class Handoff(BaseModel):
127
+ """Everything the next agent session needs on resume (DESIGN.md 6.2)."""
128
+
129
+ model_config = ConfigDict(frozen=True, extra="forbid")
130
+
131
+ run_id: str
132
+ target: str
133
+ generated_at: AwareDatetime
134
+ done: tuple[str, ...] = ()
135
+ skipped_fresh: tuple[str, ...] = ()
136
+ failed: tuple[FailedItem, ...] = ()
137
+ waiting_gate: tuple[WaitingItem, ...] = ()
138
+ blocked: tuple[str, ...] = ()
139
+ open_questions: tuple[str, ...] = ()
140
+ next_steps: tuple[str, ...] = ()
141
+
142
+
143
+ def _collect_open_questions(events: Sequence[Event]) -> tuple[str, ...]:
144
+ seen: set[str] = set()
145
+ questions: list[str] = []
146
+ for event in events:
147
+ if event.type not in ("rule_fail", "agent_turn"):
148
+ continue
149
+ raw = event.payload.get("open_questions")
150
+ if not isinstance(raw, list):
151
+ continue
152
+ for item in raw:
153
+ text = str(item)
154
+ if text not in seen:
155
+ seen.add(text)
156
+ questions.append(text)
157
+ return tuple(questions)
158
+
159
+
160
+ def _next_steps(
161
+ *,
162
+ target: str,
163
+ run_id: str,
164
+ failed: tuple[FailedItem, ...],
165
+ waiting_gate: tuple[WaitingItem, ...],
166
+ blocked: tuple[str, ...],
167
+ ) -> tuple[str, ...]:
168
+ steps: list[str] = []
169
+ for wait_item in waiting_gate:
170
+ steps.append(f"`chipgraph approve {wait_item.gate_id} --instance {wait_item.instance}`")
171
+ for fail_item in failed:
172
+ if fail_item.agent is not None and fail_item.agent.reason is not None:
173
+ key = _AGENT_STEP_KEY.get(fail_item.agent.reason, "exhausted")
174
+ template = _AGENT_NEXT_STEP[key]
175
+ elif fail_item.label is not None:
176
+ template = _FAILURE_NEXT_STEP.get(fail_item.label, _DEFAULT_NEXT_STEP)
177
+ else:
178
+ template = _DEFAULT_NEXT_STEP
179
+ steps.append(template.format(target=target, run_id=run_id, instance=fail_item.instance))
180
+
181
+ if not steps and not blocked:
182
+ return (_NOTHING_TO_DO,)
183
+
184
+ seen: set[str] = set()
185
+ deduped: list[str] = []
186
+ for step in steps:
187
+ if step not in seen:
188
+ seen.add(step)
189
+ deduped.append(step)
190
+ return tuple(deduped)
191
+
192
+
193
+ def build_handoff(
194
+ events: Sequence[Event],
195
+ *,
196
+ target: str,
197
+ approvers: Mapping[str, Sequence[str]] | None = None,
198
+ now: datetime | None = None,
199
+ ) -> Handoff:
200
+ """Derive a :class:`Handoff` from a run's journal events.
201
+
202
+ ``approvers`` maps a gate id to the people known to approve it (informational only;
203
+ the scheduler does not record approvers in the journal). ``now`` overrides the
204
+ generated-at timestamp, mainly for deterministic tests.
205
+ """
206
+ ordered = sorted(events, key=lambda event: event.seq)
207
+ state: RunState = replay(list(ordered))
208
+ approver_map = approvers or {}
209
+
210
+ last_rule_done: dict[str, Event] = {}
211
+ last_rule_fail: dict[str, Event] = {}
212
+ last_gate_wait: dict[str, Event] = {}
213
+ last_run_stop: Event | None = None
214
+ for event in ordered:
215
+ if event.rule_instance is not None:
216
+ if event.type == "rule_done":
217
+ last_rule_done[event.rule_instance] = event
218
+ elif event.type == "rule_fail":
219
+ last_rule_fail[event.rule_instance] = event
220
+ elif event.type == "gate_wait":
221
+ last_gate_wait[event.rule_instance] = event
222
+ if event.type == "run_stop":
223
+ last_run_stop = event
224
+
225
+ done: list[str] = []
226
+ skipped_fresh: list[str] = []
227
+ failed: list[FailedItem] = []
228
+ waiting_gate: list[WaitingItem] = []
229
+
230
+ for iid in sorted(state.rules):
231
+ status = state.rules[iid]
232
+ if status == "done":
233
+ done_event = last_rule_done.get(iid)
234
+ if done_event is not None and done_event.payload.get("skipped") == "fresh":
235
+ skipped_fresh.append(iid)
236
+ else:
237
+ done.append(iid)
238
+ elif status == "failed":
239
+ fail_event = last_rule_fail.get(iid)
240
+ message = str(fail_event.payload.get("message", "")) if fail_event is not None else ""
241
+ agent = _agent_info(fail_event.payload) if fail_event is not None else None
242
+ failed.append(
243
+ FailedItem(
244
+ instance=iid, label=state.failures.get(iid), message=message, agent=agent
245
+ )
246
+ )
247
+ elif status == "waiting_gate":
248
+ wait_event = last_gate_wait.get(iid)
249
+ gate_id = str(wait_event.payload.get("gate", "")) if wait_event is not None else ""
250
+ waiting_gate.append(
251
+ WaitingItem(instance=iid, gate_id=gate_id, who=tuple(approver_map.get(gate_id, ())))
252
+ )
253
+
254
+ blocked_ids: list[str] = []
255
+ if last_run_stop is not None:
256
+ raw_blocked = last_run_stop.payload.get("blocked")
257
+ if isinstance(raw_blocked, list):
258
+ blocked_ids = [str(iid) for iid in raw_blocked]
259
+ blocked = tuple(sorted(set(blocked_ids)))
260
+
261
+ failed_t = tuple(failed)
262
+ waiting_gate_t = tuple(waiting_gate)
263
+ generated_at = now if now is not None else datetime.now(UTC)
264
+
265
+ return Handoff(
266
+ run_id=state.run_id,
267
+ target=target,
268
+ generated_at=generated_at,
269
+ done=tuple(done),
270
+ skipped_fresh=tuple(skipped_fresh),
271
+ failed=failed_t,
272
+ waiting_gate=waiting_gate_t,
273
+ blocked=blocked,
274
+ open_questions=_collect_open_questions(ordered),
275
+ next_steps=_next_steps(
276
+ target=target,
277
+ run_id=state.run_id,
278
+ failed=failed_t,
279
+ waiting_gate=waiting_gate_t,
280
+ blocked=blocked,
281
+ ),
282
+ )
283
+
284
+
285
+ def render_markdown(h: Handoff) -> str:
286
+ """Render a short, deterministic ``HANDOFF.md`` body for a :class:`Handoff`."""
287
+ lines: list[str] = [
288
+ f"# Handoff — {h.target}",
289
+ f"Run `{h.run_id}` — generated {h.generated_at.isoformat()}",
290
+ "",
291
+ ]
292
+
293
+ total_done = len(h.done) + len(h.skipped_fresh)
294
+ if total_done:
295
+ lines.append("## Done")
296
+ lines.append("")
297
+ if h.skipped_fresh:
298
+ lines.append(f"{total_done} done ({len(h.skipped_fresh)} already up to date, skipped).")
299
+ else:
300
+ lines.append(f"{total_done} done.")
301
+ if h.done:
302
+ lines.append("")
303
+ for instance in h.done:
304
+ lines.append(f"- {instance}")
305
+ lines.append("")
306
+
307
+ if h.waiting_gate:
308
+ lines.append("## Waiting for people")
309
+ lines.append("")
310
+ for wait_item in h.waiting_gate:
311
+ who = ", ".join(wait_item.who) if wait_item.who else "unknown"
312
+ lines.append(
313
+ f"- gate `{wait_item.gate_id}` for `{wait_item.instance}` — waiting on: {who}"
314
+ )
315
+ lines.append("")
316
+
317
+ if h.failed:
318
+ lines.append("## Failed")
319
+ lines.append("")
320
+ for fail_item in h.failed:
321
+ label = fail_item.label if fail_item.label is not None else "unlabeled"
322
+ lines.append(f"- `{fail_item.instance}` [{label}]: {fail_item.message}")
323
+ if fail_item.agent is not None:
324
+ lines.extend(_render_agent(fail_item.agent))
325
+ lines.append("")
326
+
327
+ if h.blocked:
328
+ lines.append("## Blocked")
329
+ lines.append("")
330
+ lines.append(f"{len(h.blocked)} blocked:")
331
+ for instance in h.blocked:
332
+ lines.append(f"- {instance}")
333
+ lines.append("")
334
+
335
+ if h.open_questions:
336
+ lines.append("## Open questions")
337
+ lines.append("")
338
+ for question in h.open_questions:
339
+ lines.append(f"- {question}")
340
+ lines.append("")
341
+
342
+ lines.append("## Next step")
343
+ lines.append("")
344
+ for i, step in enumerate(h.next_steps, start=1):
345
+ lines.append(f"{i}. {step}")
346
+
347
+ return "\n".join(lines) + "\n"
348
+
349
+
350
+ def _render_agent(info: AgentStopInfo) -> list[str]:
351
+ """The indented detail lines of an agent rule's stop, under its failed item."""
352
+ used = f"{info.tries} of {info.max_tries} tries"
353
+ if info.infra_failures:
354
+ used += f" (+{info.infra_failures} infra failures, not counted)"
355
+ spent = []
356
+ if info.tokens is not None:
357
+ spent.append(f"{info.tokens} tokens")
358
+ if info.cost is not None:
359
+ spent.append(f"${info.cost:.4f}")
360
+ lines = [f" - agent: {info.status}, reason `{info.reason}`, {used}"]
361
+ if spent:
362
+ lines[0] += "; spent " + ", ".join(spent)
363
+ if info.failures:
364
+ lines.append(f" - last failures [{info.label}]:")
365
+ lines.extend(f" - {failure}" for failure in info.failures)
366
+ elif info.failed_checks:
367
+ lines.append(f" - last failures [{info.label}]: {', '.join(info.failed_checks)}")
368
+ if info.stopped_earlier:
369
+ lines.append(" - stopped in an earlier run; not run again until an input changes")
370
+ return lines
371
+
372
+
373
+ def render_status(state: RunState, *, target: str) -> str:
374
+ """Render a compact plain-text status table for ``chipgraph status``."""
375
+ lines: list[str] = []
376
+ counts: dict[str, int] = {}
377
+ for iid in sorted(state.rules):
378
+ status = state.rules[iid]
379
+ counts[status] = counts.get(status, 0) + 1
380
+ lines.append(f"{status.upper():<12} {iid}")
381
+
382
+ total = len(state.rules)
383
+ parts = ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
384
+ summary = f"{total} rule(s) for {target}"
385
+ if parts:
386
+ summary += f": {parts}"
387
+ lines.append(summary)
388
+
389
+ return "\n".join(lines) + "\n"
390
+
391
+
392
+ def write_handoff(layout: StateLayout, h: Handoff) -> Path:
393
+ """Atomically write ``HANDOFF.md`` for a run's directory and return its path."""
394
+ path = layout.run_dir(h.run_id) / "HANDOFF.md"
395
+ path.parent.mkdir(parents=True, exist_ok=True)
396
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
397
+ tmp_path.write_text(render_markdown(h), encoding="utf-8")
398
+ os.replace(tmp_path, path)
399
+ return path
400
+
401
+
402
+ __all__ = [
403
+ "AgentStopInfo",
404
+ "FailedItem",
405
+ "Handoff",
406
+ "WaitingItem",
407
+ "build_handoff",
408
+ "render_markdown",
409
+ "render_status",
410
+ "write_handoff",
411
+ ]
@@ -0,0 +1,59 @@
1
+ """Idempotency store: DESIGN.md 6.2, "Mỗi lần gọi tool có khóa idempotency, nên resume
2
+ không chạy lại sim hay synth đã xong" (every tool call has an idempotency key, so resume
3
+ does not re-run simulation or synthesis that already finished).
4
+
5
+ Results are stored as `CheckResult` JSON, one file per key, named by the key's SHA-256
6
+ hash, under the run's directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+
16
+ from chipgraph.core.contracts.check import CheckResult
17
+ from chipgraph.core.state.layout import StateLayout
18
+
19
+
20
+ def make_key(parts: Mapping[str, str]) -> str:
21
+ """A stable idempotency key: SHA-256 over `parts`, sorted by key, as ``k=v`` lines.
22
+
23
+ Callers pass whatever makes a tool call reproducible: the command, cwd, input hashes,
24
+ tool version, and so on.
25
+ """
26
+ lines = "\n".join(f"{k}={parts[k]}" for k in sorted(parts))
27
+ return hashlib.sha256(lines.encode("utf-8")).hexdigest()
28
+
29
+
30
+ class IdempotencyStore:
31
+ """Stores and retrieves `CheckResult`s by idempotency key, scoped to one run."""
32
+
33
+ def __init__(self, layout: StateLayout, run_id: str) -> None:
34
+ self.layout = layout
35
+ self.run_id = run_id
36
+ self._dir = layout.run_dir(run_id) / "results"
37
+
38
+ def _path(self, key: str) -> Path:
39
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
40
+ return self._dir / f"{digest}.json"
41
+
42
+ def has(self, key: str) -> bool:
43
+ return self._path(key).exists()
44
+
45
+ def get(self, key: str) -> CheckResult | None:
46
+ path = self._path(key)
47
+ if not path.exists():
48
+ return None
49
+ return CheckResult.model_validate_json(path.read_text(encoding="utf-8"))
50
+
51
+ def put(self, result: CheckResult) -> None:
52
+ self._dir.mkdir(parents=True, exist_ok=True)
53
+ path = self._path(result.idempotency_key)
54
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
55
+ tmp_path.write_text(result.model_dump_json(indent=2), encoding="utf-8")
56
+ os.replace(tmp_path, path)
57
+
58
+
59
+ __all__ = ["IdempotencyStore", "make_key"]