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/workflow.py ADDED
@@ -0,0 +1,263 @@
1
+ """Workflow discovery, loading, validation, and mermaid rendering."""
2
+
3
+ import math
4
+ import re
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any, NoReturn
8
+
9
+ from workgraph.harness import HARNESS_NAMES
10
+
11
+ END = "END"
12
+ LIMIT = "LIMIT"
13
+ RESERVED_NAMES = frozenset({END, LIMIT})
14
+ AGENT_SETTINGS = ("harness", "model", "effort")
15
+ HARNESS_SETTINGS = {"allowed_tools": "claude", "sandbox": "codex", "web_search": "codex"}
16
+ NODE_KINDS = ("agent", "command", "map", "gate")
17
+ TIME_KEYS = ("time_soft", "time_hard")
18
+ BUDGET_KEYS = (*TIME_KEYS, "cost")
19
+ DURATION_PATTERN = re.compile(r"(\d+(?:\.\d+)?)([smh]?)")
20
+ DURATION_UNITS = {"": 1, "s": 1, "m": 60, "h": 3600}
21
+
22
+
23
+ class WorkflowError(Exception):
24
+ """A workflow failed to load or validate."""
25
+
26
+
27
+ def load_workflow(workflow_name: str) -> dict[str, Any]:
28
+ """Find the workflow by name, parse it, and validate every load-time rule."""
29
+ path = _find_workflow_file(workflow_name)
30
+ with path.open("rb") as file:
31
+ try:
32
+ workflow = tomllib.load(file)
33
+ except tomllib.TOMLDecodeError as error:
34
+ raise WorkflowError(f"{workflow_name}: invalid TOML: {error}") from error
35
+ _validate_workflow(workflow_name, workflow)
36
+ if "budget" in workflow:
37
+ workflow["budget"] = _validate_budget(workflow_name, workflow["budget"])
38
+ return workflow
39
+
40
+
41
+ def parse_duration(duration: object) -> float:
42
+ """Return the seconds a duration denotes: a positive number, or a string with unit s, m, or h.
43
+
44
+ parse_duration raises ValueError for any other input.
45
+ """
46
+ if isinstance(duration, int | float) and not isinstance(duration, bool):
47
+ seconds = float(duration)
48
+ elif isinstance(duration, str) and (match := DURATION_PATTERN.fullmatch(duration)):
49
+ seconds = float(match[1]) * DURATION_UNITS[match[2]]
50
+ else:
51
+ raise ValueError(
52
+ f"invalid duration {duration!r}: expected seconds or a number with unit s, m, or h"
53
+ )
54
+ if seconds <= 0:
55
+ raise ValueError(f"invalid duration {duration!r}: must be positive")
56
+ return seconds
57
+
58
+
59
+ def parse_cost(cost: object) -> float:
60
+ """Return the USD a cost denotes: a positive number, or its string form.
61
+
62
+ parse_cost raises ValueError for any other input.
63
+ """
64
+ message = f"invalid cost {cost!r}: expected a positive USD number"
65
+ if isinstance(cost, bool) or not isinstance(cost, int | float | str):
66
+ raise ValueError(message)
67
+ try:
68
+ usd = float(cost)
69
+ except ValueError:
70
+ raise ValueError(message) from None
71
+ if not usd > 0:
72
+ raise ValueError(message)
73
+ return usd
74
+
75
+
76
+ def resolve_agent_settings(
77
+ node_definition: dict[str, Any], defaults: dict[str, Any]
78
+ ) -> dict[str, Any]:
79
+ """Return the agent settings of a node: its own value, else the default."""
80
+ settings = {key: node_definition.get(key, defaults.get(key)) for key in AGENT_SETTINGS}
81
+ settings.update(
82
+ (key, node_definition.get(key, defaults.get(key)))
83
+ for key, owner in HARNESS_SETTINGS.items()
84
+ if owner == settings["harness"] and (key in node_definition or key in defaults)
85
+ )
86
+ return settings
87
+
88
+
89
+ def render_mermaid(workflow: dict[str, Any]) -> str:
90
+ """Render a validated workflow as a bare mermaid flowchart.
91
+
92
+ The start node is drawn as a stadium and a gate node as a hexagon so
93
+ every viz style (unicode, ascii, mermaid) marks them.
94
+ """
95
+ start_node = workflow["start"]
96
+ lines = ["flowchart TD", f" {start_node}([{start_node}])"]
97
+ for node_name, node_definition in workflow["nodes"].items():
98
+ if "gate" in node_definition:
99
+ lines.append(f" {node_name}{{{{{node_name}}}}}")
100
+ for fanned_out_node in node_definition.get("map", []):
101
+ lines.append(f" {node_name} --> {fanned_out_node}")
102
+ for outcome, target in node_definition.get("transitions", {}).items():
103
+ lines.append(f" {node_name} -->|{outcome}| {target}")
104
+ return "\n".join(lines)
105
+
106
+
107
+ def list_definition_directories() -> tuple[Path, Path, Path]:
108
+ """Return where definitions resolve from, in order: invocation directory, home, package."""
109
+ return (
110
+ Path.cwd() / ".workgraph",
111
+ Path.home() / ".workgraph",
112
+ Path(__file__).parent / "definitions",
113
+ )
114
+
115
+
116
+ def _find_workflow_file(workflow_name: str) -> Path:
117
+ for definition_directory in list_definition_directories():
118
+ path = definition_directory / "workflows" / f"{workflow_name}.toml"
119
+ if path.is_file():
120
+ return path
121
+ raise WorkflowError(
122
+ f"workflow '{workflow_name}' not found in a .workgraph/workflows directory of the"
123
+ " invocation directory or the home directory, nor among the bundled workflows"
124
+ )
125
+
126
+
127
+ def _validate_workflow(workflow_name: str, workflow: dict[str, Any]) -> None:
128
+ nodes = workflow.get("nodes", {})
129
+ start_node = workflow.get("start")
130
+ if start_node is None:
131
+ raise WorkflowError(f"{workflow_name}: missing top-level 'start'")
132
+ if start_node not in nodes:
133
+ raise WorkflowError(f"{workflow_name}: start node '{start_node}' does not exist")
134
+ fanned_out_by = _collect_fanned_out(workflow_name, nodes)
135
+ if start_node in fanned_out_by:
136
+ raise WorkflowError(
137
+ f"{workflow_name}: start node '{start_node}' is fanned out by map node '{fanned_out_by[start_node]}'"
138
+ )
139
+ defaults = workflow.get("defaults", {})
140
+ for node_name, node_definition in nodes.items():
141
+ _validate_node(workflow_name, nodes, defaults, fanned_out_by, node_name, node_definition)
142
+
143
+
144
+ def _validate_budget(workflow_name: str, budget: dict[str, Any]) -> dict[str, float]:
145
+ """Check the budget keys and return the time limits in seconds and the cost limit in USD."""
146
+ if not isinstance(budget, dict):
147
+ raise WorkflowError(f"{workflow_name}: [budget] must be a table")
148
+ limits: dict[str, float] = {}
149
+ for key, value in budget.items():
150
+ if key not in BUDGET_KEYS:
151
+ raise WorkflowError(f"{workflow_name}: [budget]: unknown key '{key}'")
152
+ try:
153
+ limits[key] = parse_cost(value) if key == "cost" else parse_duration(value)
154
+ except ValueError as error:
155
+ raise WorkflowError(f"{workflow_name}: [budget]: {key}: {error}") from error
156
+ if limits.get("time_hard", math.inf) < limits.get("time_soft", 0):
157
+ raise WorkflowError(f"{workflow_name}: [budget]: time_hard is below time_soft")
158
+ return limits
159
+
160
+
161
+ def _collect_fanned_out(workflow_name: str, nodes: dict[str, Any]) -> dict[str, str]:
162
+ """Map each fanned-out node to its map node, checking the fan-out lists."""
163
+ fanned_out_by: dict[str, str] = {}
164
+ for node_name, node_definition in nodes.items():
165
+ for fanned_out_node in node_definition.get("map", []):
166
+ if fanned_out_node not in nodes:
167
+ raise WorkflowError(
168
+ f"{workflow_name}: node '{node_name}': fanned-out node '{fanned_out_node}' does not exist"
169
+ )
170
+ for kind in ("map", "gate"):
171
+ if kind in nodes[fanned_out_node]:
172
+ raise WorkflowError(
173
+ f"{workflow_name}: node '{node_name}': fanned-out node '{fanned_out_node}'"
174
+ f" is a {kind} node"
175
+ )
176
+ if fanned_out_node in fanned_out_by:
177
+ raise WorkflowError(
178
+ f"{workflow_name}: node '{node_name}': fanned-out node '{fanned_out_node}'"
179
+ f" is already fanned out by map node '{fanned_out_by[fanned_out_node]}'"
180
+ )
181
+ fanned_out_by[fanned_out_node] = node_name
182
+ return fanned_out_by
183
+
184
+
185
+ def _validate_node(
186
+ workflow_name: str,
187
+ nodes: dict[str, Any],
188
+ defaults: dict[str, Any],
189
+ fanned_out_by: dict[str, str],
190
+ node_name: str,
191
+ node_definition: dict[str, Any],
192
+ ) -> None:
193
+ def fail(rule: str) -> NoReturn:
194
+ raise WorkflowError(f"{workflow_name}: node '{node_name}': {rule}")
195
+
196
+ if node_name in RESERVED_NAMES:
197
+ fail(f"'{node_name}' is reserved and cannot name a node")
198
+ if node_name.endswith("#"):
199
+ fail("a node name cannot end with '#'")
200
+ if sum(kind in node_definition for kind in NODE_KINDS) != 1:
201
+ fail("declare exactly one of 'agent', 'command', 'map', or 'gate'")
202
+ if "agent" not in node_definition:
203
+ kind = next(kind for kind in NODE_KINDS if kind in node_definition)
204
+ if "outcomes" in node_definition:
205
+ fail(f"a {kind} node cannot declare 'outcomes'")
206
+ for setting in (*AGENT_SETTINGS, *HARNESS_SETTINGS):
207
+ if setting in node_definition:
208
+ fail(f"a {kind} node cannot declare '{setting}'")
209
+ if kind == "map":
210
+ if not node_definition["map"]:
211
+ fail("'map' must list at least one node")
212
+ if node_definition.get("resolve") not in ("any", "all"):
213
+ fail("'resolve' must be 'any' or 'all'")
214
+ if kind == "gate":
215
+ if not isinstance(node_definition["gate"], str) or not node_definition["gate"]:
216
+ fail("'gate' must be a non-empty question")
217
+ if "limits" in node_definition:
218
+ fail("a gate node cannot declare 'limits'")
219
+ outcomes = ["accept", "reject"] if kind == "gate" else ["pass", "fail"]
220
+ else:
221
+ outcomes = node_definition.get("outcomes", [])
222
+ if not outcomes:
223
+ fail("an agent node must declare a non-empty 'outcomes' list")
224
+ for outcome in outcomes:
225
+ if outcome in RESERVED_NAMES:
226
+ fail(f"'{outcome}' is reserved and cannot name an outcome")
227
+ settings = resolve_agent_settings(node_definition, defaults)
228
+ for setting in AGENT_SETTINGS:
229
+ if settings[setting] is None:
230
+ fail(f"'{setting}' is set neither on the node nor in [defaults]")
231
+ harness = settings["harness"]
232
+ if harness not in HARNESS_NAMES:
233
+ accepted = ", ".join(repr(name) for name in HARNESS_NAMES)
234
+ fail(f"harness '{harness}' is not supported; accepted: {accepted}")
235
+ for setting, owner in HARNESS_SETTINGS.items():
236
+ if setting in node_definition and harness != owner:
237
+ fail(f"'{setting}' belongs to harness '{owner}', but the node uses '{harness}'")
238
+ if node_name in fanned_out_by:
239
+ for field in ("transitions", "limits"):
240
+ if field in node_definition:
241
+ fail(f"a fanned-out node cannot declare '{field}'")
242
+ if "pass" not in outcomes:
243
+ fail("a fanned-out node must have 'pass' in its outcomes")
244
+ return
245
+ limits = node_definition.get("limits", {})
246
+ if "reset" in limits:
247
+ if "visits" not in limits:
248
+ fail("'reset' requires 'visits' in the same limits table")
249
+ if limits["reset"] not in outcomes:
250
+ fail(f"reset outcome '{limits['reset']}' is not an outcome of the node")
251
+ transitions = node_definition.get("transitions", {})
252
+ for outcome in outcomes:
253
+ if outcome not in transitions:
254
+ fail(f"missing a transition for outcome '{outcome}'")
255
+ for transition_key, target in transitions.items():
256
+ if transition_key != LIMIT and transition_key not in outcomes:
257
+ fail(f"transition key '{transition_key}' is not an outcome of the node")
258
+ if target != END and target not in nodes:
259
+ fail(f"transition target '{target}' does not exist")
260
+ if target in fanned_out_by:
261
+ fail(
262
+ f"transition target '{target}' is fanned out by map node '{fanned_out_by[target]}'"
263
+ )
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: workgraph
3
+ Version: 0.3.3
4
+ Summary: Graph workflow orchestrator.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: termaid[rich]>=0.8.0
8
+ Requires-Dist: tomlkit>=0.15.1
9
+ Requires-Python: >=3.12
10
+ Project-URL: Changelog, https://github.com/sylmarien/workgraph/blob/main/CHANGELOG.md
11
+ Project-URL: Repository, https://github.com/sylmarien/workgraph
12
+ Description-Content-Type: text/markdown
13
+
14
+ # workgraph
15
+
16
+ workgraph orchestrates development workflows declared as graphs. Nodes run
17
+ agents or commands; a node's outcome selects the transition to follow. The
18
+ developer writes the workflow once and starts a run from a harness session
19
+ with `/workgraph`. The run prints one progress line per node. One command
20
+ resumes a stopped run.
21
+
22
+ The CLI bundles workflow and agent definitions; [Workflow
23
+ files](docs/workflow-files.md) lists them. This repository runs the bundled
24
+ `wg` workflow on itself:
25
+
26
+ ```mermaid
27
+ flowchart TD
28
+ design([design])
29
+ design -->|done| approve-design
30
+ approve-design -->|accept| plan
31
+ approve-design -->|reject| design
32
+ plan -->|done| approve-plan
33
+ approve-plan -->|accept| implement
34
+ approve-plan -->|reject| plan
35
+ implement -->|done| test
36
+ test -->|pass| review
37
+ test -->|fail| implement
38
+ review --> code-review
39
+ review --> overengineering-review
40
+ review -->|pass| pr
41
+ review -->|fail| review-loop
42
+ review-loop -->|pass| implement
43
+ review-loop -->|fail| implement
44
+ review-loop -->|LIMIT| summary
45
+ summary -->|done| pr
46
+ pr -->|done| END
47
+ ```
48
+
49
+ ## Install
50
+
51
+ As a Claude Code plugin:
52
+
53
+ ```
54
+ /plugin marketplace add sylmarien/workgraph
55
+ /plugin install workgraph@workgraph
56
+ ```
57
+
58
+ Installing the plugin adds the `/workgraph` skill. The plugin's `install`
59
+ skill installs the CLI with `uv`, and its `update` skill upgrades it. Both
60
+ check that `uv` is on `PATH` and install nothing else.
61
+
62
+ The CLI bundles workflow and agent definitions. A user's own definitions
63
+ shadow them; see [Workflow files](docs/workflow-files.md) for the bundled
64
+ definitions and the resolution order.
65
+
66
+ As a Codex plugin:
67
+
68
+ ```sh
69
+ codex plugin marketplace add sylmarien/workgraph
70
+ codex plugin add workgraph@workgraph
71
+ ```
72
+
73
+ Start a new Codex session and invoke `$workgraph <workflow> "#<issue>"`.
74
+ The plugin shares the install, update, and run skills with the Claude Code
75
+ plugin. The bundled workflows run nodes on the Claude harness, so they
76
+ require `claude` and `uv` on `PATH` in Codex too.
77
+
78
+ Codex reads the repository's existing marketplace at
79
+ `.claude-plugin/marketplace.json` and its manifest at
80
+ `.codex-plugin/plugin.json`. Both plugins ship in the same Git release.
81
+ The patch, minor, and major release workflows update both manifests and
82
+ the CLI to the same version. See the
83
+ [Codex packaging documentation](https://developers.openai.com/plugins/build/plugins).
84
+
85
+ Without the plugin:
86
+
87
+ ```sh
88
+ uv tool install workgraph
89
+ ```
90
+
91
+ `pip install workgraph` installs the same package from PyPI.
92
+ `uv tool upgrade workgraph` upgrades it. Requires Python 3.12+. An agent
93
+ node additionally requires the CLI of its harness on `PATH`: `claude` for
94
+ `harness = "claude"`, `codex` for `harness = "codex"`.
95
+
96
+ ## Example
97
+
98
+ ```sh
99
+ workgraph run wg "#12"
100
+ ```
101
+
102
+ ```
103
+ design: done
104
+ approve-design: parked
105
+ parked at approve-design: Plan from this design? · spent 1m20s · $0.15
106
+ Review material from design:
107
+ https://github.com/sylmarien/workgraph/issues/12#issuecomment-5550441682
108
+ ```
109
+
110
+ ```sh
111
+ workgraph resume --decision accept
112
+ ```
113
+
114
+ ```
115
+ approve-design: accept
116
+ plan: done
117
+ approve-plan: parked
118
+ parked at approve-plan: Implement this plan? · spent 4m05s · $0.42
119
+ Review material from plan:
120
+ <the plan>
121
+ ```
122
+
123
+ `workgraph resume --decision accept` delivers the decision and resumes the
124
+ run.
125
+
126
+ ## Reference
127
+
128
+ - [Commands](docs/commands.md)
129
+ - [Workflow files](docs/workflow-files.md)
130
+ - [Agent definitions](docs/agent-definitions.md)
131
+
132
+ ## Development
133
+
134
+ ```sh
135
+ uv sync
136
+ uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest
137
+ ```
138
+
139
+ The dogfood workflow runs the same gate: `workgraph run wg "#<issue>"`.
@@ -0,0 +1,23 @@
1
+ workgraph/__init__.py,sha256=-98aA3TsgzNO0jLI6qXojt-dAKhg0jJJxHAgMgoaqCo,35
2
+ workgraph/claude.py,sha256=I6Beld-t8AfNl1K1QY9WA9RHzlM5Gi4uEWb3lkBoenY,3841
3
+ workgraph/cli.py,sha256=bRXDLmZMelm-RCb-XUuzMMmIharVyOFTabBv9j68PYo,12872
4
+ workgraph/codex.py,sha256=6AG2rQtY31oH4iivbxTnrRwA4882wYEPQozs9wazgbI,9589
5
+ workgraph/definitions/agents/wg_code-review.md,sha256=q5mX_DIkOr_KfmnJH6BXYAm-1ME76RAHCSRUiabMJus,576
6
+ workgraph/definitions/agents/wg_design.md,sha256=mbNULWzJFtkhJ1JLCMui0YePyFsBFnygpdUYOnbd0cU,2960
7
+ workgraph/definitions/agents/wg_implement.md,sha256=ZhhKkmrta9hweXRp1BadAlrx4nk9QG6fHvbvmAediH0,1576
8
+ workgraph/definitions/agents/wg_overengineering-review.md,sha256=tBhBTygGQvHW5eA592GGmp586WAyHFsjZ1dcNU6RzVc,508
9
+ workgraph/definitions/agents/wg_plan.md,sha256=GZq-2isG-en2fZ1eKTVDVE5zJiOIBYQvs3NT3XmqBWo,3798
10
+ workgraph/definitions/agents/wg_pr.md,sha256=oNGkLbdTehraFUEV_htbKdpCJA2q7GUMuUAll6OB7M4,1664
11
+ workgraph/definitions/agents/wg_summarize-review.md,sha256=17Qe-3fBEyjTavaCtGDNACrYxQn3Cushu2_2dPDeFRg,451
12
+ workgraph/definitions/workflows/wg.toml,sha256=kzSY_6qoetRHxpV1NwkE1U6k-HoDycVJlxoSBz3XL48,1751
13
+ workgraph/definitions/workflows/wg_codex.toml,sha256=O3UVY0_G_UNz5IpFZu_dOhqDRIcrgoJHapUzMSLwGgc,1873
14
+ workgraph/graph.py,sha256=sm6oZAHhNyCQ77Xb3FnjIaUEYpH16mReTjqHnNK1Ya0,14332
15
+ workgraph/harness.py,sha256=xa2_lYXWkWpzcTwrJdBzpRBmzFUtIDY09pVhnIu9JQQ,3993
16
+ workgraph/run.py,sha256=tD_8RKbKYmkl0vxaX69rN9D8ZU4Z9b286F5du5G3zkk,36316
17
+ workgraph/show.py,sha256=PJP7CnjUjSSFySztZQej7XMGOtlo6fGlXXkIsZpYC7w,29516
18
+ workgraph/workflow.py,sha256=1kvBqkjwN8_O-byaf68IQ35gRYHTVVUxs-uI_AJXz6A,11575
19
+ workgraph-0.3.3.dist-info/licenses/LICENSE,sha256=Giuyc4C51f_S6HrS6dHoqObZR76kzgYkqYbmNe7_wGg,1071
20
+ workgraph-0.3.3.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
21
+ workgraph-0.3.3.dist-info/entry_points.txt,sha256=Tjt1r5Q4yUb4AkbmXj8A-wNQAT6WcxtcXEiFu9lofc4,50
22
+ workgraph-0.3.3.dist-info/METADATA,sha256=rmzHmAzAfG50TXjG1i0EQ_850qp-NDIVZwhtV4W5uqI,4030
23
+ workgraph-0.3.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ workgraph = workgraph.cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maxime Schmitt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.