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,565 @@
1
+ """The agent task queue: one record per ready agent rule instance (DESIGN.md 5.5, D35).
2
+
3
+ An agent runtime that runs outside the engine's process (for example the user's own
4
+ agent harness, driven through tool calls) cannot be called like a function: the engine
5
+ hands out tasks, the harness does them, and the engine checks the result. This module
6
+ keeps that hand-over as state, under the state backend (never in the repo tree)::
7
+
8
+ <state>/runtime/tasks/<sha256(task_id)>.json AgentTaskRecord (engine writes)
9
+ <state>/runtime/baselines/<sha256(task_id)>.json file hashes at dispatch
10
+ <state>/runtime/agents/<sha256(agent_id)>.json AgentBinding (write guard writes)
11
+
12
+ A task moves ``ready -> dispatched -> submitted -> accepted | rejected |
13
+ budget_exhausted | needs_human``; a ``rejected`` task is dispatched again until its
14
+ ``tries`` are used up. Each dispatch also records what the task's agent must not read
15
+ (``denied_reads``, from its role's read policy), for the harness's guard. The runtime
16
+ that checks the submits keeps the agent rule loop's count on the record
17
+ (``budget_state``: tries, infra retries, the last failures and redo instruction), and
18
+ ``dispatch`` never hands a task out more than ``max_dispatches`` times in one budget
19
+ cycle (tries plus infra retries). Every file has
20
+ a single writer, so the engine and the harness's write guard (a separate process) never
21
+ overwrite each other's updates: the guard owns the agent bindings and their tool-call
22
+ counts, the engine owns everything else.
23
+
24
+ Pure Python over JSON files: no subprocess, no VCS, no knowledge of any harness.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import os
32
+ from collections.abc import Iterable, Mapping
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Literal
36
+
37
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError
38
+
39
+ from chipgraph.core.contracts import AgentResult, ModelTier, RuleId
40
+ from chipgraph.core.contracts.types import FailureLabel
41
+ from chipgraph.core.plugin_api.types import AgentTask
42
+ from chipgraph.core.runtime.roles import find_role, model_ladder
43
+ from chipgraph.core.state.layout import StateLayout
44
+
45
+ TaskStatus = Literal[
46
+ "ready",
47
+ "dispatched",
48
+ "submitted",
49
+ "accepted",
50
+ "rejected",
51
+ "budget_exhausted",
52
+ "needs_human",
53
+ ]
54
+ """Where an agent task is in its life: see the module docstring for the transitions."""
55
+
56
+ OPEN_STATUSES: frozenset[TaskStatus] = frozenset({"ready", "rejected"})
57
+ """Statuses a task can be dispatched from."""
58
+
59
+ ACTIVE_STATUSES: frozenset[TaskStatus] = frozenset({"dispatched", "submitted"})
60
+ """Statuses in which an agent may be working on the task, or the engine is checking it."""
61
+
62
+ DEFAULT_TOOL_CALL_CAP = 80
63
+ """Tool calls one dispatch of a task may make, over every agent bound to it.
64
+
65
+ A harness's own turn limit may not count the work of the agents it starts (spike S7:
66
+ the turn limit counts only the main session), so the engine keeps this budget itself,
67
+ per task.
68
+ """
69
+
70
+
71
+ class QueueError(Exception):
72
+ """An unknown task, or a transition its current status does not allow."""
73
+
74
+
75
+ def _now() -> datetime:
76
+ return datetime.now(UTC)
77
+
78
+
79
+ def state_key(value: str) -> str:
80
+ """The file name stem for a task id or an agent id: its sha256 hex digest."""
81
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
82
+
83
+
84
+ BudgetStop = Literal["tries", "stagnation", "infra", "planning"]
85
+ """Why a task's loop stopped on a submit: its tries ran out, two tries in a row left the
86
+ same outputs and failures, its infra retries ran out, or the checks point at the spec."""
87
+
88
+
89
+ class TaskBudgetState(BaseModel):
90
+ """The agent rule loop's count for one task, kept across submits (DESIGN.md 5.2).
91
+
92
+ An out-of-process runtime checks one attempt per submit, so the loop's budget state
93
+ (`chipgraph.core.engine.agent_rule.BudgetState`: tries used, infra retries, the last
94
+ failure signature) lives on the task record, with what the next attempt and a
95
+ HANDOFF need (the last label, failures and redo instruction). It counts one budget
96
+ cycle: a reopened task starts a new one. Tokens and cost are not kept: such a
97
+ runtime does not report them per task.
98
+ """
99
+
100
+ model_config = ConfigDict(extra="forbid", frozen=True)
101
+
102
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
103
+ dispatches: int = Field(default=0, ge=0, description="Dispatches in this budget cycle.")
104
+ tries_used: int = Field(default=0, ge=0, description="Tries used (failed or passed).")
105
+ failed_tries: int = Field(default=0, ge=0, description="Tries that failed.")
106
+ infra_failures: int = Field(
107
+ default=0, ge=0, description="Submits that failed on infra (not tries)."
108
+ )
109
+ last_signature: str | None = Field(
110
+ default=None, description="Output hashes and failures of the last failed try."
111
+ )
112
+ stagnant: bool = Field(
113
+ default=False, description="The last two failed tries had the same signature."
114
+ )
115
+ output_hashes: dict[str, str] = Field(
116
+ default_factory=dict, description="Output path to content hash at the last submit."
117
+ )
118
+ label: FailureLabel | None = Field(default=None, description="Label of the last failure.")
119
+ tier: ModelTier | None = Field(default=None, description="Model tier of the last try.")
120
+ failed_checks: tuple[str, ...] = Field(
121
+ default=(), description="Check ids that did not pass on the last failed submit."
122
+ )
123
+ failures: tuple[str, ...] = Field(
124
+ default=(), description="One line per failing check of the last failed submit."
125
+ )
126
+ redo: str | None = Field(
127
+ default=None, description="The redo instruction for the next try (bounded text)."
128
+ )
129
+ stop: BudgetStop | None = Field(default=None, description="Why the loop stopped, if it did.")
130
+ handoff: str | None = Field(
131
+ default=None, description="The HANDOFF.md written when the loop stopped."
132
+ )
133
+
134
+
135
+ class AgentTaskRecord(BaseModel):
136
+ """One agent rule instance handed to an out-of-process agent runtime."""
137
+
138
+ model_config = ConfigDict(extra="forbid", frozen=True)
139
+
140
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
141
+ task_id: str = Field(min_length=1, description="The rule instance id this task fulfils.")
142
+ rule_id: RuleId = Field(description="The rule this task is an instance of.")
143
+ role: str = Field(min_length=1, description="The agent role that runs this task.")
144
+ skills: tuple[str, ...] = Field(default=(), description="Skill ids available to the role.")
145
+ allowed_writes: tuple[str, ...] = Field(
146
+ min_length=1, description="Repo-relative paths the agent may write: the outputs."
147
+ )
148
+ tries: int = Field(ge=1, description="Submissions allowed before the budget is exhausted.")
149
+ tier: ModelTier = Field(default="medium", description="Model tier for the first attempt.")
150
+ escalate: ModelTier | None = Field(
151
+ default=None, description="Model tier for later attempts, if the rule escalates."
152
+ )
153
+ denied_reads: tuple[str, ...] = Field(
154
+ default=(),
155
+ description=(
156
+ "Repo-relative paths and globs the agent must not read (its role's read policy "
157
+ "resolved against the project); set on every dispatch. The guard refuses reads "
158
+ "of them, and listings or searches that could reach them."
159
+ ),
160
+ )
161
+ status: TaskStatus = Field(default="ready", description="Where the task is in its life.")
162
+ attempts: int = Field(default=0, ge=0, description="Rejected submissions so far.")
163
+ dispatches: int = Field(default=0, ge=0, description="How many times it was handed out.")
164
+ tool_call_cap: int = Field(
165
+ default=DEFAULT_TOOL_CALL_CAP, ge=1, description="Tool calls allowed per dispatch."
166
+ )
167
+ inputs_hash: str = Field(description="Hash of the instance's inputs when it was enqueued.")
168
+ run_id: str | None = Field(default=None, description="The run that last dispatched it.")
169
+ accepted_hashes: dict[str, str] = Field(
170
+ default_factory=dict, description="Output path to content hash, when accepted."
171
+ )
172
+ reasons: tuple[str, ...] = Field(default=(), description="Why the last submit was rejected.")
173
+ outside: tuple[str, ...] = Field(
174
+ default=(), description="Files the last rejected attempt changed outside its outputs."
175
+ )
176
+ last_result: AgentResult | None = Field(default=None, description="The last agent result.")
177
+ max_dispatches: int | None = Field(
178
+ default=None,
179
+ ge=1,
180
+ description=(
181
+ "Most dispatches in one budget cycle: tries plus infra retries. `dispatch` "
182
+ "refuses one more. Unset on records written before it existed."
183
+ ),
184
+ )
185
+ budget_state: TaskBudgetState | None = Field(
186
+ default=None, description="The loop's count across submits; unset until dispatched."
187
+ )
188
+ workspace: str | None = Field(
189
+ default=None,
190
+ description=(
191
+ "Absolute path to the task's isolated workspace, when `fanout.isolate` is true "
192
+ "or the rule sets `isolate: true`. The workspace is a worktree at HEAD with the "
193
+ "task's declared inputs copied in; the subagent writes there, not the user's tree."
194
+ ),
195
+ )
196
+ seed_hashes: dict[str, str] = Field(
197
+ default_factory=dict,
198
+ description=(
199
+ "Content hash of each input file copied into the workspace when it was seeded, "
200
+ "keyed by repo-relative path. Used to detect changes during the task."
201
+ ),
202
+ )
203
+ updated_at: AwareDatetime = Field(default_factory=_now, description="Last change.")
204
+
205
+ @property
206
+ def current_tier(self) -> ModelTier:
207
+ """The model tier for the next attempt: `escalate` once an attempt was rejected."""
208
+ if self.attempts >= 1 and self.escalate is not None:
209
+ return self.escalate
210
+ return self.tier
211
+
212
+ @property
213
+ def state(self) -> TaskBudgetState:
214
+ """The budget state; for a record without one, what its counters imply.
215
+
216
+ A record from before `budget_state` existed counted every rejected submit as a
217
+ try and dispatched once per try, so `attempts` stands for both (plus the
218
+ dispatch in progress, if any).
219
+ """
220
+ if self.budget_state is not None:
221
+ return self.budget_state
222
+ active = 1 if self.status in ACTIVE_STATUSES else 0
223
+ return TaskBudgetState(
224
+ dispatches=self.attempts + active,
225
+ tries_used=self.attempts,
226
+ failed_tries=self.attempts,
227
+ )
228
+
229
+
230
+ class AgentBinding(BaseModel):
231
+ """Which task one agent works on, and how many tool calls it made (guard-owned).
232
+
233
+ Written only by the harness's write guard, which binds an agent to a task when the
234
+ agent asks for that task's context. The engine only reads it.
235
+ """
236
+
237
+ model_config = ConfigDict(extra="forbid", frozen=True)
238
+
239
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
240
+ agent_id: str = Field(min_length=1, description="The harness's id for the agent.")
241
+ agent_type: str = Field(default="", description="The agent definition it runs.")
242
+ task_id: str = Field(min_length=1, description="The task the agent is bound to.")
243
+ dispatch: int = Field(ge=1, description="The task's `dispatches` count when it bound.")
244
+ tool_calls: int = Field(default=0, ge=0, description="Tool calls the agent made so far.")
245
+
246
+
247
+ def _write_atomic(path: Path, text: str) -> None:
248
+ path.parent.mkdir(parents=True, exist_ok=True)
249
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
250
+ tmp.write_text(text, encoding="utf-8")
251
+ os.replace(tmp, path)
252
+
253
+
254
+ class TaskQueue:
255
+ """Reads and writes the agent task queue under a project's state directory."""
256
+
257
+ def __init__(self, layout: StateLayout) -> None:
258
+ self.layout = layout
259
+ self.runtime_dir = layout.state_dir / "runtime"
260
+ self.tasks_dir = self.runtime_dir / "tasks"
261
+ self.baselines_dir = self.runtime_dir / "baselines"
262
+ self.agents_dir = self.runtime_dir / "agents"
263
+
264
+ # --- reading -------------------------------------------------------------------
265
+
266
+ def _task_path(self, task_id: str) -> Path:
267
+ return self.tasks_dir / f"{state_key(task_id)}.json"
268
+
269
+ def get(self, task_id: str) -> AgentTaskRecord | None:
270
+ """The record for `task_id`, or `None` if it was never enqueued."""
271
+ path = self._task_path(task_id)
272
+ if not path.is_file():
273
+ return None
274
+ return AgentTaskRecord.model_validate_json(path.read_text(encoding="utf-8"))
275
+
276
+ def require(self, task_id: str) -> AgentTaskRecord:
277
+ """The record for `task_id`; raises `QueueError` if there is none."""
278
+ record = self.get(task_id)
279
+ if record is None:
280
+ raise QueueError(f"no agent task {task_id!r}")
281
+ return record
282
+
283
+ def all(self) -> list[AgentTaskRecord]:
284
+ """Every record, sorted by task id."""
285
+ if not self.tasks_dir.is_dir():
286
+ return []
287
+ records = [
288
+ AgentTaskRecord.model_validate_json(path.read_text(encoding="utf-8"))
289
+ for path in self.tasks_dir.glob("*.json")
290
+ ]
291
+ return sorted(records, key=lambda r: r.task_id)
292
+
293
+ def baseline(self, task_id: str) -> dict[str, str]:
294
+ """The file hashes recorded when `task_id` was last dispatched (empty if none)."""
295
+ path = self.baselines_dir / f"{state_key(task_id)}.json"
296
+ if not path.is_file():
297
+ return {}
298
+ data = json.loads(path.read_text(encoding="utf-8"))
299
+ if not isinstance(data, dict) or not all(
300
+ isinstance(k, str) and isinstance(v, str) for k, v in data.items()
301
+ ):
302
+ raise QueueError(f"baseline {path} is not a mapping of path to hash")
303
+ return dict(data)
304
+
305
+ def bindings(self, task_id: str | None = None) -> list[AgentBinding]:
306
+ """Agent bindings written by the write guard, optionally only those for `task_id`.
307
+
308
+ A binding file that does not validate raises `QueueError`: it is the guard's
309
+ state, and a broken one means the guard is denying every call anyway.
310
+ """
311
+ if not self.agents_dir.is_dir():
312
+ return []
313
+ found: list[AgentBinding] = []
314
+ for path in sorted(self.agents_dir.glob("*.json")):
315
+ try:
316
+ binding = AgentBinding.model_validate_json(path.read_text(encoding="utf-8"))
317
+ except ValidationError as exc:
318
+ raise QueueError(f"broken agent binding {path}: {exc}") from exc
319
+ if task_id is None or binding.task_id == task_id:
320
+ found.append(binding)
321
+ return found
322
+
323
+ def tool_calls(self, task_id: str) -> int:
324
+ """Tool calls made so far in the task's current dispatch, over all its agents."""
325
+ record = self.require(task_id)
326
+ return sum(b.tool_calls for b in self.bindings(task_id) if b.dispatch == record.dispatches)
327
+
328
+ # --- writing -------------------------------------------------------------------
329
+
330
+ def _put(self, record: AgentTaskRecord) -> AgentTaskRecord:
331
+ record = record.model_copy(update={"updated_at": _now()})
332
+ _write_atomic(self._task_path(record.task_id), record.model_dump_json(indent=2))
333
+ return record
334
+
335
+ def enqueue(
336
+ self,
337
+ task: AgentTask,
338
+ *,
339
+ inputs_hash: str,
340
+ output_hashes: Mapping[str, str],
341
+ tool_call_cap: int = DEFAULT_TOOL_CALL_CAP,
342
+ max_dispatches: int | None = None,
343
+ task_id: str | None = None,
344
+ ) -> AgentTaskRecord:
345
+ """Add `task` as `ready`, or return its existing record (idempotent).
346
+
347
+ An existing record is reopened as a fresh `ready` task (attempts back to 0) when
348
+ the work it did no longer holds: an `accepted` task whose inputs changed or whose
349
+ outputs no longer hash as accepted, or a `budget_exhausted`/`needs_human` task
350
+ whose inputs changed (a person fixed the spec, say). Otherwise it is unchanged.
351
+ A reopened task starts a new budget cycle (no `budget_state`); its `dispatches`
352
+ count goes on, so the guard's bindings of earlier dispatches stay stale.
353
+
354
+ The model tiers come from the task's role (`RoleSpec.default_tier`,
355
+ `escalate_to`) unless the rule's budget sets `tier` or `escalate` itself.
356
+ `max_dispatches` bounds the dispatches of a new record (see `dispatch`).
357
+ `task_id` (best-of-N) names the record `<instance id>#<n>` instead of the instance id:
358
+ a candidate of the same instance, with its own workspace and budget.
359
+ """
360
+ task_id = task_id or task.instance.instance_id
361
+ existing = self.get(task_id)
362
+ if existing is not None:
363
+ inputs_changed = existing.inputs_hash != inputs_hash
364
+ if existing.status == "accepted":
365
+ outputs_changed = dict(output_hashes) != existing.accepted_hashes
366
+ if not inputs_changed and not outputs_changed:
367
+ return existing
368
+ elif existing.status in ("budget_exhausted", "needs_human"):
369
+ if not inputs_changed:
370
+ return existing
371
+ else:
372
+ return existing
373
+ tier, escalate = model_ladder(find_role(task.role), task.budget)
374
+ record = AgentTaskRecord(
375
+ task_id=task_id,
376
+ rule_id=task.instance.rule_id,
377
+ role=task.role,
378
+ skills=task.skills,
379
+ allowed_writes=task.allowed_writes,
380
+ tries=task.budget.tries,
381
+ tier=tier,
382
+ escalate=escalate,
383
+ tool_call_cap=tool_call_cap,
384
+ inputs_hash=inputs_hash,
385
+ dispatches=existing.dispatches if existing is not None else 0,
386
+ max_dispatches=max_dispatches,
387
+ )
388
+ return self._put(record)
389
+
390
+ def _transition(
391
+ self, task_id: str, allowed: frozenset[TaskStatus], action: str
392
+ ) -> AgentTaskRecord:
393
+ record = self.require(task_id)
394
+ if record.status not in allowed:
395
+ raise QueueError(
396
+ f"cannot {action} task {task_id!r}: it is {record.status!r} "
397
+ f"(needs one of {sorted(allowed)})"
398
+ )
399
+ return record
400
+
401
+ def dispatch(
402
+ self,
403
+ task_id: str,
404
+ *,
405
+ run_id: str,
406
+ baseline: Mapping[str, str],
407
+ denied_reads: Iterable[str] = (),
408
+ workspace: str | None = None,
409
+ seed_hashes: Mapping[str, str] | None = None,
410
+ ) -> AgentTaskRecord:
411
+ """Hand the task out: `ready`/`rejected` -> `dispatched`, with a new baseline.
412
+
413
+ `denied_reads` (the role's read policy resolved against the project as it is
414
+ now) replaces the record's, in the same write as the status change, so the
415
+ guard never sees the task dispatched without it. Counts the dispatch in the
416
+ budget cycle; refused (`QueueError`) once `max_dispatches` were handed out.
417
+
418
+ `workspace` (absolute path) and `seed_hashes` (input path -> hash) are set when
419
+ `fanout.isolate` is true: the task runs in an isolated workspace, not the user's
420
+ tree. The guard uses `workspace` to route writes there.
421
+ """
422
+ record = self._transition(task_id, OPEN_STATUSES, "dispatch")
423
+ state = record.state
424
+ if record.max_dispatches is not None and state.dispatches >= record.max_dispatches:
425
+ raise QueueError(
426
+ f"cannot dispatch task {task_id!r}: it was handed out "
427
+ f"{state.dispatches} times, its limit (tries plus infra retries)"
428
+ )
429
+ _write_atomic(
430
+ self.baselines_dir / f"{state_key(task_id)}.json",
431
+ json.dumps(dict(sorted(baseline.items())), indent=1),
432
+ )
433
+ update: dict[str, object] = {
434
+ "status": "dispatched",
435
+ "dispatches": record.dispatches + 1,
436
+ "run_id": run_id,
437
+ "denied_reads": tuple(sorted(set(denied_reads))),
438
+ "budget_state": state.model_copy(update={"dispatches": state.dispatches + 1}),
439
+ "workspace": workspace,
440
+ "seed_hashes": dict(seed_hashes) if seed_hashes is not None else {},
441
+ }
442
+ return self._put(record.model_copy(update=update))
443
+
444
+ def mark_submitted(self, task_id: str) -> AgentTaskRecord:
445
+ """`dispatched` -> `submitted`: the engine is checking the work (no more writes)."""
446
+ record = self._transition(task_id, frozenset({"dispatched"}), "submit")
447
+ return self._put(record.model_copy(update={"status": "submitted"}))
448
+
449
+ def unsubmit(self, task_id: str) -> AgentTaskRecord:
450
+ """`submitted` -> `dispatched`, when checking the work failed for reasons of its own."""
451
+ record = self._transition(task_id, frozenset({"submitted"}), "unsubmit")
452
+ return self._put(record.model_copy(update={"status": "dispatched"}))
453
+
454
+ def accept(
455
+ self,
456
+ task_id: str,
457
+ *,
458
+ result: AgentResult,
459
+ output_hashes: Mapping[str, str],
460
+ budget_state: TaskBudgetState | None = None,
461
+ ) -> AgentTaskRecord:
462
+ """`submitted` -> `accepted`, remembering the output hashes that were accepted."""
463
+ record = self._transition(task_id, frozenset({"submitted"}), "accept")
464
+ update: dict[str, object] = {
465
+ "status": "accepted",
466
+ "accepted_hashes": dict(output_hashes),
467
+ "reasons": (),
468
+ "outside": (),
469
+ "last_result": result,
470
+ }
471
+ if budget_state is not None:
472
+ update["budget_state"] = budget_state
473
+ return self._put(record.model_copy(update=update))
474
+
475
+ def reject(
476
+ self,
477
+ task_id: str,
478
+ *,
479
+ result: AgentResult,
480
+ reasons: tuple[str, ...],
481
+ outside: tuple[str, ...] = (),
482
+ counted: bool = True,
483
+ exhausted: bool | None = None,
484
+ budget_state: TaskBudgetState | None = None,
485
+ ) -> AgentTaskRecord:
486
+ """`submitted` -> `rejected`, or `budget_exhausted` once every try is used.
487
+
488
+ The stored result's status says which: `failed` or `budget_exhausted`. A submit
489
+ that is not `counted` (an infra failure) uses no try. `exhausted` overrides the
490
+ default "every try used" (the loop's budget may stop earlier, or on infra);
491
+ `budget_state` replaces the record's.
492
+ """
493
+ record = self._transition(task_id, frozenset({"submitted"}), "reject")
494
+ attempts = record.attempts + (1 if counted else 0)
495
+ if exhausted is None:
496
+ exhausted = attempts >= record.tries
497
+ status: TaskStatus = "budget_exhausted" if exhausted else "rejected"
498
+ final = result.model_copy(update={"status": "budget_exhausted" if exhausted else "failed"})
499
+ update: dict[str, object] = {
500
+ "status": status,
501
+ "attempts": attempts,
502
+ "reasons": reasons,
503
+ "outside": outside,
504
+ "last_result": final,
505
+ }
506
+ if budget_state is not None:
507
+ update["budget_state"] = budget_state
508
+ return self._put(record.model_copy(update=update))
509
+
510
+ def needs_human(
511
+ self,
512
+ task_id: str,
513
+ *,
514
+ result: AgentResult,
515
+ budget_state: TaskBudgetState | None = None,
516
+ ) -> AgentTaskRecord:
517
+ """`dispatched`/`submitted` -> `needs_human`: a person must answer `open_questions`."""
518
+ if result.status != "needs_human":
519
+ raise QueueError("needs_human needs an AgentResult with status 'needs_human'")
520
+ record = self._transition(task_id, ACTIVE_STATUSES, "hand to a person")
521
+ update: dict[str, object] = {
522
+ "status": "needs_human",
523
+ "reasons": result.open_questions,
524
+ "last_result": result,
525
+ }
526
+ if budget_state is not None:
527
+ update["budget_state"] = budget_state
528
+ return self._put(record.model_copy(update=update))
529
+
530
+ def reopen(self, task_id: str) -> AgentTaskRecord | None:
531
+ """`budget_exhausted`/`needs_human` -> `ready`, with a fresh budget cycle.
532
+
533
+ A person's go-ahead to try a stopped task again (a rewind). Any other status, or
534
+ no record, is left alone (`None`). `dispatches` goes on, as in `enqueue`.
535
+ """
536
+ record = self.get(task_id)
537
+ if record is None or record.status not in ("budget_exhausted", "needs_human"):
538
+ return None
539
+ return self._put(
540
+ record.model_copy(
541
+ update={
542
+ "status": "ready",
543
+ "attempts": 0,
544
+ "reasons": (),
545
+ "outside": (),
546
+ "last_result": None,
547
+ "budget_state": None,
548
+ }
549
+ )
550
+ )
551
+
552
+
553
+ __all__ = [
554
+ "ACTIVE_STATUSES",
555
+ "DEFAULT_TOOL_CALL_CAP",
556
+ "OPEN_STATUSES",
557
+ "AgentBinding",
558
+ "AgentTaskRecord",
559
+ "BudgetStop",
560
+ "QueueError",
561
+ "TaskBudgetState",
562
+ "TaskQueue",
563
+ "TaskStatus",
564
+ "state_key",
565
+ ]
@@ -0,0 +1,91 @@
1
+ """Roles and skills as data (DESIGN.md 5.1, 5.4; D8).
2
+
3
+ - `RoleSpec` (`spec`): one role's prompt and tool table: capabilities, write scope,
4
+ read policy, model tier and escalation tier. The built-in roles are files under
5
+ `data/` (`registry`: `get_role`, `list_roles`).
6
+ - `SkillSpec`, `load_skills` (`skills`): skills the packs provide, checked per role.
7
+ - `policy`: the tool table applied to rules (`check_agent_rules`), to a task's model
8
+ tiers (`model_ladder`) and to what its agent may read (`denied_reads`).
9
+ - `ReviewReport`, `review_problems` (`review`): the structured reply of a role that
10
+ writes no files (the Critic), from which the engine writes the task's output.
11
+
12
+ Generic: no harness, tool or project names. A runtime maps the abstract capabilities to
13
+ its own tools (runtime `claude-code`: `chipgraph.adapters.runtime.claude_code.agents`).
14
+ """
15
+
16
+ from chipgraph.core.runtime.roles.policy import (
17
+ LayoutValue,
18
+ check_agent_rules,
19
+ covers_denied,
20
+ denied_kind,
21
+ denied_reads,
22
+ model_ladder,
23
+ path_denied,
24
+ static_prefix,
25
+ task_denied_reads,
26
+ )
27
+ from chipgraph.core.runtime.roles.registry import find_role, get_role, list_roles, roles_dir
28
+ from chipgraph.core.runtime.roles.review import (
29
+ ReviewComment,
30
+ ReviewReport,
31
+ ReviewScope,
32
+ diff_hunks,
33
+ parse_review,
34
+ review_problems,
35
+ )
36
+ from chipgraph.core.runtime.roles.skills import (
37
+ SkillError,
38
+ SkillId,
39
+ SkillSet,
40
+ SkillSpec,
41
+ load_skills,
42
+ parse_skill_file,
43
+ )
44
+ from chipgraph.core.runtime.roles.spec import (
45
+ CAPABILITIES,
46
+ Capability,
47
+ ReadPolicy,
48
+ RoleError,
49
+ RoleId,
50
+ RoleSpec,
51
+ WriteScope,
52
+ parse_role_file,
53
+ split_frontmatter,
54
+ )
55
+
56
+ __all__ = [
57
+ "CAPABILITIES",
58
+ "Capability",
59
+ "LayoutValue",
60
+ "ReadPolicy",
61
+ "ReviewComment",
62
+ "ReviewReport",
63
+ "ReviewScope",
64
+ "RoleError",
65
+ "RoleId",
66
+ "RoleSpec",
67
+ "SkillError",
68
+ "SkillId",
69
+ "SkillSet",
70
+ "SkillSpec",
71
+ "WriteScope",
72
+ "check_agent_rules",
73
+ "covers_denied",
74
+ "denied_kind",
75
+ "denied_reads",
76
+ "diff_hunks",
77
+ "find_role",
78
+ "get_role",
79
+ "list_roles",
80
+ "load_skills",
81
+ "model_ladder",
82
+ "parse_review",
83
+ "parse_role_file",
84
+ "parse_skill_file",
85
+ "path_denied",
86
+ "review_problems",
87
+ "roles_dir",
88
+ "split_frontmatter",
89
+ "static_prefix",
90
+ "task_denied_reads",
91
+ ]