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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""The context of a review task, and the reply it is checked against (DESIGN.md 4.8, 5.1).
|
|
2
|
+
|
|
3
|
+
A task whose role writes no files but has `engine_writes` (the Critic) reviews a change.
|
|
4
|
+
`get_context` gives it, with a fresh context (no conversation, no author reasoning, no
|
|
5
|
+
earlier review), a `review` section built here:
|
|
6
|
+
|
|
7
|
+
- `diff`: the target's files, base ref vs the working tree (tracked changes and new
|
|
8
|
+
untracked files), each line led by its number in the new file (blank for a removed
|
|
9
|
+
line), capped at `DIFF_CAP` characters with a note naming the files left out;
|
|
10
|
+
- `spec`: the target block's requirements, interface, registers and open items from
|
|
11
|
+
the Design Model, each with its `file:line`;
|
|
12
|
+
- `model`: the block's Design Model slice (`model.block(name)`);
|
|
13
|
+
- `base`, `head`, `target`: what the reply must name.
|
|
14
|
+
|
|
15
|
+
The base ref is the instance param `base` when the rule's instance has one; otherwise the
|
|
16
|
+
merge-base of HEAD with the default branch (`origin/HEAD`, else `main`, else `master`),
|
|
17
|
+
which is HEAD itself on the default branch (the diff is then the uncommitted change). The
|
|
18
|
+
target's files are the paths of the build graph's other instances for the block, the
|
|
19
|
+
Design Model's module files and spec sources for it, and the profile's layout paths for
|
|
20
|
+
it; the review rule's own outputs are never among them.
|
|
21
|
+
|
|
22
|
+
What the task was shown is kept as a `ReviewScope` under the state directory
|
|
23
|
+
(`<state>/runtime/reviews/`), per dispatch, so `submit` checks the reply against exactly
|
|
24
|
+
that diff (`load_scope`).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import posixpath
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from chipgraph.adapters.tool.filelist import FilelistError, read_filelist
|
|
36
|
+
from chipgraph.app.context import AppContext
|
|
37
|
+
from chipgraph.app.errors import AppError
|
|
38
|
+
from chipgraph.core.contracts import RuleInstance, RuleSpec
|
|
39
|
+
from chipgraph.core.engine.graph import BuildGraph
|
|
40
|
+
from chipgraph.core.model import ModelQuery, ModelStore, QueryError, default_model_db_path
|
|
41
|
+
from chipgraph.core.runtime.queue import state_key
|
|
42
|
+
from chipgraph.core.runtime.roles import ReviewReport, ReviewScope, diff_hunks
|
|
43
|
+
|
|
44
|
+
DIFF_CAP = 60_000
|
|
45
|
+
"""Longest diff text `get_context` returns; the files after the cut are named instead."""
|
|
46
|
+
|
|
47
|
+
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
48
|
+
"""git's empty tree: the base of a repo with no commit yet."""
|
|
49
|
+
|
|
50
|
+
_DEFAULT_BRANCHES = ("main", "master")
|
|
51
|
+
_PLACEHOLDER_RE = re.compile(r"\{([^{}]+)\}")
|
|
52
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
53
|
+
_SPEC_KINDS = ("requirement", "register", "field", "port", "open_item")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_review_role(role: Any) -> bool:
|
|
57
|
+
"""Whether a role replies with a review the engine writes (`engine_writes`)."""
|
|
58
|
+
return role is not None and role.write_scope == "none" and bool(role.engine_writes)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- git ----------------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _git(ctx: AppContext, *args: str, ok: tuple[int, ...] = (0,)) -> str | None:
|
|
65
|
+
"""`git <args>` in the project; its stdout, or `None` when it exits with another code."""
|
|
66
|
+
runner = ctx.registry.get("runner", "local")
|
|
67
|
+
result = await runner.run(["git", "-c", "core.quotePath=false", *args], cwd=ctx.root)
|
|
68
|
+
return result.stdout if result.returncode in ok else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _default_branch(ctx: AppContext) -> str | None:
|
|
72
|
+
remote = await _git(ctx, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
73
|
+
if remote and remote.strip():
|
|
74
|
+
return remote.strip().removeprefix("refs/remotes/")
|
|
75
|
+
for name in _DEFAULT_BRANCHES:
|
|
76
|
+
if await _git(ctx, "rev-parse", "--verify", "--quiet", f"refs/heads/{name}") is not None:
|
|
77
|
+
return name
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def resolve_refs(ctx: AppContext, instance: RuleInstance) -> tuple[str, str, str]:
|
|
82
|
+
"""`(base, how, head)`: the base commit, how it was chosen, and the HEAD commit."""
|
|
83
|
+
head_out = await _git(ctx, "rev-parse", "--verify", "--quiet", "HEAD")
|
|
84
|
+
head = head_out.strip() if head_out else ""
|
|
85
|
+
param = instance.params.get("base")
|
|
86
|
+
if param:
|
|
87
|
+
found = await _git(ctx, "rev-parse", "--verify", "--quiet", f"{param}^{{commit}}")
|
|
88
|
+
if not found:
|
|
89
|
+
raise AppError(f"task {instance.instance_id!r}: base ref {param!r} is not a commit")
|
|
90
|
+
return found.strip(), f"rule param base={param}", head or EMPTY_TREE
|
|
91
|
+
if not head:
|
|
92
|
+
return EMPTY_TREE, "no commit yet: the empty tree", EMPTY_TREE
|
|
93
|
+
branch = await _default_branch(ctx)
|
|
94
|
+
if branch is None:
|
|
95
|
+
return head, "HEAD (no default branch found)", head
|
|
96
|
+
base = await _git(ctx, "merge-base", "HEAD", branch)
|
|
97
|
+
if not base:
|
|
98
|
+
return head, f"HEAD (no merge-base with {branch})", head
|
|
99
|
+
return base.strip(), f"merge-base of HEAD and {branch}", head
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- the target's files -------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _clean(path: str | None) -> str | None:
|
|
106
|
+
if not path:
|
|
107
|
+
return None
|
|
108
|
+
text = path.replace("\\", "/").strip()
|
|
109
|
+
if not text or text.startswith("/") or _GLOB_CHARS & set(text):
|
|
110
|
+
return None
|
|
111
|
+
norm = posixpath.normpath(text)
|
|
112
|
+
if norm in (".", "..") or norm.startswith("../"):
|
|
113
|
+
return None
|
|
114
|
+
return norm
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fill(template: str, block: str) -> str | None:
|
|
118
|
+
def _replace(match: re.Match[str]) -> str:
|
|
119
|
+
key = match.group(1)
|
|
120
|
+
if key == "block":
|
|
121
|
+
return block
|
|
122
|
+
if key == "BLOCK":
|
|
123
|
+
return block.upper()
|
|
124
|
+
return "*"
|
|
125
|
+
|
|
126
|
+
return _clean(_PLACEHOLDER_RE.sub(_replace, template))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _model(ctx: AppContext) -> tuple[Any, Any] | None:
|
|
130
|
+
db = default_model_db_path(ctx.layout.root)
|
|
131
|
+
if not db.is_file():
|
|
132
|
+
return None
|
|
133
|
+
store = ModelStore(db)
|
|
134
|
+
return store.read(), store
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _entity_block(entity: Any) -> str | None:
|
|
138
|
+
block = getattr(entity, "block", None)
|
|
139
|
+
if isinstance(block, str):
|
|
140
|
+
return block
|
|
141
|
+
attr = entity.attrs.get("block")
|
|
142
|
+
return attr if isinstance(attr, str) else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def target_files(
|
|
146
|
+
ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
"""The repo-relative files a review of `instance` covers, sorted."""
|
|
149
|
+
own = {
|
|
150
|
+
ref.path
|
|
151
|
+
for inst in graph.instances.values()
|
|
152
|
+
if inst.rule_id == rule.id
|
|
153
|
+
for ref in inst.outputs
|
|
154
|
+
if ref.path is not None
|
|
155
|
+
}
|
|
156
|
+
paths: set[str | None] = {ref.path for ref in instance.inputs}
|
|
157
|
+
block = instance.params.get("block")
|
|
158
|
+
if block:
|
|
159
|
+
for inst in graph.instances.values():
|
|
160
|
+
if inst.rule_id != rule.id and inst.params.get("block") == block:
|
|
161
|
+
paths.update(ref.path for ref in (*inst.inputs, *inst.outputs))
|
|
162
|
+
profile = ctx.require_profile().profile
|
|
163
|
+
layout: dict[str, Any] = dict(profile.layout)
|
|
164
|
+
override = profile.blocks.get(block)
|
|
165
|
+
if override is not None:
|
|
166
|
+
layout.update(override.layout)
|
|
167
|
+
for value in layout.values():
|
|
168
|
+
for template in (value,) if isinstance(value, str) else value:
|
|
169
|
+
paths.add(_fill(template, block))
|
|
170
|
+
loaded = _model(ctx)
|
|
171
|
+
if loaded is not None:
|
|
172
|
+
model, _ = loaded
|
|
173
|
+
key = f"block:{block}"
|
|
174
|
+
modules = set()
|
|
175
|
+
for entity in model.by_kind("module"):
|
|
176
|
+
if getattr(entity, "block", None) == key:
|
|
177
|
+
modules.add(entity.key)
|
|
178
|
+
paths.add(getattr(entity, "file", None))
|
|
179
|
+
for kind in _SPEC_KINDS:
|
|
180
|
+
for entity in model.by_kind(kind):
|
|
181
|
+
if _entity_block(entity) == key or getattr(entity, "module", None) in modules:
|
|
182
|
+
paths.add(entity.source.file)
|
|
183
|
+
cleaned = {c for c in (_clean(p) for p in paths) if c is not None}
|
|
184
|
+
cleaned |= _filelist_sources(ctx.root, sorted(p for p in cleaned if p.endswith(".f")))
|
|
185
|
+
return sorted(cleaned - own)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _filelist_sources(root: Path, filelists: list[str]) -> set[str]:
|
|
189
|
+
"""The repo files the filelists among the target's files list (new files too).
|
|
190
|
+
|
|
191
|
+
Paths are tried both relative to the filelist and to the project root, as ingest does;
|
|
192
|
+
only those inside the project are kept.
|
|
193
|
+
"""
|
|
194
|
+
found: set[str] = set()
|
|
195
|
+
top = root.resolve()
|
|
196
|
+
for rel in filelists:
|
|
197
|
+
path = root / rel
|
|
198
|
+
if not path.is_file():
|
|
199
|
+
continue
|
|
200
|
+
for style in ("filelist", "cwd"):
|
|
201
|
+
try:
|
|
202
|
+
listed = read_filelist(path, relative_to=style, root=root)
|
|
203
|
+
except FilelistError:
|
|
204
|
+
continue
|
|
205
|
+
for source in listed.sources:
|
|
206
|
+
if not source.is_file():
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
found.add(source.resolve().relative_to(top).as_posix())
|
|
210
|
+
except ValueError:
|
|
211
|
+
continue
|
|
212
|
+
return {c for c in (_clean(p) for p in found) if c is not None}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --- the diff -----------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def full_diff(ctx: AppContext, base: str, files: list[str]) -> str:
|
|
219
|
+
"""Unified diff of `files`, `base` vs the working tree, new untracked files included."""
|
|
220
|
+
if not files:
|
|
221
|
+
return ""
|
|
222
|
+
tracked = await _git(
|
|
223
|
+
ctx, "diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3", base, "--", *files
|
|
224
|
+
)
|
|
225
|
+
parts = [tracked or ""]
|
|
226
|
+
untracked = await _git(ctx, "ls-files", "-z", "--others", "--exclude-standard", "--", *files)
|
|
227
|
+
for path in sorted(p for p in (untracked or "").split("\0") if p):
|
|
228
|
+
added = await _git(
|
|
229
|
+
ctx, "diff", "--no-color", "--no-index", "--", "/dev/null", path, ok=(0, 1)
|
|
230
|
+
)
|
|
231
|
+
parts.append(added or "")
|
|
232
|
+
return "".join(p if p.endswith("\n") or not p else p + "\n" for p in parts)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _sections(diff: str) -> list[tuple[str, list[str]]]:
|
|
236
|
+
"""The diff split per file: (path, lines), in order."""
|
|
237
|
+
sections: list[tuple[str, list[str]]] = []
|
|
238
|
+
for line in diff.splitlines():
|
|
239
|
+
if line.startswith("diff --git ") or not sections:
|
|
240
|
+
sections.append(("", []))
|
|
241
|
+
name, lines = sections[-1]
|
|
242
|
+
lines.append(line)
|
|
243
|
+
if line.startswith("+++ ") and not name:
|
|
244
|
+
new = line[4:].strip()
|
|
245
|
+
sections[-1] = (new[2:] if new.startswith("b/") else new, lines)
|
|
246
|
+
elif line.startswith("--- ") and not name:
|
|
247
|
+
old = line[4:].strip()
|
|
248
|
+
if old != "/dev/null":
|
|
249
|
+
sections[-1] = (old[2:] if old.startswith("a/") else old, lines)
|
|
250
|
+
return sections
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def annotate(diff: str) -> list[tuple[str, str]]:
|
|
254
|
+
"""Per file: (path, text), each hunk line led by its line number in the new file."""
|
|
255
|
+
out: list[tuple[str, str]] = []
|
|
256
|
+
for path, lines in _sections(diff):
|
|
257
|
+
shown: list[str] = []
|
|
258
|
+
new_line = 0
|
|
259
|
+
in_hunk = False
|
|
260
|
+
for line in lines:
|
|
261
|
+
match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
|
|
262
|
+
if match:
|
|
263
|
+
new_line = int(match.group(1))
|
|
264
|
+
in_hunk = True
|
|
265
|
+
shown.append(line)
|
|
266
|
+
elif in_hunk and line[:1] in ("+", " "):
|
|
267
|
+
shown.append(f"{new_line:>5} {line}")
|
|
268
|
+
new_line += 1
|
|
269
|
+
elif in_hunk and line[:1] == "-":
|
|
270
|
+
shown.append(f"{'':>5} {line}")
|
|
271
|
+
else:
|
|
272
|
+
if line.startswith("diff --git "):
|
|
273
|
+
in_hunk = False
|
|
274
|
+
shown.append(line)
|
|
275
|
+
out.append((path, "\n".join(shown) + "\n"))
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def capped(sections: list[tuple[str, str]], cap: int | None = None) -> tuple[str, list[str]]:
|
|
280
|
+
"""The annotated diff within `cap` (default `DIFF_CAP`) characters, and the files left
|
|
281
|
+
out (or cut)."""
|
|
282
|
+
cap = DIFF_CAP if cap is None else cap
|
|
283
|
+
text = ""
|
|
284
|
+
omitted: list[str] = []
|
|
285
|
+
for path, body in sections:
|
|
286
|
+
if omitted or len(text) + len(body) > cap:
|
|
287
|
+
if not text:
|
|
288
|
+
text = body[:cap]
|
|
289
|
+
omitted.append(path)
|
|
290
|
+
continue
|
|
291
|
+
text += body
|
|
292
|
+
return text, omitted
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --- the spec and model slices ------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _where(entity: Any) -> str | None:
|
|
299
|
+
src = entity.source
|
|
300
|
+
if not src.file:
|
|
301
|
+
return None
|
|
302
|
+
return f"{src.file}:{src.line}" if src.line else src.file
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def spec_slice(ctx: AppContext, block: str | None) -> dict[str, Any]:
|
|
306
|
+
"""The block's requirements, interface, registers and open items, from the model."""
|
|
307
|
+
if not block:
|
|
308
|
+
return {"note": "the task has no block param: no spec slice"}
|
|
309
|
+
loaded = _model(ctx)
|
|
310
|
+
if loaded is None:
|
|
311
|
+
return {"note": "no Design Model (run `chipgraph ingest` first)"}
|
|
312
|
+
model, _ = loaded
|
|
313
|
+
key = f"block:{block}"
|
|
314
|
+
modules = {e.key for e in model.by_kind("module") if getattr(e, "block", None) == key}
|
|
315
|
+
requirements = [
|
|
316
|
+
{"id": e.name, "text": getattr(e, "text", None), "source": _where(e)}
|
|
317
|
+
for e in model.by_kind("requirement")
|
|
318
|
+
if _entity_block(e) == key
|
|
319
|
+
]
|
|
320
|
+
interface = [
|
|
321
|
+
{
|
|
322
|
+
"name": e.name,
|
|
323
|
+
"direction": e.direction,
|
|
324
|
+
"width": e.width,
|
|
325
|
+
"description": e.attrs.get("description"),
|
|
326
|
+
"source": _where(e),
|
|
327
|
+
}
|
|
328
|
+
for e in model.by_kind("port")
|
|
329
|
+
if _entity_block(e) == key and e.attrs.get("origin") == "spec"
|
|
330
|
+
]
|
|
331
|
+
rtl_ports = [
|
|
332
|
+
{
|
|
333
|
+
"module": e.module,
|
|
334
|
+
"name": e.name,
|
|
335
|
+
"direction": e.direction,
|
|
336
|
+
"width": e.width,
|
|
337
|
+
"source": _where(e),
|
|
338
|
+
}
|
|
339
|
+
for e in model.by_kind("port")
|
|
340
|
+
if e.module in modules
|
|
341
|
+
]
|
|
342
|
+
fields: dict[str, list[dict[str, Any]]] = {}
|
|
343
|
+
for e in model.by_kind("field"):
|
|
344
|
+
fields.setdefault(str(getattr(e, "register", "")), []).append(
|
|
345
|
+
{
|
|
346
|
+
"name": e.name,
|
|
347
|
+
"msb": e.msb,
|
|
348
|
+
"lsb": e.lsb,
|
|
349
|
+
"access": e.access,
|
|
350
|
+
"reset_value": e.reset_value,
|
|
351
|
+
"source": _where(e),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
registers = [
|
|
355
|
+
{
|
|
356
|
+
"name": e.name,
|
|
357
|
+
"offset": e.offset,
|
|
358
|
+
"access": e.access,
|
|
359
|
+
"reset_value": e.reset_value,
|
|
360
|
+
"source": _where(e),
|
|
361
|
+
"fields": fields.get(e.key, []),
|
|
362
|
+
}
|
|
363
|
+
for e in model.by_kind("register")
|
|
364
|
+
if getattr(e, "block", None) == key
|
|
365
|
+
]
|
|
366
|
+
open_items = [
|
|
367
|
+
{"name": e.name, "status": getattr(e, "status", None), "source": _where(e)}
|
|
368
|
+
for e in model.by_kind("open_item")
|
|
369
|
+
if _entity_block(e) == key
|
|
370
|
+
]
|
|
371
|
+
return {
|
|
372
|
+
"requirements": sorted(requirements, key=lambda r: str(r["id"])),
|
|
373
|
+
"interface": interface,
|
|
374
|
+
"rtl_ports": rtl_ports,
|
|
375
|
+
"registers": registers,
|
|
376
|
+
"open_items": open_items,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def model_slice(ctx: AppContext, block: str | None) -> dict[str, Any]:
|
|
381
|
+
"""`model.block(name)` as JSON, or a note."""
|
|
382
|
+
loaded = _model(ctx) if block else None
|
|
383
|
+
if loaded is None:
|
|
384
|
+
return {"note": "not available from the Design Model"}
|
|
385
|
+
model, store = loaded
|
|
386
|
+
try:
|
|
387
|
+
info = ModelQuery(model, store).block(str(block))
|
|
388
|
+
except QueryError as exc:
|
|
389
|
+
return {"note": str(exc)}
|
|
390
|
+
result: dict[str, Any] = info.model_dump(mode="json")
|
|
391
|
+
return result
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# --- the scope, kept per dispatch ---------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _scope_path(ctx: AppContext, task_id: str) -> Path:
|
|
398
|
+
return ctx.layout.state_dir / "runtime" / "reviews" / f"{state_key(task_id)}.json"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def save_scope(ctx: AppContext, task_id: str, dispatch: int, scope: ReviewScope) -> None:
|
|
402
|
+
path = _scope_path(ctx, task_id)
|
|
403
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
data = {"task_id": task_id, "dispatch": dispatch, "scope": scope.model_dump(mode="json")}
|
|
405
|
+
tmp = path.with_suffix(".tmp")
|
|
406
|
+
tmp.write_text(json.dumps(data, indent=1, sort_keys=True) + "\n", encoding="utf-8")
|
|
407
|
+
tmp.replace(path)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def load_scope(ctx: AppContext, task_id: str, dispatch: int) -> ReviewScope | None:
|
|
411
|
+
"""The scope `get_context` showed this dispatch of the task, if it did."""
|
|
412
|
+
path = _scope_path(ctx, task_id)
|
|
413
|
+
try:
|
|
414
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
415
|
+
except (OSError, json.JSONDecodeError):
|
|
416
|
+
return None
|
|
417
|
+
if data.get("task_id") != task_id or data.get("dispatch") != dispatch:
|
|
418
|
+
return None
|
|
419
|
+
return ReviewScope.model_validate(data["scope"])
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def _scope_and_diff(
|
|
423
|
+
ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
|
|
424
|
+
) -> tuple[ReviewScope, str, str, list[str]]:
|
|
425
|
+
base, how, head = await resolve_refs(ctx, instance)
|
|
426
|
+
files = target_files(ctx, graph, rule, instance)
|
|
427
|
+
diff = await full_diff(ctx, base, files)
|
|
428
|
+
target = instance.params.get("block") or instance.instance_id
|
|
429
|
+
scope = ReviewScope(target=target, base=base, head=head, hunks=diff_hunks(diff))
|
|
430
|
+
return scope, diff, how, files
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
async def review_scope(
|
|
434
|
+
ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
|
|
435
|
+
) -> ReviewScope:
|
|
436
|
+
"""The scope of a review task as it is now (when `get_context` kept none)."""
|
|
437
|
+
scope, _, _, _ = await _scope_and_diff(ctx, graph, rule, instance)
|
|
438
|
+
return scope
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async def build_review(
|
|
442
|
+
ctx: AppContext, graph: BuildGraph, rule: RuleSpec, instance: RuleInstance
|
|
443
|
+
) -> tuple[ReviewScope, dict[str, Any]]:
|
|
444
|
+
"""The scope of a review task and the `review` section of its context."""
|
|
445
|
+
scope, diff, how, files = await _scope_and_diff(ctx, graph, rule, instance)
|
|
446
|
+
base, head = scope.base, scope.head
|
|
447
|
+
block = instance.params.get("block")
|
|
448
|
+
text, omitted = capped(annotate(diff))
|
|
449
|
+
note = (
|
|
450
|
+
"Each hunk line starts with its line number in the new file (blank for a removed "
|
|
451
|
+
"line); comment on those numbers. The diff is the base vs the working tree."
|
|
452
|
+
)
|
|
453
|
+
if omitted:
|
|
454
|
+
note += (
|
|
455
|
+
f" The diff was cut at {DIFF_CAP} characters; not shown (read them with your "
|
|
456
|
+
f"read tools): {', '.join(omitted)}."
|
|
457
|
+
)
|
|
458
|
+
if not scope.hunks:
|
|
459
|
+
note += " The diff is empty: nothing of this target changed since the base."
|
|
460
|
+
view: dict[str, Any] = {
|
|
461
|
+
"target": scope.target,
|
|
462
|
+
"base": base,
|
|
463
|
+
"base_from": how,
|
|
464
|
+
"head": head,
|
|
465
|
+
"files": files,
|
|
466
|
+
"changed": list(scope.files),
|
|
467
|
+
"diff": text,
|
|
468
|
+
"truncated": bool(omitted),
|
|
469
|
+
"omitted": omitted,
|
|
470
|
+
"note": note,
|
|
471
|
+
"spec": spec_slice(ctx, block),
|
|
472
|
+
"model": model_slice(ctx, block),
|
|
473
|
+
"reply_schema": ReviewReport.model_json_schema(),
|
|
474
|
+
}
|
|
475
|
+
return scope, view
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
__all__ = [
|
|
479
|
+
"DIFF_CAP",
|
|
480
|
+
"EMPTY_TREE",
|
|
481
|
+
"annotate",
|
|
482
|
+
"build_review",
|
|
483
|
+
"capped",
|
|
484
|
+
"full_diff",
|
|
485
|
+
"is_review_role",
|
|
486
|
+
"load_scope",
|
|
487
|
+
"resolve_refs",
|
|
488
|
+
"review_scope",
|
|
489
|
+
"save_scope",
|
|
490
|
+
"spec_slice",
|
|
491
|
+
"target_files",
|
|
492
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""`ClaudeCodeRuntime`: the `AgentRuntime` for `runtime: claude-code` (DESIGN.md 5.5, D35).
|
|
2
|
+
|
|
3
|
+
chipgraph never calls a model in this runtime. `run_task` only puts the task in the
|
|
4
|
+
queue (`chipgraph.core.runtime.TaskQueue`) and reports where it is:
|
|
5
|
+
|
|
6
|
+
- accepted, and its outputs still hash as accepted -> `done`;
|
|
7
|
+
- `budget_exhausted` -> `budget_exhausted`, with the last rejection as the message;
|
|
8
|
+
- `needs_human` -> `needs_human`, with the agent's (or the engine's) open questions;
|
|
9
|
+
- anything else -> `needs_human` with one question: run the plugin command in Claude
|
|
10
|
+
Code, which fetches the task with `next_task`, runs it in a role subagent, and hands
|
|
11
|
+
it back with `submit` (see `chipgraph.adapters.runtime.claude_code.service`).
|
|
12
|
+
|
|
13
|
+
So a `chipgraph build` stops at an agent rule like it stops at a `human` rule, and goes
|
|
14
|
+
on once the task is accepted.
|
|
15
|
+
|
|
16
|
+
`ClaudeCodeExecutor` is the build's executor for agent rules in this runtime. A task
|
|
17
|
+
whose loop stopped on a submit (its budget ran out, or the checks point at the spec:
|
|
18
|
+
`chipgraph.adapters.runtime.claude_code.loop`) fails with the label of its last failure
|
|
19
|
+
and the loop's `agent` payload, so HANDOFF.md says how many tries were used, why it
|
|
20
|
+
stopped and what failed last. It stays stopped until an input changes or
|
|
21
|
+
`chipgraph rewind` reopens it; a rewind also puts back the output files that existed
|
|
22
|
+
before the task, for a role whose outputs were moved aside (`previous`).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from chipgraph.adapters.runtime.claude_code.loop import (
|
|
28
|
+
MAX_INFRA_RETRIES,
|
|
29
|
+
max_dispatches,
|
|
30
|
+
stop_info,
|
|
31
|
+
stop_message,
|
|
32
|
+
)
|
|
33
|
+
from chipgraph.adapters.runtime.claude_code.previous import PreviousOutputs
|
|
34
|
+
from chipgraph.core.contracts import AgentResult, RuleInstance, RuleSpec
|
|
35
|
+
from chipgraph.core.engine.scheduler import ExecOutcome
|
|
36
|
+
from chipgraph.core.plugin_api.types import AgentTask
|
|
37
|
+
from chipgraph.core.runtime import (
|
|
38
|
+
ACTIVE_STATUSES,
|
|
39
|
+
DEFAULT_TOOL_CALL_CAP,
|
|
40
|
+
AgentRuntimeExecutor,
|
|
41
|
+
AgentTaskRecord,
|
|
42
|
+
TaskQueue,
|
|
43
|
+
instance_inputs_hash,
|
|
44
|
+
outcome_for,
|
|
45
|
+
task_for,
|
|
46
|
+
)
|
|
47
|
+
from chipgraph.core.state.artifacts import ArtifactStore
|
|
48
|
+
|
|
49
|
+
PLUGIN_NAME = "chipgraph"
|
|
50
|
+
"""The Claude Code plugin's name: agents are `chipgraph:<role>`, commands `/chipgraph:<name>`."""
|
|
51
|
+
|
|
52
|
+
RUN_COMMAND = f"/{PLUGIN_NAME}:run"
|
|
53
|
+
"""The plugin command that runs the task loop."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def output_hashes(store: ArtifactStore, task: AgentTask) -> dict[str, str]:
|
|
57
|
+
"""Current content hash of each of the task's outputs that exists, keyed by path."""
|
|
58
|
+
refs = [ref for ref in task.instance.outputs if ref.path is not None]
|
|
59
|
+
current = store.current_hashes(refs)
|
|
60
|
+
return {
|
|
61
|
+
ref.path: current[f"{ref.repo}:{ref.path}"]
|
|
62
|
+
for ref in refs
|
|
63
|
+
if ref.path is not None and f"{ref.repo}:{ref.path}" in current
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def result_for(record: AgentTaskRecord) -> AgentResult:
|
|
68
|
+
"""What the queue record means for the build, as an `AgentResult`."""
|
|
69
|
+
if record.status == "accepted":
|
|
70
|
+
return AgentResult(status="done", files_written=record.allowed_writes)
|
|
71
|
+
if record.status == "budget_exhausted":
|
|
72
|
+
last = "; ".join(record.reasons) or "no reason recorded"
|
|
73
|
+
return AgentResult(
|
|
74
|
+
status="budget_exhausted",
|
|
75
|
+
open_questions=(
|
|
76
|
+
f"agent task {record.task_id!r} used all {record.tries} tries "
|
|
77
|
+
f"(last rejection: {last}); fix its inputs or raise budget.tries, "
|
|
78
|
+
"then build again (see HANDOFF.md)",
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
if record.status == "needs_human":
|
|
82
|
+
questions = record.last_result.open_questions if record.last_result else ()
|
|
83
|
+
return AgentResult(
|
|
84
|
+
status="needs_human",
|
|
85
|
+
open_questions=questions or (f"agent task {record.task_id!r} needs a person",),
|
|
86
|
+
)
|
|
87
|
+
return AgentResult(
|
|
88
|
+
status="needs_human",
|
|
89
|
+
open_questions=(
|
|
90
|
+
f"waiting for an agent: run `{RUN_COMMAND}` in Claude Code "
|
|
91
|
+
f"(task {record.task_id!r} is {record.status})",
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ClaudeCodeRuntime:
|
|
97
|
+
"""Queues agent tasks for the user's Claude Code session; never calls a model."""
|
|
98
|
+
|
|
99
|
+
name = "claude-code"
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
queue: TaskQueue,
|
|
104
|
+
store: ArtifactStore,
|
|
105
|
+
*,
|
|
106
|
+
tool_call_cap: int = DEFAULT_TOOL_CALL_CAP,
|
|
107
|
+
max_infra_retries: int = MAX_INFRA_RETRIES,
|
|
108
|
+
) -> None:
|
|
109
|
+
self.queue = queue
|
|
110
|
+
self.store = store
|
|
111
|
+
self.tool_call_cap = tool_call_cap
|
|
112
|
+
self.max_infra_retries = max_infra_retries
|
|
113
|
+
|
|
114
|
+
def enqueue(self, task: AgentTask) -> AgentTaskRecord:
|
|
115
|
+
"""Queue `task` (idempotent, see `TaskQueue.enqueue`) and return its record."""
|
|
116
|
+
return self.queue.enqueue(
|
|
117
|
+
task,
|
|
118
|
+
inputs_hash=instance_inputs_hash(self.store, task.instance),
|
|
119
|
+
output_hashes=output_hashes(self.store, task),
|
|
120
|
+
tool_call_cap=self.tool_call_cap,
|
|
121
|
+
max_dispatches=max_dispatches(task.budget.tries, self.max_infra_retries),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def run_task(self, task: AgentTask) -> AgentResult:
|
|
125
|
+
return result_for(self.enqueue(task))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def stopped_outcome(record: AgentTaskRecord) -> ExecOutcome | None:
|
|
129
|
+
"""The outcome of a task whose loop stopped on a submit, or `None` if it did not."""
|
|
130
|
+
state = record.budget_state
|
|
131
|
+
if state is None or state.stop is None or state.label is None:
|
|
132
|
+
return None
|
|
133
|
+
if record.status not in ("budget_exhausted", "needs_human"):
|
|
134
|
+
return None
|
|
135
|
+
info = stop_info(record, stopped_earlier=True)
|
|
136
|
+
message = (
|
|
137
|
+
f"{stop_message(info)} (stopped in an earlier run; see HANDOFF.md; change an "
|
|
138
|
+
f"input or run `chipgraph rewind {record.task_id}` to try again)"
|
|
139
|
+
)
|
|
140
|
+
payload: dict[str, object] = {"agent": info}
|
|
141
|
+
questions = record.last_result.open_questions if record.last_result else ()
|
|
142
|
+
if questions:
|
|
143
|
+
payload["open_questions"] = list(questions)
|
|
144
|
+
return ExecOutcome(ok=False, failure_label=state.label, message=message, payload=payload)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ClaudeCodeExecutor(AgentRuntimeExecutor):
|
|
148
|
+
"""The executor for `kind: agent` rules in runtime claude-code.
|
|
149
|
+
|
|
150
|
+
Like `AgentRuntimeExecutor` over a `ClaudeCodeRuntime`, except that a task whose
|
|
151
|
+
loop stopped reports its label and the loop's `agent` payload (`stopped_outcome`),
|
|
152
|
+
and that `Scheduler.rewind` reopens a stopped task with a fresh budget (`forget`).
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, runtime: ClaudeCodeRuntime) -> None:
|
|
156
|
+
super().__init__(runtime)
|
|
157
|
+
self.cc_runtime = runtime
|
|
158
|
+
|
|
159
|
+
async def execute(self, rule: RuleSpec, instance: RuleInstance) -> ExecOutcome:
|
|
160
|
+
try:
|
|
161
|
+
task = task_for(rule, instance)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
return ExecOutcome(ok=False, failure_label="planning", message=str(exc))
|
|
164
|
+
record = self.cc_runtime.enqueue(task)
|
|
165
|
+
stopped = stopped_outcome(record)
|
|
166
|
+
if stopped is not None:
|
|
167
|
+
return stopped
|
|
168
|
+
return outcome_for(result_for(record), task_id=instance.instance_id)
|
|
169
|
+
|
|
170
|
+
def forget(self, instance_id: str) -> None:
|
|
171
|
+
"""Reopen a stopped task (`chipgraph rewind`): a person said to try again.
|
|
172
|
+
|
|
173
|
+
A task no agent is working on gets back every output file that existed before
|
|
174
|
+
its budget cycle (`previous`); one that is dispatched or being checked is left
|
|
175
|
+
to finish its attempt.
|
|
176
|
+
"""
|
|
177
|
+
queue = self.cc_runtime.queue
|
|
178
|
+
record = queue.get(instance_id)
|
|
179
|
+
if record is not None and record.status not in ACTIVE_STATUSES:
|
|
180
|
+
PreviousOutputs(queue, instance_id).restore(dispatch=record.dispatches)
|
|
181
|
+
queue.reopen(instance_id)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
__all__ = [
|
|
185
|
+
"PLUGIN_NAME",
|
|
186
|
+
"RUN_COMMAND",
|
|
187
|
+
"ClaudeCodeExecutor",
|
|
188
|
+
"ClaudeCodeRuntime",
|
|
189
|
+
"output_hashes",
|
|
190
|
+
"result_for",
|
|
191
|
+
"stopped_outcome",
|
|
192
|
+
]
|