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.
@@ -0,0 +1,3 @@
1
+ """Stellars Claude Code Plugins - shared orchestration engine."""
2
+
3
+ __version__ = "0.8.43"
@@ -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