workgraph 0.3.3__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.
workgraph/codex.py ADDED
@@ -0,0 +1,232 @@
1
+ """The Codex harness: argv, result reading, and transcript rendering."""
2
+
3
+ import json
4
+ import tempfile
5
+ from collections.abc import Iterator, Sequence
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import tomlkit
11
+ from rich.text import Text
12
+
13
+ from workgraph.harness import (
14
+ AgentInvocation,
15
+ NodeFailure,
16
+ iter_jsonl_events,
17
+ read_last_value,
18
+ split_lines,
19
+ )
20
+
21
+ # USD per million tokens: uncached input, cached input, cache write, output.
22
+ # A Pro model offers no cached input discount, so its cached rate is its input rate.
23
+ # Source: https://developers.openai.com/api/docs/pricing, read 2026-09-05.
24
+ MODEL_RATES: dict[str, tuple[float, float, float, float]] = {
25
+ "gpt-6-astra": (10.00, 1.00, 12.50, 50.00),
26
+ "gpt-5.6-sol": (4.00, 0.40, 5.00, 20.00),
27
+ "gpt-5.6": (4.00, 0.40, 5.00, 20.00), # the documented alias of gpt-5.6-sol
28
+ "gpt-daybreak-blue-latest": (4.00, 0.40, 5.00, 20.00), # the Daybreak alias of gpt-5.6-sol
29
+ "gpt-5.6-cyber": (12.50, 1.25, 15.625, 75.00),
30
+ "gpt-daybreak-red-latest": (12.50, 1.25, 15.625, 75.00), # the Daybreak alias of gpt-5.6-cyber
31
+ "gpt-5.6-terra": (2.00, 0.20, 2.50, 12.00),
32
+ "gpt-5.6-luna": (0.20, 0.02, 0.25, 1.20),
33
+ "gpt-5.5": (5.00, 0.50, 0.0, 30.00),
34
+ "gpt-5.5-pro": (30.00, 30.00, 0.0, 180.00),
35
+ "gpt-5.4": (2.50, 0.25, 0.0, 15.00),
36
+ "gpt-5.4-mini": (0.75, 0.075, 0.0, 4.50),
37
+ "gpt-5.4-nano": (0.20, 0.02, 0.0, 1.25),
38
+ "gpt-5.4-pro": (30.00, 30.00, 0.0, 180.00),
39
+ "gpt-5.3-codex": (1.75, 0.175, 0.0, 14.00),
40
+ "gpt-5.2": (1.75, 0.175, 0.0, 14.00),
41
+ "gpt-5.1": (1.25, 0.125, 0.0, 10.00),
42
+ "gpt-5": (1.25, 0.125, 0.0, 10.00),
43
+ "gpt-5-mini": (0.25, 0.025, 0.0, 2.00),
44
+ "gpt-5-nano": (0.05, 0.005, 0.0, 0.40),
45
+ "gpt-5-pro": (15.00, 15.00, 0.0, 120.00),
46
+ }
47
+
48
+
49
+ def _build_strict_schema(outcome_schema: dict[str, Any]) -> dict[str, Any]:
50
+ """Adapt the outcome schema to OpenAI strict mode, which Codex structured output requires.
51
+
52
+ Strict mode forbids extra properties and requires every property, so an absent handoff
53
+ comes back as null.
54
+ """
55
+ properties = dict(outcome_schema["properties"])
56
+ properties["handoff"] = {**properties["handoff"], "type": ["string", "null"]}
57
+ return {
58
+ **outcome_schema,
59
+ "properties": properties,
60
+ "required": list(properties),
61
+ "additionalProperties": False,
62
+ }
63
+
64
+
65
+ def _format_toml_string(value: str) -> str:
66
+ """Format a string as the TOML basic string a `-c` override takes.
67
+
68
+ A JSON string with raw non-ASCII characters is a TOML basic string, and ensure_ascii=False
69
+ avoids the surrogate-pair escapes TOML rejects.
70
+ """
71
+ return json.dumps(value, ensure_ascii=False)
72
+
73
+
74
+ @contextmanager
75
+ def build_argv(invocation: AgentInvocation) -> Iterator[list[str]]:
76
+ """Yield the argv that runs the agent through the Codex CLI.
77
+
78
+ The outcome schema lives in a temporary file for the duration of the with block.
79
+ """
80
+ session = invocation.session
81
+ with tempfile.NamedTemporaryFile("w", suffix=".json") as schema_file:
82
+ schema_file.write(json.dumps(_build_strict_schema(invocation.outcome_schema)))
83
+ schema_file.flush()
84
+ # A resumed session already holds the developer instructions, and `codex exec resume`
85
+ # has no --sandbox flag, so the sandbox goes as a configuration override.
86
+ session_dependent_argv = (
87
+ [
88
+ "--sandbox",
89
+ invocation.sandbox,
90
+ "-c",
91
+ f"developer_instructions={_format_toml_string(invocation.agent_definition['prompt'])}",
92
+ ]
93
+ if session is None
94
+ else ["-c", f"sandbox_mode={_format_toml_string(invocation.sandbox)}"]
95
+ )
96
+ argv = [
97
+ "codex",
98
+ "exec",
99
+ *([] if session is None else ["resume", session]),
100
+ "--json",
101
+ # The target directory of a run is any directory; codex exec refuses a non-git one.
102
+ "--skip-git-repo-check",
103
+ "--model",
104
+ invocation.model,
105
+ "-c",
106
+ f"model_reasoning_effort={_format_toml_string(invocation.effort)}",
107
+ *session_dependent_argv,
108
+ "--output-schema",
109
+ schema_file.name,
110
+ ]
111
+ if invocation.web_search is not None:
112
+ web_search = tomlkit.inline_table()
113
+ web_search["value"] = invocation.web_search
114
+ argv += ["-c", f"tools.web_search={tomlkit.item(web_search['value']).as_string()}"]
115
+ # The prompt is any text; `--` keeps one starting with a hyphen out of the options.
116
+ yield [*argv, "--", invocation.prompt]
117
+
118
+
119
+ def _read_auth_mode() -> str:
120
+ """Return the Codex login mode; without a readable auth file, Codex runs on an API key."""
121
+ auth_file = Path.home() / ".codex" / "auth.json"
122
+ try:
123
+ return str(json.loads(auth_file.read_text()).get("auth_mode", "apikey"))
124
+ except (OSError, ValueError, AttributeError):
125
+ return "apikey"
126
+
127
+
128
+ def _estimate_cost_usd(model: str, usage: Any) -> float:
129
+ """Estimate the USD cost of the usage at list API prices for the model.
130
+
131
+ Cost is secondary to the run: an unknown model or unexpected usage yields 0, never a failure.
132
+ """
133
+ if model not in MODEL_RATES or not isinstance(usage, dict):
134
+ return 0.0
135
+ input_rate, cached_rate, cache_write_rate, output_rate = MODEL_RATES[model]
136
+ if _read_auth_mode() == "chatgpt":
137
+ # A ChatGPT login pays nothing for cache writes.
138
+ cache_write_rate = 0.0
139
+ try:
140
+ input_tokens = int(usage["input_tokens"])
141
+ cached_tokens = int(usage.get("cached_input_tokens", 0))
142
+ cache_write_tokens = int(usage.get("cache_write_input_tokens", 0))
143
+ output_tokens = int(usage["output_tokens"])
144
+ except (KeyError, TypeError, ValueError):
145
+ return 0.0
146
+ # Reasoning tokens are a breakdown of the output tokens, not an addition.
147
+ uncached_tokens = input_tokens - cached_tokens - cache_write_tokens
148
+ if min(uncached_tokens, cached_tokens, cache_write_tokens, output_tokens) < 0:
149
+ return 0.0
150
+ return (
151
+ uncached_tokens * input_rate
152
+ + cached_tokens * cached_rate
153
+ + cache_write_tokens * cache_write_rate
154
+ + output_tokens * output_rate
155
+ ) / 1_000_000
156
+
157
+
158
+ def read_result(invocation: AgentInvocation, stdout_lines: Sequence[str]) -> tuple[Any, float]:
159
+ """Return the structured output of the last agent message and the estimated cost."""
160
+ agent_node_name = invocation.agent_node_name
161
+ last_agent_message = None
162
+ usage = None
163
+ for event in iter_jsonl_events(stdout_lines):
164
+ event_type = event.get("type")
165
+ if event_type in ("turn.failed", "error"):
166
+ # A turn.failed carries its message under `error`; an error event carries it directly.
167
+ error = event.get("error")
168
+ error_message = event.get("message") or (
169
+ error.get("message") if isinstance(error, dict) else error
170
+ )
171
+ raise NodeFailure(f"node '{agent_node_name}': agent reported an error: {error_message}")
172
+ item = event.get("item")
173
+ if (
174
+ event_type == "item.completed"
175
+ and isinstance(item, dict)
176
+ and item.get("type") == "agent_message"
177
+ and isinstance(item.get("text"), str)
178
+ ):
179
+ last_agent_message = item["text"]
180
+ if event_type == "turn.completed":
181
+ usage = event.get("usage")
182
+ if last_agent_message is None:
183
+ raise NodeFailure(f"node '{agent_node_name}': agent output holds no agent message")
184
+ try:
185
+ structured_output = json.loads(last_agent_message)
186
+ except json.JSONDecodeError:
187
+ structured_output = None
188
+ return structured_output, _estimate_cost_usd(invocation.model, usage)
189
+
190
+
191
+ def read_session(stdout_lines: Sequence[str]) -> str | None:
192
+ """Return the session the last thread.started event names by its Codex thread id."""
193
+ thread_events = (
194
+ event for event in iter_jsonl_events(stdout_lines) if event.get("type") == "thread.started"
195
+ )
196
+ return read_last_value(thread_events, "thread_id")
197
+
198
+
199
+ def _read_narration(agent_message_text: str) -> str | None:
200
+ """Return the handoff of a schema-shaped agent message, else the text itself.
201
+
202
+ Under --output-schema Codex shapes every agent message like the outcome, so the narration
203
+ of a message sits in its handoff.
204
+ """
205
+ try:
206
+ handoff: str | None = json.loads(agent_message_text)["handoff"]
207
+ except (json.JSONDecodeError, TypeError, KeyError):
208
+ return agent_message_text
209
+ return handoff
210
+
211
+
212
+ def render_transcript(stdout_lines: Sequence[str]) -> list[Text]:
213
+ """Render the narration of the agent messages, command executions, and file changes.
214
+
215
+ Reasoning and every other item are dropped.
216
+ """
217
+ completed_items = [
218
+ event["item"]
219
+ for event in iter_jsonl_events(stdout_lines)
220
+ if event.get("type") == "item.completed" and isinstance(event.get("item"), dict)
221
+ ]
222
+ transcript_rows: list[Text] = []
223
+ for item in completed_items:
224
+ match item.get("type"):
225
+ case "agent_message":
226
+ transcript_rows += split_lines(_read_narration(item["text"]))
227
+ case "command_execution":
228
+ transcript_rows.append(Text(f"▸ command_execution: {item['command']}", "bold"))
229
+ case "file_change":
230
+ paths = ", ".join(change["path"] for change in item["changes"])
231
+ transcript_rows.append(Text(f"▸ file_change: {paths}", "bold"))
232
+ return transcript_rows
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: wg_code-review
3
+ description: Reviews the branch's diff against the merge-base for standards and spec.
4
+ tools: Bash, Read, Glob, Grep, Skill, Task
5
+ ---
6
+ Invoke the `/mattpocock-skills:code-review` skill over the branch's diff
7
+ against the merge-base with the default branch. The skill runs the Standards
8
+ and Spec axes in parallel sub-agents.
9
+
10
+ Your job is this review only. The workflow's other nodes implement and open
11
+ the PR: do not change code and do not open a PR.
12
+
13
+ Report `pass` when both axes are clean. Otherwise report `fail` and list
14
+ every finding in the handoff.
@@ -0,0 +1,57 @@
1
+ ---
2
+ name: wg_design
3
+ description: Makes sure the change the run input names has an agent brief.
4
+ tools: Bash, Read, Glob, Grep
5
+ ---
6
+ The run input names a GitHub issue in this repository, or a task with no
7
+ issue. Read the issue with `gh issue view <number> --comments`. Take the
8
+ owner and the repo from `git remote -v`. Where `gh` is unavailable, read
9
+ `https://api.github.com/repos/<owner>/<repo>/issues/<number>` and its
10
+ `/comments` with `curl`, and post or edit through the same API with `curl`
11
+ and the `GITHUB_TOKEN` environment variable. Do not modify the repository.
12
+
13
+ An agent brief is the issue body or an issue comment whose text contains the
14
+ heading `## Agent Brief`. Its link is the issue URL
15
+ (`https://github.com/<owner>/<repo>/issues/<number>`) when the brief is in the
16
+ body, and the comment's `html_url`
17
+ (`https://github.com/<owner>/<repo>/issues/<number>#issuecomment-<id>`) when
18
+ the brief is in a comment. `gh api repos/<owner>/<repo>/issues/<number>/comments`
19
+ gives the comment ids, bodies, and `html_url` values. When several comments
20
+ hold a brief, the last one is the brief.
21
+
22
+ ## The four cases
23
+
24
+ 1. No handoff and the issue carries a brief: do no other work. Report `done`
25
+ with the brief's link on one line as the handoff.
26
+ 2. No handoff and the issue carries no brief: read the code the issue
27
+ touches, write the brief, and post it with
28
+ `gh issue comment <number> --body-file <file>` from a temporary file
29
+ outside the repository. Read the new comment's link with
30
+ `gh api repos/<owner>/<repo>/issues/<number>/comments --jq '.[-1].html_url'`
31
+ and report `done` with that link as the handoff.
32
+ 3. A handoff with `received` and `feedback`: `received` holds the brief's
33
+ link. `feedback` holds the feedback on the rejected brief. Rewrite the
34
+ brief to address every point of the feedback, keeping the first line and
35
+ the seven sections. Edit it in place with
36
+ `gh api --method PATCH repos/<owner>/<repo>/issues/comments/<id> -F body=@<file>`
37
+ for a comment and `gh issue edit <number> --body-file <file>` for the body.
38
+ Report `done` with the same link as the handoff.
39
+ 4. The run input names no issue: write the brief from the run input and the
40
+ code, and report `done` with the whole brief as the handoff. A later
41
+ handoff then carries the brief itself under `received`; revise it to
42
+ address every point of `feedback` and report the revised brief inline
43
+ again.
44
+
45
+ ## Brief shape
46
+
47
+ Write the brief under the AGENTS.md writing rules. Its first line is
48
+ `> *This was generated by AI as the design node.*`. The heading
49
+ `## Agent Brief` follows, then seven sections:
50
+
51
+ - **Category**: the kind of change.
52
+ - **Summary**: one sentence.
53
+ - **Current behavior**: what the code does today.
54
+ - **Desired behavior**: what it does after the change.
55
+ - **Key interfaces**: the names and types the change touches.
56
+ - **Acceptance criteria**: a checklist of independently verifiable items.
57
+ - **Out of scope**: what the change leaves alone.
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: wg_implement
3
+ description: Implements a GitHub issue in the current repository.
4
+ tools: Bash, Read, Edit, Write, Glob, Grep, Skill
5
+ ---
6
+ The run input names a GitHub issue in this repository, or a task with no
7
+ issue. Read the issue with `gh issue view <number> --comments`. Take the
8
+ owner and the repo from `git remote -v`. Where `gh` is unavailable, read
9
+ `https://api.github.com/repos/<owner>/<repo>/issues/<number>` and its
10
+ `/comments` with `curl`.
11
+
12
+ ## The plan
13
+
14
+ The plan comes from the first of these that holds:
15
+
16
+ 1. The handoff, when it is one line holding an issue comment link
17
+ (`https://github.com/<owner>/<repo>/issues/<number>#issuecomment-<id>`).
18
+ Read the plan with
19
+ `gh api repos/<owner>/<repo>/issues/comments/<id> --jq .body`.
20
+ 2. Else the issue's last comment whose text contains the heading
21
+ `## Implementation Plan`, from
22
+ `gh api repos/<owner>/<repo>/issues/<number>/comments`.
23
+ 3. Else the plan in the prompt, for a run whose input names no issue.
24
+
25
+ Follow the plan task by task, one commit per task, with the task's commit
26
+ summary. On re-entry from `test` or `review-loop`, the handoff is not the
27
+ plan, so locate the plan on the issue and still address every finding the
28
+ handoff carries.
29
+
30
+ Implement what the issue asks:
31
+
32
+ - Follow the repository standards in AGENTS.md.
33
+ - Use the `/mattpocock-skills:tdd` skill where possible.
34
+ - Run typechecking and single test files regularly, and the full test suite
35
+ once at the end.
36
+ - Commit the work to the current branch.
37
+
38
+ Report `done` when the implementation and its tests are complete and
39
+ committed.
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: wg_overengineering-review
3
+ description: Reviews the branch's diff against the merge-base for over-engineering.
4
+ tools: Bash, Read, Glob, Grep, Skill
5
+ ---
6
+ Invoke the `/ponytail:ponytail-review` skill over the branch's diff against
7
+ the merge-base with the default branch.
8
+
9
+ Your job is this review only. The workflow's other nodes implement and open
10
+ the PR: do not change code and do not open a PR.
11
+
12
+ Report `pass` when there are no findings. Otherwise report `fail` and list
13
+ the findings in the handoff.
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: wg_plan
3
+ description: Makes sure the change the run input names has an implementation plan.
4
+ tools: Bash, Read, Glob, Grep
5
+ ---
6
+ The run input names a GitHub issue in this repository, or a task with no
7
+ issue. Read the issue with `gh issue view <number> --comments`. Take the
8
+ owner and the repo from `git remote -v`. Where `gh` is unavailable, read
9
+ `https://api.github.com/repos/<owner>/<repo>/issues/<number>` and its
10
+ `/comments` with `curl`, and post or edit through the same API with `curl`
11
+ and the `GITHUB_TOKEN` environment variable. Do not modify the repository.
12
+
13
+ The handoff on entry from `approve-design` carries the link of the issue's
14
+ agent brief, or the brief itself when the run input names no issue. The brief
15
+ is the body or the comment at the link, under the heading `## Agent Brief`.
16
+ The plan follows the brief: its tasks implement the brief's desired behavior
17
+ and stay inside its scope.
18
+
19
+ An implementation plan is an issue comment whose text contains the heading
20
+ `## Implementation Plan`. Its link is the comment's `html_url`
21
+ (`https://github.com/<owner>/<repo>/issues/<number>#issuecomment-<id>`).
22
+ `gh api repos/<owner>/<repo>/issues/<number>/comments` gives the comment ids,
23
+ bodies, and `html_url` values. When several comments hold a plan, the last one
24
+ is the plan.
25
+
26
+ ## The four cases
27
+
28
+ 1. The handoff holds the brief's link and the issue carries a plan: do no
29
+ other work. Report `done` with the plan's link on one line as the handoff.
30
+ 2. The handoff holds the brief's link and the issue carries no plan: read the
31
+ brief, read the code the issue touches, write the plan, and post it with
32
+ `gh issue comment <number> --body-file <file>` from a temporary file
33
+ outside the repository. Read the new comment's link with
34
+ `gh api repos/<owner>/<repo>/issues/<number>/comments --jq '.[-1].html_url'`
35
+ and report `done` with that link on one line as the handoff.
36
+ 3. A handoff with `received` and `feedback`: `received` holds the plan's link.
37
+ `feedback` holds the feedback on the rejected plan. Rewrite the plan to
38
+ address every point of the feedback, keeping the first line and the
39
+ heading. Edit it in place with
40
+ `gh api --method PATCH repos/<owner>/<repo>/issues/comments/<id> -F body=@<file>`.
41
+ Report `done` with the same link as the handoff.
42
+ 4. The run input names no issue: write the plan from the brief and the code,
43
+ and report `done` with the whole plan as the handoff. A later handoff then
44
+ carries the plan itself under `received`; revise it to address every point
45
+ of `feedback` and report the revised plan inline again.
46
+
47
+ ## Plan shape
48
+
49
+ Write the plan under the AGENTS.md writing rules. Use file paths, never
50
+ line numbers. Do not use code blocks.
51
+
52
+ The plan's first line is `> *This was generated by AI as the plan node.*`.
53
+ The heading `## Implementation Plan` follows, then:
54
+
55
+ 1. Header:
56
+ - **Goal**: one sentence.
57
+ - **Approach**: two or three sentences.
58
+ - **Issue**: `#<number>`.
59
+ 2. **Files**: every file created or modified, one line each on its
60
+ responsibility.
61
+ 3. **Tasks**, in order. A task is the smallest unit with its own test cycle
62
+ that a reviewer could reject on its own. Setup, configuration, and
63
+ documentation belong to the task that needs them. Each task lists:
64
+ - the files it touches;
65
+ - the interfaces it consumes from earlier tasks and produces for later
66
+ ones, with exact names and types;
67
+ - its tests, described by behavior;
68
+ - its commit summary.
69
+
70
+ Do not write placeholders: no "TBD", no "add error handling", no "similar
71
+ to task N", no reference to a name that no task defines.
72
+
73
+ ## Self-check
74
+
75
+ Before reporting, check the plan against the brief:
76
+
77
+ - every acceptance criterion of the brief maps to a task;
78
+ - no placeholder remains;
79
+ - a name used in several tasks is the same in all of them.
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: wg_pr
3
+ description: Squashes the branch to one commit and creates or updates the pull request.
4
+ tools: Bash, Read, Glob, Grep
5
+ ---
6
+ The run input names a GitHub issue in this repository.
7
+
8
+ 1. Run `git fetch origin main`, then squash the branch to exactly one
9
+ commit on top of `origin/main` with a rebase or `git commit --amend`,
10
+ then `git push --force-with-lease`. Never use the local `main`: it is
11
+ stale. The branch carries only this run's commits; never rewrite
12
+ history containing someone else's commits.
13
+ 2. Rewrite the commit message to fit the rules below, amend, and push
14
+ again. Do not just validate — fix any violation yourself.
15
+ 3. Create the pull request for the branch with `gh pr create`, or update
16
+ the existing one with `gh pr edit`. Never merge it.
17
+ 4. When the prompt carries a handoff from `summary`, add a `Not addressed`
18
+ section to the PR body that lists every finding in it.
19
+
20
+ Rules for the commit message and the PR text:
21
+
22
+ - Commit message: one summary line, then at most one paragraph of 3–5
23
+ sentences, then a final line `Closes #<number>` that names the issue
24
+ from the run input.
25
+ - PR title: the commit summary line, verbatim.
26
+ - PR description: the first line is `Closes #<number>`, naming the issue
27
+ from the run input. The rest is more detailed than the commit message,
28
+ at most 3 sections plus the `Not addressed` section, formatted with
29
+ markdown; itemized lists replace long paragraphs and sentences.
30
+ - Everywhere: simple sentences, active voice, and one term per concept —
31
+ reuse the terms the repository already uses instead of varying them.
32
+
33
+ Report `done` when the PR exists and matches the branch.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: wg_summarize-review
3
+ description: Summarizes the findings of the last review as not addressed.
4
+ ---
5
+ The prompt carries a handoff from `review` with the findings of the last
6
+ review. Those findings, and only those, are not addressed. Summarize them:
7
+ one bullet per finding, naming its location and what it asks for. When the
8
+ prompt carries no handoff from `review`, report `done` with no handoff.
9
+
10
+ Report `done` with the summary as the handoff.
@@ -0,0 +1,101 @@
1
+ start = "design"
2
+
3
+ [defaults]
4
+ harness = "claude"
5
+ model = "claude-fable-5-1"
6
+ effort = "high"
7
+
8
+ [nodes.design]
9
+ agent = "wg_design"
10
+ effort = "xhigh"
11
+ outcomes = ["done"]
12
+
13
+ [nodes.design.transitions]
14
+ done = "approve-design"
15
+
16
+ [nodes.approve-design]
17
+ gate = "Plan from this design?"
18
+
19
+ [nodes.approve-design.transitions]
20
+ accept = "plan"
21
+ reject = "design"
22
+
23
+ [nodes.plan]
24
+ agent = "wg_plan"
25
+ effort = "xhigh"
26
+ outcomes = ["done"]
27
+
28
+ [nodes.plan.transitions]
29
+ done = "approve-plan"
30
+
31
+ [nodes.approve-plan]
32
+ gate = "Implement this plan?"
33
+
34
+ [nodes.approve-plan.transitions]
35
+ accept = "implement"
36
+ reject = "plan"
37
+
38
+ [nodes.implement]
39
+ agent = "wg_implement"
40
+ model = "claude-opus-5"
41
+ outcomes = ["done"]
42
+
43
+ [nodes.implement.transitions]
44
+ done = "test"
45
+
46
+ [nodes.test]
47
+ command = "sh -c 'uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest'"
48
+
49
+ [nodes.test.limits]
50
+ visits = 5
51
+ reset = "pass"
52
+
53
+ [nodes.test.transitions]
54
+ pass = "review"
55
+ fail = "implement"
56
+
57
+ [nodes.review]
58
+ map = ["code-review", "overengineering-review"]
59
+ resolve = "all"
60
+
61
+ [nodes.review.transitions]
62
+ pass = "pr"
63
+ fail = "review-loop"
64
+
65
+ # Holds the review limit so the LIMIT diversion carries the review findings.
66
+ [nodes.review-loop]
67
+ command = "true"
68
+
69
+ [nodes.review-loop.limits]
70
+ visits = 2
71
+
72
+ [nodes.review-loop.transitions]
73
+ pass = "implement"
74
+ fail = "implement"
75
+ LIMIT = "summary"
76
+
77
+ [nodes.code-review]
78
+ agent = "wg_code-review"
79
+ outcomes = ["pass", "fail"]
80
+
81
+ [nodes.overengineering-review]
82
+ agent = "wg_overengineering-review"
83
+ outcomes = ["pass", "fail"]
84
+
85
+ [nodes.summary]
86
+ agent = "wg_summarize-review"
87
+ model = "sonnet"
88
+ effort = "medium"
89
+ outcomes = ["done"]
90
+
91
+ [nodes.summary.transitions]
92
+ done = "pr"
93
+
94
+ [nodes.pr]
95
+ agent = "wg_pr"
96
+ model = "haiku"
97
+ effort = "medium"
98
+ outcomes = ["done"]
99
+
100
+ [nodes.pr.transitions]
101
+ done = "END"