snodo-foundation 0.7.2__tar.gz

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.
Files changed (42) hide show
  1. snodo_foundation-0.7.2/PKG-INFO +16 -0
  2. snodo_foundation-0.7.2/pyproject.toml +32 -0
  3. snodo_foundation-0.7.2/setup.cfg +4 -0
  4. snodo_foundation-0.7.2/src/snodo/compiler/__init__.py +0 -0
  5. snodo_foundation-0.7.2/src/snodo/compiler/models.py +610 -0
  6. snodo_foundation-0.7.2/src/snodo/compiler/verifier.py +557 -0
  7. snodo_foundation-0.7.2/src/snodo/infrastructure/__init__.py +0 -0
  8. snodo_foundation-0.7.2/src/snodo/infrastructure/audit.py +612 -0
  9. snodo_foundation-0.7.2/src/snodo/infrastructure/cloud_sync.py +490 -0
  10. snodo_foundation-0.7.2/src/snodo/infrastructure/config.py +184 -0
  11. snodo_foundation-0.7.2/src/snodo/infrastructure/decisions.py +392 -0
  12. snodo_foundation-0.7.2/src/snodo/infrastructure/environment.py +176 -0
  13. snodo_foundation-0.7.2/src/snodo/infrastructure/jwks.py +73 -0
  14. snodo_foundation-0.7.2/src/snodo/infrastructure/memory.py +319 -0
  15. snodo_foundation-0.7.2/src/snodo/infrastructure/model_catalog.py +196 -0
  16. snodo_foundation-0.7.2/src/snodo/infrastructure/model_discovery.py +426 -0
  17. snodo_foundation-0.7.2/src/snodo/infrastructure/model_resolver.py +64 -0
  18. snodo_foundation-0.7.2/src/snodo/infrastructure/oauth_verifier.py +32 -0
  19. snodo_foundation-0.7.2/src/snodo/infrastructure/patch_coverage.py +273 -0
  20. snodo_foundation-0.7.2/src/snodo/infrastructure/paths.py +12 -0
  21. snodo_foundation-0.7.2/src/snodo/infrastructure/session.py +693 -0
  22. snodo_foundation-0.7.2/src/snodo/infrastructure/signing_keys.py +159 -0
  23. snodo_foundation-0.7.2/src/snodo/infrastructure/state.py +154 -0
  24. snodo_foundation-0.7.2/src/snodo/infrastructure/tokens.py +444 -0
  25. snodo_foundation-0.7.2/src/snodo/infrastructure/tool_telemetry.py +210 -0
  26. snodo_foundation-0.7.2/src/snodo/infrastructure/usage_tracker.py +242 -0
  27. snodo_foundation-0.7.2/src/snodo/infrastructure/wave_registry.py +354 -0
  28. snodo_foundation-0.7.2/src/snodo/infrastructure/worktree.py +360 -0
  29. snodo_foundation-0.7.2/src/snodo/protocols/__init__.py +174 -0
  30. snodo_foundation-0.7.2/src/snodo/protocols/templates/2+n.yml +172 -0
  31. snodo_foundation-0.7.2/src/snodo/protocols/templates/__init__.py +1 -0
  32. snodo_foundation-0.7.2/src/snodo/protocols/templates/bugfix-surgeon.yml +86 -0
  33. snodo_foundation-0.7.2/src/snodo/protocols/templates/feature-warden.yml +96 -0
  34. snodo_foundation-0.7.2/src/snodo/protocols/templates/greenfield.yml +208 -0
  35. snodo_foundation-0.7.2/src/snodo/protocols/templates/intent.yml +70 -0
  36. snodo_foundation-0.7.2/src/snodo/protocols/templates/solo.yml +105 -0
  37. snodo_foundation-0.7.2/src/snodo/protocols/templates/team.yml +161 -0
  38. snodo_foundation-0.7.2/src/snodo_foundation.egg-info/PKG-INFO +16 -0
  39. snodo_foundation-0.7.2/src/snodo_foundation.egg-info/SOURCES.txt +40 -0
  40. snodo_foundation-0.7.2/src/snodo_foundation.egg-info/dependency_links.txt +1 -0
  41. snodo_foundation-0.7.2/src/snodo_foundation.egg-info/requires.txt +7 -0
  42. snodo_foundation-0.7.2/src/snodo_foundation.egg-info/top_level.txt +1 -0
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: snodo-foundation
3
+ Version: 0.7.2
4
+ Summary: Snodo foundation — compiler, protocols, and infrastructure
5
+ Author-email: The Snodo Authors <noreply@snodo.dev>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://snodo.dev
8
+ Project-URL: Repository, https://github.com/snodo-dev/snodo
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: snodo-core==0.7.2
11
+ Requires-Dist: pydantic>=2.12.0
12
+ Requires-Dist: pyyaml>=6.0
13
+ Requires-Dist: pyjwt>=2.8.0
14
+ Requires-Dist: filelock>=3.0.0
15
+ Requires-Dist: langgraph>=0.2.0
16
+ Requires-Dist: langgraph-checkpoint-sqlite>=3.0.0
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snodo-foundation"
7
+ version = "0.7.2"
8
+ description = "Snodo foundation — compiler, protocols, and infrastructure"
9
+ requires-python = ">=3.12"
10
+ license = { text = "Apache-2.0" }
11
+ authors = [{ name = "The Snodo Authors", email = "noreply@snodo.dev" }]
12
+ dependencies = [
13
+ "snodo-core==0.7.2",
14
+ "pydantic>=2.12.0",
15
+ "pyyaml>=6.0",
16
+ "pyjwt>=2.8.0",
17
+ "filelock>=3.0.0",
18
+ "langgraph>=0.2.0",
19
+ "langgraph-checkpoint-sqlite>=3.0.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://snodo.dev"
24
+ Repository = "https://github.com/snodo-dev/snodo"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+ namespaces = true
29
+
30
+ [tool.setuptools.package-data]
31
+ snodo = ["py.typed"]
32
+ "snodo.protocols.templates" = ["*.yml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,610 @@
1
+ """Protocol syntax models for the Snodo compiler.
2
+
3
+ Pydantic models representing the abstract syntax from Section 4.1 of the paper.
4
+ All models are immutable and include validation logic.
5
+ """
6
+
7
+ from enum import Enum
8
+ from typing import List, Optional, Dict, Any, Set
9
+ from pydantic import BaseModel, Field, field_validator, field_serializer, ConfigDict
10
+
11
+
12
+ class ExecutionConfig(BaseModel):
13
+ """Branch execution configuration for task isolation."""
14
+
15
+ max_retries: int = Field(default=3, ge=0, le=10)
16
+ branch_ttl_days: int = Field(default=7, ge=1, le=30)
17
+ branch_prefix: str = Field(default="task")
18
+ max_recovery_depth: int = Field(
19
+ default=3,
20
+ ge=0,
21
+ le=20,
22
+ description=(
23
+ "Maximum recovery depth per branch (default 3). Bounded against "
24
+ "non-converging loops by recovery-stall detection and max_total_fix_attempts."
25
+ ),
26
+ )
27
+ max_total_fix_attempts: int = Field(default=10, ge=1, le=100)
28
+ auto_merge: bool = Field(
29
+ default=False,
30
+ description=(
31
+ "Whether a successfully completed task's branch is merged into the "
32
+ "base branch automatically. Default off; a mode may override it."
33
+ ),
34
+ )
35
+ prepare_command: Optional[str] = Field(
36
+ default=None,
37
+ description=(
38
+ "Explicit environment preparation command executed after worktree "
39
+ "setup and before task execution/validation. None = auto-detect "
40
+ "from lockfiles."
41
+ ),
42
+ )
43
+
44
+
45
+ class DisagreementPolicy(str, Enum):
46
+ """Policy for resolving validator disagreements."""
47
+ UNANIMOUS = "unanimous" # All validators must pass
48
+ MAJORITY = "majority" # >50% must pass
49
+ QUORUM = "quorum" # Configurable threshold
50
+ ANY = "any" # At least one must pass
51
+
52
+
53
+ # Tools that confer approval/integration authority. These are the only tools
54
+ # that WF1 requires be exclusive to a single mode (see ADR 017); a protocol may
55
+ # extend this set but may not shrink it — dropping an approval-conferring tool
56
+ # from the set would silently weaken the no-self-approval guarantee.
57
+ DEFAULT_EXCLUSIVE_TOOLS = frozenset({"approve", "merge"})
58
+
59
+
60
+ class Severity(str, Enum):
61
+ """Validator result severity levels.
62
+
63
+ Ordered: PASS < WARN < BLOCKER. Explicit comparison operators
64
+ override the str-inherited lexicographic ordering.
65
+ """
66
+ PASS = "pass" # noqa: S105 - the validators' PASS severity enum value ("Pass"), not a password; tripped by the uppercase "PASS" name
67
+ WARN = "warn"
68
+ BLOCKER = "blocker"
69
+
70
+ # Intentional LSP violation: we override str's comparison operators
71
+ # to provide semantic ordering (pass < warn < blocker) rather than
72
+ # lexicographic ordering. This is required for policy evaluation.
73
+ def __lt__(self, other: "Severity") -> bool: # type: ignore[override]
74
+ _order = {"pass": 0, "warn": 1, "blocker": 2}
75
+ return _order[self.value] < _order[other.value]
76
+
77
+ def __le__(self, other: "Severity") -> bool: # type: ignore[override]
78
+ _order = {"pass": 0, "warn": 1, "blocker": 2}
79
+ return _order[self.value] <= _order[other.value]
80
+
81
+ def __gt__(self, other: "Severity") -> bool: # type: ignore[override]
82
+ _order = {"pass": 0, "warn": 1, "blocker": 2}
83
+ return _order[self.value] > _order[other.value]
84
+
85
+ def __ge__(self, other: "Severity") -> bool: # type: ignore[override]
86
+ _order = {"pass": 0, "warn": 1, "blocker": 2}
87
+ return _order[self.value] >= _order[other.value]
88
+
89
+
90
+ EVALUATION_PHASES = {"pre_execute", "post_execute", "mode_transition"}
91
+
92
+ # Fixed read-only tool names that validators may be granted.
93
+ # No write/exec/mutating tool is ever accepted here.
94
+ _READ_ONLY_TOOL_NAMES = {
95
+ "read_file",
96
+ "read_file_lines",
97
+ "list_files",
98
+ "git_show",
99
+ "git_log",
100
+ "read_diff_between_refs",
101
+ }
102
+
103
+
104
+ class Constraint(BaseModel):
105
+ """A rule or limitation on protocol execution."""
106
+
107
+ model_config = ConfigDict(frozen=True)
108
+
109
+ constraint_id: str = Field(..., description="Unique constraint identifier")
110
+ description: str = Field(..., description="Human-readable constraint description")
111
+ expression: str = Field(default="", description="Boolean expression string (legacy; summary when predicate is set)")
112
+ predicate: str = Field(default="", description="Predicate name to evaluate this constraint")
113
+ params: Dict[str, Any] = Field(default_factory=dict, description="Parameters passed to the predicate")
114
+ severity: Severity = Field(default=Severity.BLOCKER, description="Impact if violated")
115
+
116
+ @field_validator('constraint_id')
117
+ @classmethod
118
+ def validate_id(cls, v: str) -> str:
119
+ if not v or not v.strip():
120
+ raise ValueError("constraint_id cannot be empty")
121
+ if not v.replace('_', '').replace('-', '').isalnum():
122
+ raise ValueError("constraint_id must be alphanumeric with - or _")
123
+ return v
124
+
125
+
126
+ class Validator(BaseModel):
127
+ """Evaluation criteria for tasks."""
128
+
129
+ model_config = ConfigDict(frozen=True)
130
+
131
+ validator_id: str = Field(..., description="Unique validator identifier")
132
+ validator_type: str = Field(..., description="Type of validation (e.g., security, architecture)")
133
+ criteria: List[str] = Field(default_factory=list, description="Evaluation criteria")
134
+ constraints: List[Constraint] = Field(default_factory=list, description="Additional constraints")
135
+ evaluation_phase: str = Field(
136
+ default="pre_execute",
137
+ description="When to run this validator (e.g., pre_execute, post_execute)"
138
+ )
139
+ tooling: Dict[str, Any] = Field(
140
+ default_factory=dict,
141
+ description="Tooling configuration (e.g., test_command, timeout)"
142
+ )
143
+ severity_cap: Optional[Severity] = Field(
144
+ default=None,
145
+ description="Maximum severity this validator can emit. Useful for "
146
+ "validators under evaluation: blocker capped to warn "
147
+ "prevents blocking the workflow. None = no cap."
148
+ )
149
+ tools: List[str] = Field(
150
+ default_factory=list,
151
+ description="Read-only tool allowlist for this validator. "
152
+ "Empty means no tool access (single-completion path). "
153
+ f"Allowed: {sorted(_READ_ONLY_TOOL_NAMES)}"
154
+ )
155
+ judges_spec: bool = Field(
156
+ default=False,
157
+ description="Whether this validator's critique is about the spec's "
158
+ "wording (intent, constraints, scope) rather than about the "
159
+ "work. Only judges_spec validators' critique feeds the "
160
+ "spec-authoring rewriter; a non-spec objection must not "
161
+ "silently reshape the spec (Fixes #35)."
162
+ )
163
+ model: Optional[str] = Field(
164
+ default=None,
165
+ description="Optional LLM model override for this validator. "
166
+ "Falls back to coder model / default_model if not set."
167
+ )
168
+
169
+ @field_validator('validator_id')
170
+ @classmethod
171
+ def validate_id(cls, v: str) -> str:
172
+ if not v or not v.strip():
173
+ raise ValueError("validator_id cannot be empty")
174
+ return v
175
+
176
+ @field_validator('validator_type')
177
+ @classmethod
178
+ def validate_type(cls, v: str) -> str:
179
+ if not v or not v.strip():
180
+ raise ValueError("validator_type cannot be empty")
181
+ return v
182
+
183
+ @field_validator('evaluation_phase')
184
+ @classmethod
185
+ def validate_phase(cls, v: str) -> str:
186
+ if v not in EVALUATION_PHASES:
187
+ raise ValueError(
188
+ f"evaluation_phase must be one of {sorted(EVALUATION_PHASES)}, got '{v}'"
189
+ )
190
+ return v
191
+
192
+ @field_validator('tools')
193
+ @classmethod
194
+ def validate_tools(cls, v: List[str]) -> List[str]:
195
+ """Reject any tool name not in the fixed read-only set."""
196
+ for tool_name in v:
197
+ if tool_name not in _READ_ONLY_TOOL_NAMES:
198
+ raise ValueError(
199
+ f"Validator tool '{tool_name}' is not a read-only tool. "
200
+ f"Allowed tools: {sorted(_READ_ONLY_TOOL_NAMES)}. "
201
+ f"Validators may never use write/exec/mutating tools."
202
+ )
203
+ return v
204
+
205
+
206
+ class Role(BaseModel):
207
+ """Participant role in the protocol."""
208
+
209
+ model_config = ConfigDict(frozen=True)
210
+
211
+ role_id: str = Field(..., description="Unique role identifier")
212
+ name: str = Field(..., description="Human-readable role name")
213
+ permissions: List[str] = Field(default_factory=list, description="Allowed actions")
214
+ responsibilities: List[str] = Field(default_factory=list, description="Expected duties")
215
+
216
+ @field_validator('role_id')
217
+ @classmethod
218
+ def validate_id(cls, v: str) -> str:
219
+ if not v or not v.strip():
220
+ raise ValueError("role_id cannot be empty")
221
+ return v
222
+
223
+
224
+ class Mode(BaseModel):
225
+ """Operational stage with defined permissions and transitions.
226
+
227
+ Transitions are DECLARATIVE only — they document the protocol's
228
+ intended mode handoffs but are NOT executed by the engine at
229
+ runtime. The engine runs single-mode per invocation; cross-mode
230
+ handoffs are explicit user actions (snodo mode change <m>).
231
+
232
+ Transitions ARE read by ProtocolAdherenceValidator to provide
233
+ mode-profile context to the LLM.
234
+ """
235
+ model_config = ConfigDict(frozen=True)
236
+
237
+ mode_id: str = Field(..., description="Unique mode identifier")
238
+ name: str = Field(..., description="Human-readable mode name")
239
+ tools: List[str] = Field(default_factory=list, description="Available tools in this mode")
240
+ transitions: Dict[str, str] = Field(default_factory=dict, description="Declarative event → target mode mappings (not engine-executed)")
241
+ validators: List[str] = Field(default_factory=list, description="Active validator IDs")
242
+ constraints: List[Constraint] = Field(default_factory=list, description="Mode-specific constraints")
243
+ coder: Optional[str] = Field(default=None, description="Coder backend name (e.g., 'litellm', 'mock')")
244
+ coder_config: Dict[str, Any] = Field(default_factory=dict, description="Coder backend configuration")
245
+ auto_merge: Optional[bool] = Field(
246
+ default=None,
247
+ description=(
248
+ "Override the protocol-level auto_merge for this mode. None = inherit "
249
+ "the protocol's execution.auto_merge setting."
250
+ ),
251
+ )
252
+ max_recovery_depth: Optional[int] = Field(
253
+ default=None,
254
+ ge=0,
255
+ le=20,
256
+ description=(
257
+ "Override the protocol-level max_recovery_depth for this mode. None = inherit "
258
+ "the protocol's execution.max_recovery_depth setting."
259
+ ),
260
+ )
261
+
262
+ @field_validator('mode_id')
263
+ @classmethod
264
+ def validate_id(cls, v: str) -> str:
265
+ if not v or not v.strip():
266
+ raise ValueError("mode_id cannot be empty")
267
+ return v
268
+
269
+ @field_validator('transitions')
270
+ @classmethod
271
+ def validate_transitions(cls, v: Dict[str, str]) -> Dict[str, str]:
272
+ for event, target in v.items():
273
+ if not event or not target:
274
+ raise ValueError("transitions must have non-empty event and target")
275
+ return v
276
+
277
+
278
+ class Protocol(BaseModel):
279
+ """Top-level protocol definition."""
280
+
281
+ model_config = ConfigDict(frozen=True)
282
+
283
+ protocol_id: str = Field(..., description="Unique protocol identifier")
284
+ name: str = Field(..., description="Human-readable protocol name")
285
+ version: str = Field(default="1.0.0", description="Protocol version")
286
+ modes: List[Mode] = Field(..., description="Available operational modes", min_length=1)
287
+ roles: List[Role] = Field(default_factory=list, description="Participant roles")
288
+ validators: List[Validator] = Field(..., description="Validation agents", min_length=1)
289
+ disagreement_policy: DisagreementPolicy = Field(
290
+ default=DisagreementPolicy.UNANIMOUS,
291
+ description="How to resolve validator conflicts"
292
+ )
293
+ initial_mode: str = Field(..., description="Starting mode ID")
294
+ global_constraints: List[Constraint] = Field(
295
+ default_factory=list,
296
+ description="Protocol-wide constraints"
297
+ )
298
+ execution: ExecutionConfig = Field(
299
+ default_factory=ExecutionConfig,
300
+ description="Branch isolation and retry configuration"
301
+ )
302
+ metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
303
+ exclusive_tools: Set[str] = Field(
304
+ default_factory=lambda: set(DEFAULT_EXCLUSIVE_TOOLS),
305
+ description=(
306
+ "Tools that must be exclusive to a single mode (approval-conferring). "
307
+ "Default: approve + merge. A protocol may extend, but not shrink, this "
308
+ "set — the defaults are always enforced."
309
+ ),
310
+ )
311
+
312
+ @field_validator('protocol_id')
313
+ @classmethod
314
+ def validate_id(cls, v: str) -> str:
315
+ if not v or not v.strip():
316
+ raise ValueError("protocol_id cannot be empty")
317
+ return v
318
+
319
+ @field_validator('exclusive_tools')
320
+ @classmethod
321
+ def validate_exclusive_tools(cls, v: Set[str]) -> Set[str]:
322
+ """The defaults are always enforced: a protocol may extend the
323
+ exclusive set but never shrink it."""
324
+ return set(v) | set(DEFAULT_EXCLUSIVE_TOOLS)
325
+
326
+ @field_serializer('exclusive_tools')
327
+ @classmethod
328
+ def serialize_exclusive_tools(cls, v: Set[str]):
329
+ """Deterministic serialization (sets have no stable order)."""
330
+ return sorted(v)
331
+
332
+ @field_validator('initial_mode')
333
+ @classmethod
334
+ def validate_initial_mode(cls, v: str, info) -> str:
335
+ """Ensure initial_mode references a valid mode."""
336
+ # Note: Cross-field validation happens in model_validator
337
+ if not v or not v.strip():
338
+ raise ValueError("initial_mode cannot be empty")
339
+ return v
340
+
341
+ @field_validator('modes')
342
+ @classmethod
343
+ def validate_unique_mode_ids(cls, v: List[Mode]) -> List[Mode]:
344
+ """Ensure all mode IDs are unique."""
345
+ ids = [m.mode_id for m in v]
346
+ if len(ids) != len(set(ids)):
347
+ raise ValueError("mode IDs must be unique")
348
+ return v
349
+
350
+ @field_validator('validators')
351
+ @classmethod
352
+ def validate_unique_validator_ids(cls, v: List[Validator]) -> List[Validator]:
353
+ """Ensure all validator IDs are unique."""
354
+ ids = [val.validator_id for val in v]
355
+ if len(ids) != len(set(ids)):
356
+ raise ValueError("validator IDs must be unique")
357
+ return v
358
+
359
+ def get_mode(self, mode_id: str) -> Optional[Mode]:
360
+ """Retrieve a mode by ID."""
361
+ for mode in self.modes:
362
+ if mode.mode_id == mode_id:
363
+ return mode
364
+ return None
365
+
366
+ def resolve_mode_setting(self, mode_id: str, field_name: str) -> Any:
367
+ """Resolve a setting for *mode_id*, falling back to protocol execution default.
368
+
369
+ Checks mode.*field_name* (if mode exists and setting is not None), otherwise
370
+ falls back to protocol.execution.*field_name*.
371
+ """
372
+ mode = self.get_mode(mode_id)
373
+ if mode is not None:
374
+ val = getattr(mode, field_name, None)
375
+ if val is not None:
376
+ return val
377
+ return getattr(self.execution, field_name)
378
+
379
+ def auto_merge_enabled(self, mode_id: str) -> bool:
380
+ """Whether a successfully completed task in *mode_id* auto-merges.
381
+
382
+ The mode's ``auto_merge`` (if set) overrides the protocol-level
383
+ ``execution.auto_merge``; otherwise the protocol setting applies.
384
+ """
385
+ return bool(self.resolve_mode_setting(mode_id, "auto_merge"))
386
+
387
+ def max_recovery_depth_for(self, mode_id: str) -> int:
388
+ """Resolve max recovery depth for *mode_id*.
389
+
390
+ The mode's ``max_recovery_depth`` (if set) overrides the protocol-level
391
+ ``execution.max_recovery_depth``; otherwise the protocol setting applies.
392
+ """
393
+ return int(self.resolve_mode_setting(mode_id, "max_recovery_depth"))
394
+
395
+ def get_validator(self, validator_id: str) -> Optional[Validator]:
396
+ """Retrieve a validator by ID."""
397
+ for validator in self.validators:
398
+ if validator.validator_id == validator_id:
399
+ return validator
400
+ return None
401
+
402
+ def get_role(self, role_id: str) -> Optional[Role]:
403
+ """Retrieve a role by ID."""
404
+ for role in self.roles:
405
+ if role.role_id == role_id:
406
+ return role
407
+ return None
408
+
409
+ def get_validators_by_phase(self, phase: str) -> List[Validator]:
410
+ """Retrieve all validators for a given evaluation phase.
411
+
412
+ Args:
413
+ phase: Evaluation phase (e.g., "pre_execute", "post_execute")
414
+
415
+ Returns:
416
+ List of validators matching the phase.
417
+ """
418
+ return [v for v in self.validators if v.evaluation_phase == phase]
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # Plan models (Pydantic view over plan.yml and status.json)
423
+ # ---------------------------------------------------------------------------
424
+
425
+ class PlanTask(BaseModel):
426
+ """A task entry within a plan."""
427
+
428
+ id: str
429
+ status: str = Field(default="pending")
430
+ parent_task_ref: Optional[str] = Field(default=None)
431
+ depth: int = Field(default=0, ge=0)
432
+ spec_hash: Optional[str] = Field(default=None)
433
+
434
+ def __getitem__(self, item: str) -> Any:
435
+ if hasattr(self, item):
436
+ return getattr(self, item)
437
+ raise KeyError(item)
438
+
439
+ def get(self, item: str, default: Any = None) -> Any:
440
+ if hasattr(self, item):
441
+ return getattr(self, item)
442
+ return default
443
+
444
+
445
+ class PlanWave(BaseModel):
446
+ """A wave entry within a plan."""
447
+
448
+ id: int
449
+ depends_on: List[int] = Field(default_factory=list)
450
+ tasks: List[str] = Field(default_factory=list)
451
+
452
+ def __getitem__(self, item: str) -> Any:
453
+ if hasattr(self, item):
454
+ return getattr(self, item)
455
+ raise KeyError(item)
456
+
457
+ def get(self, item: str, default: Any = None) -> Any:
458
+ if hasattr(self, item):
459
+ return getattr(self, item)
460
+ return default
461
+
462
+
463
+ class Plan(BaseModel):
464
+ """Pydantic model representing a plan structure.
465
+
466
+ Provides a typed view over plan.yml and status.json without altering
467
+ the on-disk format.
468
+ """
469
+
470
+ name: str
471
+ intent: str
472
+ waves: List[PlanWave] = Field(default_factory=list)
473
+ tasks: Dict[str, PlanTask] = Field(default_factory=dict)
474
+ parse_errors: List[str] = Field(default_factory=list)
475
+
476
+ def __getitem__(self, item: str) -> Any:
477
+ if hasattr(self, item):
478
+ return getattr(self, item)
479
+ raise KeyError(item)
480
+
481
+ def get(self, item: str, default: Any = None) -> Any:
482
+ if hasattr(self, item):
483
+ return getattr(self, item)
484
+ return default
485
+
486
+ def to_dict(self) -> Dict[str, Any]:
487
+ """Return dict representation matching plan.yml on-disk format."""
488
+ return {
489
+ "name": self.name,
490
+ "intent": self.intent,
491
+ "waves": [
492
+ {
493
+ "id": w.id,
494
+ "depends_on": list(w.depends_on),
495
+ "tasks": list(w.tasks),
496
+ }
497
+ for w in self.waves
498
+ ],
499
+ }
500
+
501
+ @classmethod
502
+ def from_dict(
503
+ cls,
504
+ plan_data: Dict[str, Any],
505
+ status_data: Optional[Dict[str, Any]] = None,
506
+ ) -> "Plan":
507
+ """Construct a Plan model from raw plan_data dict and optional status_data dict.
508
+
509
+ Handles legacy string entries and dict task entries in status_data.
510
+ """
511
+ name = str(plan_data.get("name") or "")
512
+ intent = str(plan_data.get("intent") or "")
513
+ raw_waves = plan_data.get("waves") or []
514
+
515
+ waves: List[PlanWave] = []
516
+ wave_task_ids: List[str] = []
517
+ parse_errors: List[str] = []
518
+
519
+ def _is_int(val: Any) -> bool:
520
+ if val is None or isinstance(val, bool):
521
+ return False
522
+ if isinstance(val, int):
523
+ return True
524
+ if isinstance(val, str):
525
+ s = val.strip()
526
+ if not s:
527
+ return False
528
+ if s.startswith("-"):
529
+ return s[1:].isdigit()
530
+ return s.isdigit()
531
+ return False
532
+
533
+ for w in raw_waves:
534
+ if isinstance(w, PlanWave):
535
+ waves.append(w)
536
+ wave_task_ids.extend([str(t) for t in w.tasks])
537
+ elif isinstance(w, dict):
538
+ wid = w.get("id")
539
+ deps = w.get("depends_on") or []
540
+ tasks = w.get("tasks") or []
541
+
542
+ if _is_int(wid):
543
+ wid_int = int(wid)
544
+ else:
545
+ parse_errors.append(f"Wave id '{wid}' is not an integer")
546
+ wid_int = 0
547
+
548
+ deps_int: List[int] = []
549
+ for d in deps:
550
+ if _is_int(d):
551
+ deps_int.append(int(d))
552
+ else:
553
+ wave_label = wid if wid is not None else wid_int
554
+ parse_errors.append(f"Wave {wave_label} depends on non-integer wave '{d}'")
555
+
556
+ str_tasks = [str(t) for t in tasks]
557
+ waves.append(PlanWave(id=wid_int, depends_on=deps_int, tasks=str_tasks))
558
+ wave_task_ids.extend(str_tasks)
559
+
560
+ status_tasks = (status_data or {}).get("tasks", {})
561
+ if not isinstance(status_tasks, dict):
562
+ status_tasks = {}
563
+
564
+ tasks_map: Dict[str, PlanTask] = {}
565
+
566
+ # Populate tasks from waves first
567
+ for tid in wave_task_ids:
568
+ tasks_map[tid] = PlanTask(id=tid, status="pending")
569
+
570
+ # Merge status_tasks
571
+ for tid, entry in status_tasks.items():
572
+ tid_str = str(tid)
573
+ if isinstance(entry, str):
574
+ status_str = entry
575
+ parent_ref = None
576
+ depth_val = 0
577
+ hash_val = None
578
+ elif isinstance(entry, dict):
579
+ status_str = str(entry.get("status", "pending"))
580
+ parent_ref = entry.get("parent_task_ref")
581
+ if parent_ref is not None:
582
+ parent_ref = str(parent_ref)
583
+ try:
584
+ depth_val = int(entry.get("depth", 0))
585
+ except (ValueError, TypeError):
586
+ depth_val = 0
587
+ hash_val = entry.get("spec_hash")
588
+ if hash_val is not None:
589
+ hash_val = str(hash_val)
590
+ else:
591
+ status_str = "pending"
592
+ parent_ref = None
593
+ depth_val = 0
594
+ hash_val = None
595
+
596
+ tasks_map[tid_str] = PlanTask(
597
+ id=tid_str,
598
+ status=status_str,
599
+ parent_task_ref=parent_ref,
600
+ depth=depth_val,
601
+ spec_hash=hash_val,
602
+ )
603
+
604
+ return cls(
605
+ name=name,
606
+ intent=intent,
607
+ waves=waves,
608
+ tasks=tasks_map,
609
+ parse_errors=parse_errors,
610
+ )