stellars-claude-code-plugins 0.8.44__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.
- stellars_claude_code_plugins/__init__.py +3 -0
- stellars_claude_code_plugins/engine/__init__.py +35 -0
- stellars_claude_code_plugins/engine/fsm.py +258 -0
- stellars_claude_code_plugins/engine/model.py +376 -0
- stellars_claude_code_plugins/engine/orchestrator.py +2444 -0
- stellars_claude_code_plugins-0.8.44.dist-info/METADATA +126 -0
- stellars_claude_code_plugins-0.8.44.dist-info/RECORD +11 -0
- stellars_claude_code_plugins-0.8.44.dist-info/WHEEL +5 -0
- stellars_claude_code_plugins-0.8.44.dist-info/entry_points.txt +2 -0
- stellars_claude_code_plugins-0.8.44.dist-info/licenses/LICENSE +21 -0
- stellars_claude_code_plugins-0.8.44.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Orchestration engine for Claude Code plugins.
|
|
2
|
+
|
|
3
|
+
YAML-driven phase lifecycle management with FSM transitions,
|
|
4
|
+
gate validation, and multi-agent orchestration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from stellars_claude_code_plugins.engine.fsm import (
|
|
8
|
+
Event,
|
|
9
|
+
FSM,
|
|
10
|
+
FSMConfig,
|
|
11
|
+
State,
|
|
12
|
+
Transition,
|
|
13
|
+
build_phase_lifecycle_fsm,
|
|
14
|
+
resolve_phase_key,
|
|
15
|
+
)
|
|
16
|
+
from stellars_claude_code_plugins.engine.model import (
|
|
17
|
+
Model,
|
|
18
|
+
load_model,
|
|
19
|
+
validate_model,
|
|
20
|
+
)
|
|
21
|
+
from stellars_claude_code_plugins.engine.orchestrator import main
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Event",
|
|
25
|
+
"FSM",
|
|
26
|
+
"FSMConfig",
|
|
27
|
+
"Model",
|
|
28
|
+
"State",
|
|
29
|
+
"Transition",
|
|
30
|
+
"build_phase_lifecycle_fsm",
|
|
31
|
+
"load_model",
|
|
32
|
+
"main",
|
|
33
|
+
"resolve_phase_key",
|
|
34
|
+
"validate_model",
|
|
35
|
+
]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Finite State Machine engine for phase lifecycle management.
|
|
2
|
+
|
|
3
|
+
Drives phase transitions declaratively from YAML configuration instead
|
|
4
|
+
of imperative if/else branches in the orchestrator. Each phase goes
|
|
5
|
+
through states (pending -> readback -> in_progress -> gatekeeper ->
|
|
6
|
+
complete) with transitions triggered by events (start, end, reject, skip).
|
|
7
|
+
|
|
8
|
+
Guards are conditions checked before a transition fires.
|
|
9
|
+
Actions are side effects triggered after a transition completes.
|
|
10
|
+
Both are referenced by name in YAML and resolved to callables at runtime.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class State(str, Enum):
|
|
21
|
+
"""Phase lifecycle states."""
|
|
22
|
+
PENDING = "pending"
|
|
23
|
+
READBACK = "readback"
|
|
24
|
+
IN_PROGRESS = "in_progress"
|
|
25
|
+
GATEKEEPER = "gatekeeper"
|
|
26
|
+
COMPLETE = "complete"
|
|
27
|
+
SKIPPED = "skipped"
|
|
28
|
+
REJECTED = "rejected"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Event(str, Enum):
|
|
32
|
+
"""Events that trigger state transitions."""
|
|
33
|
+
START = "start"
|
|
34
|
+
READBACK_PASS = "readback_pass"
|
|
35
|
+
READBACK_FAIL = "readback_fail"
|
|
36
|
+
END = "end"
|
|
37
|
+
GATE_PASS = "gate_pass"
|
|
38
|
+
GATE_FAIL = "gate_fail"
|
|
39
|
+
REJECT = "reject"
|
|
40
|
+
SKIP = "skip"
|
|
41
|
+
ADVANCE = "advance"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Transition:
|
|
46
|
+
"""A single state transition rule."""
|
|
47
|
+
from_state: str
|
|
48
|
+
event: str
|
|
49
|
+
to_state: str
|
|
50
|
+
guard: str = ""
|
|
51
|
+
action: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class FSMConfig:
|
|
56
|
+
"""FSM configuration loaded from YAML."""
|
|
57
|
+
transitions: list[Transition] = field(default_factory=list)
|
|
58
|
+
guards: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
actions: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FSM:
|
|
63
|
+
"""Finite state machine for phase lifecycle.
|
|
64
|
+
|
|
65
|
+
Loads transitions from configuration, validates them, and executes
|
|
66
|
+
state changes. Guards are checked before transitions fire. Actions
|
|
67
|
+
run after transitions complete. Every transition is logged.
|
|
68
|
+
|
|
69
|
+
The FSM does NOT own output formatting or user-facing display.
|
|
70
|
+
It owns state transitions only. The orchestrator calls fire() and
|
|
71
|
+
handles display around it.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, config: FSMConfig):
|
|
75
|
+
self._transitions = config.transitions
|
|
76
|
+
self._guards: dict[str, Callable] = {}
|
|
77
|
+
self._actions: dict[str, Callable] = {}
|
|
78
|
+
self._log: list[dict] = []
|
|
79
|
+
self._current_state = State.PENDING
|
|
80
|
+
|
|
81
|
+
# Build transition lookup: (from_state, event) -> list[Transition]
|
|
82
|
+
self._lookup: dict[tuple[str, str], list[Transition]] = {}
|
|
83
|
+
for t in self._transitions:
|
|
84
|
+
key = (t.from_state, t.event)
|
|
85
|
+
self._lookup.setdefault(key, []).append(t)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def current_state(self) -> State:
|
|
89
|
+
return self._current_state
|
|
90
|
+
|
|
91
|
+
@current_state.setter
|
|
92
|
+
def current_state(self, state: State) -> None:
|
|
93
|
+
self._current_state = state
|
|
94
|
+
|
|
95
|
+
def register_guard(self, name: str, fn: Callable[..., bool]) -> None:
|
|
96
|
+
"""Register a named guard function."""
|
|
97
|
+
self._guards[name] = fn
|
|
98
|
+
|
|
99
|
+
def register_action(self, name: str, fn: Callable[..., Any]) -> None:
|
|
100
|
+
"""Register a named action function."""
|
|
101
|
+
self._actions[name] = fn
|
|
102
|
+
|
|
103
|
+
def can_fire(self, event: str | Event, **context) -> bool:
|
|
104
|
+
"""Check if an event can fire in the current state."""
|
|
105
|
+
event_str = event.value if isinstance(event, Event) else event
|
|
106
|
+
key = (self._current_state.value, event_str)
|
|
107
|
+
transitions = self._lookup.get(key, [])
|
|
108
|
+
for t in transitions:
|
|
109
|
+
if not t.guard or self._evaluate_guard(t.guard, context):
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def fire(self, event: str | Event, **context) -> State:
|
|
114
|
+
"""Fire an event, executing the matching transition.
|
|
115
|
+
|
|
116
|
+
Checks guards, updates state, runs actions, logs the transition.
|
|
117
|
+
Raises ValueError if no valid transition exists.
|
|
118
|
+
"""
|
|
119
|
+
event_str = event.value if isinstance(event, Event) else event
|
|
120
|
+
key = (self._current_state.value, event_str)
|
|
121
|
+
transitions = self._lookup.get(key, [])
|
|
122
|
+
|
|
123
|
+
if not transitions:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"No transition from state '{self._current_state.value}' "
|
|
126
|
+
f"on event '{event_str}'. Valid events: "
|
|
127
|
+
f"{[e for (s, e) in self._lookup if s == self._current_state.value]}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Find first transition whose guard passes
|
|
131
|
+
for t in transitions:
|
|
132
|
+
if t.guard and not self._evaluate_guard(t.guard, context):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
old_state = self._current_state
|
|
136
|
+
self._current_state = State(t.to_state)
|
|
137
|
+
|
|
138
|
+
# Log transition
|
|
139
|
+
self._log.append({
|
|
140
|
+
"from": old_state.value,
|
|
141
|
+
"event": event_str,
|
|
142
|
+
"to": self._current_state.value,
|
|
143
|
+
"guard": t.guard,
|
|
144
|
+
"action": t.action,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
# Run action if defined
|
|
148
|
+
if t.action:
|
|
149
|
+
self._execute_action(t.action, context)
|
|
150
|
+
|
|
151
|
+
return self._current_state
|
|
152
|
+
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"All guards failed for transition from '{self._current_state.value}' "
|
|
155
|
+
f"on event '{event_str}'."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def reset(self, state: State = State.PENDING) -> None:
|
|
159
|
+
"""Reset FSM to a given state."""
|
|
160
|
+
self._current_state = state
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def log(self) -> list[dict]:
|
|
164
|
+
"""Return the transition log."""
|
|
165
|
+
return self._log
|
|
166
|
+
|
|
167
|
+
def simulate(self, workflow_phases: list[str], **context) -> list[dict]:
|
|
168
|
+
"""Simulate a full workflow run without executing actions.
|
|
169
|
+
|
|
170
|
+
Walks through all phases, firing events in sequence to verify
|
|
171
|
+
the transition graph is complete. Returns a list of phase
|
|
172
|
+
reports for dry-run output.
|
|
173
|
+
"""
|
|
174
|
+
reports = []
|
|
175
|
+
saved_state = self._current_state
|
|
176
|
+
saved_actions = dict(self._actions)
|
|
177
|
+
# Disable actions during simulation
|
|
178
|
+
self._actions = {k: lambda **ctx: None for k in saved_actions}
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
for phase in workflow_phases:
|
|
182
|
+
report = {"phase": phase, "transitions": [], "valid": True}
|
|
183
|
+
self.reset(State.PENDING)
|
|
184
|
+
|
|
185
|
+
# Simulate: pending -> start -> readback_pass -> end -> gate_pass -> advance
|
|
186
|
+
for event in [Event.START, Event.READBACK_PASS, Event.END,
|
|
187
|
+
Event.GATE_PASS, Event.ADVANCE]:
|
|
188
|
+
try:
|
|
189
|
+
old = self._current_state.value
|
|
190
|
+
self.fire(event, phase=phase, **context)
|
|
191
|
+
report["transitions"].append({
|
|
192
|
+
"event": event.value,
|
|
193
|
+
"from": old,
|
|
194
|
+
"to": self._current_state.value,
|
|
195
|
+
})
|
|
196
|
+
except ValueError as e:
|
|
197
|
+
report["valid"] = False
|
|
198
|
+
report["error"] = str(e)
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
reports.append(report)
|
|
202
|
+
finally:
|
|
203
|
+
self._current_state = saved_state
|
|
204
|
+
self._actions = saved_actions
|
|
205
|
+
|
|
206
|
+
return reports
|
|
207
|
+
|
|
208
|
+
def _evaluate_guard(self, guard_name: str, context: dict) -> bool:
|
|
209
|
+
"""Evaluate a named guard with the given context."""
|
|
210
|
+
guard_fn = self._guards.get(guard_name)
|
|
211
|
+
if guard_fn is None:
|
|
212
|
+
raise ValueError(f"Unknown guard: '{guard_name}'. "
|
|
213
|
+
f"Registered: {list(self._guards.keys())}")
|
|
214
|
+
return guard_fn(**context)
|
|
215
|
+
|
|
216
|
+
def _execute_action(self, action_name: str, context: dict) -> None:
|
|
217
|
+
"""Execute a named action with the given context."""
|
|
218
|
+
action_fn = self._actions.get(action_name)
|
|
219
|
+
if action_fn is None:
|
|
220
|
+
raise ValueError(f"Unknown action: '{action_name}'. "
|
|
221
|
+
f"Registered: {list(self._actions.keys())}")
|
|
222
|
+
action_fn(**context)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def build_phase_lifecycle_fsm() -> FSM:
|
|
226
|
+
"""Build the standard phase lifecycle FSM with universal transitions.
|
|
227
|
+
|
|
228
|
+
Every phase follows the same lifecycle:
|
|
229
|
+
pending -> readback -> in_progress -> gatekeeper -> complete
|
|
230
|
+
With branches for: readback_fail (retry), gate_fail (retry),
|
|
231
|
+
reject (back to implementation), skip (advance without executing).
|
|
232
|
+
"""
|
|
233
|
+
transitions = [
|
|
234
|
+
Transition("pending", "start", "readback"),
|
|
235
|
+
Transition("readback", "readback_pass", "in_progress"),
|
|
236
|
+
Transition("readback", "readback_fail", "pending"),
|
|
237
|
+
Transition("in_progress", "end", "gatekeeper"),
|
|
238
|
+
Transition("gatekeeper", "gate_pass", "complete"),
|
|
239
|
+
Transition("gatekeeper", "gate_fail", "in_progress"),
|
|
240
|
+
Transition("pending", "skip", "skipped"),
|
|
241
|
+
Transition("in_progress", "reject", "rejected"),
|
|
242
|
+
Transition("rejected", "advance", "pending"),
|
|
243
|
+
Transition("complete", "advance", "pending"),
|
|
244
|
+
Transition("skipped", "advance", "pending"),
|
|
245
|
+
]
|
|
246
|
+
return FSM(FSMConfig(transitions=transitions))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolve_phase_key(workflow_type: str, phase_name: str, registry: dict) -> str:
|
|
250
|
+
"""Resolve a namespaced phase key with fallback to bare name.
|
|
251
|
+
|
|
252
|
+
Tries WORKFLOW::PHASE first (e.g., FULL::RESEARCH), then falls back
|
|
253
|
+
to bare PHASE (e.g., RESEARCH) for shared phases like RECORD/NEXT.
|
|
254
|
+
"""
|
|
255
|
+
namespaced = f"{workflow_type.upper()}::{phase_name}"
|
|
256
|
+
if namespaced in registry:
|
|
257
|
+
return namespaced
|
|
258
|
+
return phase_name
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Typed object model for auto-build-claw. Loads from 4 YAML resource files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Agent:
|
|
14
|
+
name: str
|
|
15
|
+
number: int
|
|
16
|
+
display_name: str
|
|
17
|
+
prompt: str
|
|
18
|
+
mode: str = "" # "" = parallel via Agent tool; "standalone_session" = claude -p
|
|
19
|
+
checklist: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Gate:
|
|
24
|
+
mode: str
|
|
25
|
+
description: str
|
|
26
|
+
prompt: str # template with {variable} placeholders
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Phase:
|
|
31
|
+
start: str = ""
|
|
32
|
+
end: str = ""
|
|
33
|
+
start_continue: str = "" # NEXT non-final variant
|
|
34
|
+
start_final: str = "" # NEXT final variant
|
|
35
|
+
end_continue: str = ""
|
|
36
|
+
end_final: str = ""
|
|
37
|
+
reject_to: Optional[dict] = None # {phase: str, condition: str} - backward transition target
|
|
38
|
+
auto_actions: Optional[dict] = None # {on_complete: [action_name, ...]} - actions after phase completes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class WorkflowType:
|
|
43
|
+
description: str
|
|
44
|
+
phases: list[dict] # raw list [{name: X, skippable?: bool}]
|
|
45
|
+
depends_on: str = "" # prerequisite workflow (auto-chains before this one)
|
|
46
|
+
dependency: bool = False # if True, cannot be invoked directly via --type
|
|
47
|
+
required: list[str] = field(default_factory=list)
|
|
48
|
+
skippable: list[str] = field(default_factory=list)
|
|
49
|
+
phase_names: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
for p in self.phases:
|
|
53
|
+
name = p["name"]
|
|
54
|
+
self.phase_names.append(name)
|
|
55
|
+
(self.skippable if p.get("skippable") else self.required).append(name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class DisplayConfig:
|
|
60
|
+
separator: str
|
|
61
|
+
separator_width: int
|
|
62
|
+
header_char: str
|
|
63
|
+
header_width: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class BannerConfig:
|
|
68
|
+
header: str
|
|
69
|
+
progress_current: str
|
|
70
|
+
progress_done: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class FooterConfig:
|
|
75
|
+
start: str
|
|
76
|
+
end: str
|
|
77
|
+
final: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class CliConfig:
|
|
82
|
+
description: str
|
|
83
|
+
epilog: str
|
|
84
|
+
commands: dict[str, str]
|
|
85
|
+
args: dict[str, str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class AppConfig:
|
|
90
|
+
name: str
|
|
91
|
+
description: str
|
|
92
|
+
cmd: str
|
|
93
|
+
artifacts_dir: str
|
|
94
|
+
display: DisplayConfig
|
|
95
|
+
banner: BannerConfig
|
|
96
|
+
footer: FooterConfig
|
|
97
|
+
messages: dict[str, str]
|
|
98
|
+
cli: CliConfig
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Model:
|
|
103
|
+
workflow_types: dict[str, WorkflowType]
|
|
104
|
+
phases: dict[str, Phase]
|
|
105
|
+
agents: dict[str, list[Agent]] # phase key -> agents (FULL::RESEARCH, FULL::HYPOTHESIS, etc.)
|
|
106
|
+
gates: dict[str, Gate] # readback, gatekeeper, gatekeeper_skip, gatekeeper_force_skip
|
|
107
|
+
app: AppConfig
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _load_yaml(path: Path) -> dict:
|
|
113
|
+
if not path.exists():
|
|
114
|
+
raise FileNotFoundError(f"Required resource file not found: {path}")
|
|
115
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
116
|
+
try:
|
|
117
|
+
return yaml.safe_load(fh) or {}
|
|
118
|
+
except yaml.YAMLError as exc:
|
|
119
|
+
raise ValueError(f"Malformed YAML in {path}: {exc}") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Builder functions ─────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _build_workflow_types(raw: dict) -> dict[str, WorkflowType]:
|
|
125
|
+
return {
|
|
126
|
+
key: WorkflowType(
|
|
127
|
+
description=val.get("description", ""),
|
|
128
|
+
phases=val.get("phases", []),
|
|
129
|
+
depends_on=val.get("depends_on", ""),
|
|
130
|
+
dependency=val.get("dependency", False),
|
|
131
|
+
)
|
|
132
|
+
for key, val in raw.items()
|
|
133
|
+
if isinstance(val, dict)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _build_phases(raw: dict) -> dict[str, Phase]:
|
|
138
|
+
return {
|
|
139
|
+
key: Phase(**{k: val[k] for k in Phase.__dataclass_fields__ if k in val})
|
|
140
|
+
for key, val in raw.items()
|
|
141
|
+
if isinstance(val, dict)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _build_agents_and_gates(raw: dict) -> tuple[dict[str, list[Agent]], dict[str, Gate]]:
|
|
146
|
+
agents: dict[str, list[Agent]] = {}
|
|
147
|
+
gates: dict[str, Gate] = {}
|
|
148
|
+
for phase_key, section in raw.items():
|
|
149
|
+
if not isinstance(section, dict):
|
|
150
|
+
continue
|
|
151
|
+
if phase_key == "shared_gates":
|
|
152
|
+
for gk, gv in section.items():
|
|
153
|
+
gates[gk] = Gate(mode=gv.get("mode", "standalone_session"),
|
|
154
|
+
description=gv.get("description", ""),
|
|
155
|
+
prompt=gv.get("prompt", ""))
|
|
156
|
+
else:
|
|
157
|
+
agent_list = section.get("agents", [])
|
|
158
|
+
if agent_list:
|
|
159
|
+
agents[phase_key] = [
|
|
160
|
+
Agent(name=a["name"], number=a["number"], display_name=a["display_name"],
|
|
161
|
+
prompt=a.get("prompt", ""), mode=a.get("mode", ""),
|
|
162
|
+
checklist=a.get("checklist"))
|
|
163
|
+
for a in agent_list
|
|
164
|
+
]
|
|
165
|
+
for gate_type, gate_def in section.get("gates", {}).items():
|
|
166
|
+
if isinstance(gate_def, dict):
|
|
167
|
+
gates[f"{phase_key}::{gate_type}"] = Gate(
|
|
168
|
+
mode=gate_def.get("mode", "standalone_session"),
|
|
169
|
+
description=gate_def.get("description", ""),
|
|
170
|
+
prompt=gate_def.get("prompt", ""),
|
|
171
|
+
)
|
|
172
|
+
return agents, gates
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_app(raw: dict) -> AppConfig:
|
|
176
|
+
app, dis, ban, ftr, cli = (raw.get(k, {}) for k in ("app", "display", "banner", "footer", "cli"))
|
|
177
|
+
return AppConfig(
|
|
178
|
+
name=app.get("name", ""), description=app.get("description", ""), cmd=app.get("cmd", ""),
|
|
179
|
+
artifacts_dir=app.get("artifacts_dir", ".auto-build-claw"),
|
|
180
|
+
display=DisplayConfig(separator=dis.get("separator", "─"), separator_width=dis.get("separator_width", 70),
|
|
181
|
+
header_char=dis.get("header_char", "="), header_width=dis.get("header_width", 70)),
|
|
182
|
+
banner=BannerConfig(header=ban.get("header", ""), progress_current=ban.get("progress_current", "**{p}**"),
|
|
183
|
+
progress_done=ban.get("progress_done", "~~{p}~~")),
|
|
184
|
+
footer=FooterConfig(start=ftr.get("start", ""), end=ftr.get("end", ""), final=ftr.get("final", "")),
|
|
185
|
+
messages=raw.get("messages", {}),
|
|
186
|
+
cli=CliConfig(description=cli.get("description", ""), epilog=cli.get("epilog", ""),
|
|
187
|
+
commands=cli.get("commands", {}), args=cli.get("args", {})),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
def load_model(resources_dir: str | Path) -> Model:
|
|
194
|
+
"""Load all YAML resource files and return a Model object."""
|
|
195
|
+
base = Path(resources_dir)
|
|
196
|
+
wf_raw = _load_yaml(base / "workflow.yaml")
|
|
197
|
+
ph_raw = _load_yaml(base / "phases.yaml")
|
|
198
|
+
ag_raw = _load_yaml(base / "agents.yaml")
|
|
199
|
+
app_raw = _load_yaml(base / "app.yaml")
|
|
200
|
+
agents, gates = _build_agents_and_gates(ag_raw)
|
|
201
|
+
|
|
202
|
+
return Model(workflow_types=_build_workflow_types(wf_raw), phases=_build_phases(ph_raw),
|
|
203
|
+
agents=agents, gates=gates, app=_build_app(app_raw))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_VALID_MODES = {"", "standalone_session"}
|
|
207
|
+
_KNOWN_VARS = {
|
|
208
|
+
"objective", "iteration", "iteration_purpose", "workflow_type", "prior_context", "plan_context", "prior_hyp", "CMD", "cmd",
|
|
209
|
+
"checklist", "benchmark_info", "remaining", "total", "agents_instructions",
|
|
210
|
+
"spawn_instruction", "spawn_instruction_plan", "phase", "nxt", "iter_label", "itype",
|
|
211
|
+
"action", "phase_idx", "reject_info", "progress", "separator_line", "header_line",
|
|
212
|
+
"description", "understanding", "exit_criteria", "required_agents", "recorded_agents",
|
|
213
|
+
"output_status", "readback_status", "benchmark_configured", "evidence", "reason", "completed_phases", "p",
|
|
214
|
+
}
|
|
215
|
+
_GATE_REQUIRED_VARS: dict[str, list[str]] = {
|
|
216
|
+
"readback": ["understanding"],
|
|
217
|
+
"gatekeeper": ["evidence"],
|
|
218
|
+
"gatekeeper_skip": ["phase", "iteration", "itype", "objective", "reason"],
|
|
219
|
+
"gatekeeper_force_skip": ["phase", "iteration", "reason"],
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _resolve_key(workflow: str, phase: str, registry: set) -> str:
|
|
224
|
+
"""Resolve a namespaced key with fallback chain.
|
|
225
|
+
|
|
226
|
+
Resolution order: WORKFLOW::PHASE -> bare PHASE -> FULL::PHASE.
|
|
227
|
+
The FULL:: fallback ensures gc/hotfix workflows can reuse full's
|
|
228
|
+
phase templates without duplicating them.
|
|
229
|
+
"""
|
|
230
|
+
namespaced = f"{workflow.upper()}::{phase}"
|
|
231
|
+
if namespaced in registry:
|
|
232
|
+
return namespaced
|
|
233
|
+
if phase in registry:
|
|
234
|
+
return phase
|
|
235
|
+
full_fallback = f"FULL::{phase}"
|
|
236
|
+
if full_fallback in registry:
|
|
237
|
+
return full_fallback
|
|
238
|
+
return phase # return bare (will fail validation if truly missing)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def validate_model(model: Model) -> list[str]:
|
|
242
|
+
"""Return list of issues found in the model. Empty list = valid."""
|
|
243
|
+
issues: list[str] = []
|
|
244
|
+
known_phases = set(model.phases.keys())
|
|
245
|
+
known_agents = set(model.agents.keys())
|
|
246
|
+
|
|
247
|
+
# workflow_types: required fields and phase names resolve (namespaced or bare)
|
|
248
|
+
for wf_name, wf in model.workflow_types.items():
|
|
249
|
+
if not wf.description:
|
|
250
|
+
issues.append(f"[workflow.yaml] '{wf_name}': missing 'description'. "
|
|
251
|
+
"Fix: add 'description: ...' to the workflow type.")
|
|
252
|
+
for p in wf.phases:
|
|
253
|
+
if not isinstance(p, dict) or "name" not in p:
|
|
254
|
+
issues.append(f"[workflow.yaml] '{wf_name}.phases': entry missing 'name'. "
|
|
255
|
+
"Fix: add 'name: PHASE_NAME' to every phase entry.")
|
|
256
|
+
else:
|
|
257
|
+
resolved = _resolve_key(wf_name, p["name"], known_phases)
|
|
258
|
+
if resolved not in known_phases:
|
|
259
|
+
issues.append(f"[workflow.yaml] '{wf_name}': phase '{p['name']}' not found "
|
|
260
|
+
f"as '{wf_name.upper()}::{p['name']}' or '{p['name']}' in phases.yaml. "
|
|
261
|
+
"Fix: add the phase to phases.yaml or correct the name.")
|
|
262
|
+
|
|
263
|
+
# agents: keys must be namespaced (WORKFLOW::PHASE) or match a known phase
|
|
264
|
+
for phase_key in model.agents:
|
|
265
|
+
# Accept any key with :: (namespaced) or bare keys matching phases
|
|
266
|
+
if "::" not in phase_key and phase_key not in known_phases:
|
|
267
|
+
issues.append(f"[agents.yaml] section '{phase_key}' has no matching phase in phases.yaml. "
|
|
268
|
+
"Fix: rename to match a phases.yaml key or use WORKFLOW::PHASE notation.")
|
|
269
|
+
|
|
270
|
+
# agents: mode values, sequential numbers, unique names, required fields
|
|
271
|
+
for phase_key, agent_list in model.agents.items():
|
|
272
|
+
numbers = [a.number for a in agent_list]
|
|
273
|
+
if numbers != list(range(1, len(agent_list) + 1)):
|
|
274
|
+
issues.append(f"[agents.yaml] '{phase_key}': agent numbers {numbers} not sequential. "
|
|
275
|
+
"Fix: number agents 1, 2, 3... in order.")
|
|
276
|
+
seen_names: set[str] = set()
|
|
277
|
+
for agent in agent_list:
|
|
278
|
+
if agent.mode not in _VALID_MODES:
|
|
279
|
+
issues.append(f"[agents.yaml] '{phase_key}.{agent.name}': invalid mode '{agent.mode}'. "
|
|
280
|
+
"Fix: use '' or 'standalone_session'.")
|
|
281
|
+
if agent.name in seen_names:
|
|
282
|
+
issues.append(f"[agents.yaml] '{phase_key}': duplicate agent name '{agent.name}'. "
|
|
283
|
+
"Fix: use unique names within each phase.")
|
|
284
|
+
seen_names.add(agent.name)
|
|
285
|
+
for f in ("name", "display_name", "prompt"):
|
|
286
|
+
if not getattr(agent, f):
|
|
287
|
+
issues.append(f"[agents.yaml] '{phase_key}.{agent.name}': missing '{f}'. "
|
|
288
|
+
f"Fix: add '{f}: ...' to the agent entry.")
|
|
289
|
+
if agent.checklist is not None:
|
|
290
|
+
if "VERDICTS:" not in agent.checklist:
|
|
291
|
+
issues.append(f"[agents.yaml] '{phase_key}.{agent.name}.checklist': missing 'VERDICTS:'. "
|
|
292
|
+
"Fix: add 'VERDICTS: CLEAN / WARN / BLOCK / ASK'.")
|
|
293
|
+
if len(re.findall(r"^\s*\d+\.", agent.checklist, re.MULTILINE)) < 4:
|
|
294
|
+
issues.append(f"[agents.yaml] '{phase_key}.{agent.name}.checklist': fewer than 4 numbered items. "
|
|
295
|
+
"Fix: checklist must have exactly 4 numbered items.")
|
|
296
|
+
|
|
297
|
+
# phases: template variables are from known set
|
|
298
|
+
for phase_name, phase in model.phases.items():
|
|
299
|
+
for attr in Phase.__dataclass_fields__:
|
|
300
|
+
text = getattr(phase, attr)
|
|
301
|
+
if text and isinstance(text, str):
|
|
302
|
+
for var in re.findall(r"\{(\w+)\}", text):
|
|
303
|
+
if var not in _KNOWN_VARS:
|
|
304
|
+
issues.append(f"[phases.yaml] '{phase_name}.{attr}': unknown variable '{{{var}}}'. "
|
|
305
|
+
"Fix: add it to _KNOWN_VARS or correct the placeholder.")
|
|
306
|
+
|
|
307
|
+
# phases: reject_to targets must reference valid phases in some workflow
|
|
308
|
+
for phase_name, phase in model.phases.items():
|
|
309
|
+
if phase.reject_to:
|
|
310
|
+
target = phase.reject_to.get("phase", "")
|
|
311
|
+
if target and target not in known_phases:
|
|
312
|
+
# Check if any namespaced version exists
|
|
313
|
+
found = any(k.endswith(f"::{target}") for k in known_phases)
|
|
314
|
+
if not found:
|
|
315
|
+
issues.append(f"[phases.yaml] '{phase_name}.reject_to': target phase '{target}' not found. "
|
|
316
|
+
"Fix: use a valid phase name from phases.yaml.")
|
|
317
|
+
|
|
318
|
+
# phases: auto_action names should be documented (warn if unknown)
|
|
319
|
+
_KNOWN_AUTO_ACTIONS = {
|
|
320
|
+
"hypothesis_autowrite", "hypothesis_gc",
|
|
321
|
+
"plan_save", "iteration_summary", "iteration_advance",
|
|
322
|
+
}
|
|
323
|
+
for phase_name, phase in model.phases.items():
|
|
324
|
+
if phase.auto_actions:
|
|
325
|
+
for action in phase.auto_actions.get("on_complete", []):
|
|
326
|
+
if action not in _KNOWN_AUTO_ACTIONS:
|
|
327
|
+
issues.append(f"[phases.yaml] '{phase_name}.auto_actions': unknown action '{action}'. "
|
|
328
|
+
f"Known: {', '.join(sorted(_KNOWN_AUTO_ACTIONS))}.")
|
|
329
|
+
|
|
330
|
+
# planning workflow: verify PLANNING::PLAN resolves distinctly (not silently to FULL::PLAN)
|
|
331
|
+
planning_wf = model.workflow_types.get("planning")
|
|
332
|
+
if planning_wf:
|
|
333
|
+
for p in planning_wf.phases:
|
|
334
|
+
if isinstance(p, dict) and p.get("name") == "PLAN":
|
|
335
|
+
resolved = _resolve_key("planning", "PLAN", known_phases)
|
|
336
|
+
if resolved != "PLANNING::PLAN":
|
|
337
|
+
issues.append(
|
|
338
|
+
f"[phases.yaml] planning workflow PLAN phase resolves to '{resolved}' "
|
|
339
|
+
"instead of 'PLANNING::PLAN'. Fix: add a PLANNING::PLAN entry to phases.yaml."
|
|
340
|
+
)
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
# gates: required placeholders present in prompt (namespaced keys)
|
|
344
|
+
for gate_key, gate in model.gates.items():
|
|
345
|
+
# Extract gate type from namespaced key: "FULL::RESEARCH::readback" -> "readback"
|
|
346
|
+
gate_type = gate_key.rsplit("::", 1)[-1] if "::" in gate_key else gate_key
|
|
347
|
+
for var in _GATE_REQUIRED_VARS.get(gate_type, []):
|
|
348
|
+
if f"{{{var}}}" not in gate.prompt:
|
|
349
|
+
issues.append(f"[agents.yaml] '{gate_key}': missing placeholder '{{{var}}}'. "
|
|
350
|
+
"Fix: add it to the gate prompt template.")
|
|
351
|
+
|
|
352
|
+
# gates: every workflow phase must resolve to both readback and gatekeeper
|
|
353
|
+
gate_phase_keys = set()
|
|
354
|
+
for gk in model.gates:
|
|
355
|
+
parts = gk.rsplit("::", 1)
|
|
356
|
+
if len(parts) == 2 and parts[1] in ("readback", "gatekeeper"):
|
|
357
|
+
gate_phase_keys.add(parts[0])
|
|
358
|
+
for wf_name, wf in model.workflow_types.items():
|
|
359
|
+
for p in wf.phases:
|
|
360
|
+
if not isinstance(p, dict) or "name" not in p:
|
|
361
|
+
continue
|
|
362
|
+
resolved = _resolve_key(wf_name, p["name"], gate_phase_keys)
|
|
363
|
+
for gate_type in ("readback", "gatekeeper"):
|
|
364
|
+
gate_key = f"{resolved}::{gate_type}"
|
|
365
|
+
if gate_key not in model.gates:
|
|
366
|
+
issues.append(f"[agents.yaml] workflow '{wf_name}' phase '{p['name']}': "
|
|
367
|
+
f"no {gate_type} gate found (tried '{wf_name.upper()}::{p['name']}::{gate_type}', "
|
|
368
|
+
f"'{p['name']}::{gate_type}', 'FULL::{p['name']}::{gate_type}'). "
|
|
369
|
+
f"Fix: add gates.{gate_type} to the phase section in agents.yaml.")
|
|
370
|
+
|
|
371
|
+
# app: required fields
|
|
372
|
+
for field_name, val in (("name", model.app.name), ("cmd", model.app.cmd)):
|
|
373
|
+
if not val:
|
|
374
|
+
issues.append(f"[app.yaml] 'app.{field_name}': missing. Fix: add '{field_name}: ...' under app:.")
|
|
375
|
+
|
|
376
|
+
return issues
|