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.
- snodo_foundation-0.7.2/PKG-INFO +16 -0
- snodo_foundation-0.7.2/pyproject.toml +32 -0
- snodo_foundation-0.7.2/setup.cfg +4 -0
- snodo_foundation-0.7.2/src/snodo/compiler/__init__.py +0 -0
- snodo_foundation-0.7.2/src/snodo/compiler/models.py +610 -0
- snodo_foundation-0.7.2/src/snodo/compiler/verifier.py +557 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/__init__.py +0 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/audit.py +612 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/cloud_sync.py +490 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/config.py +184 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/decisions.py +392 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/environment.py +176 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/jwks.py +73 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/memory.py +319 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/model_catalog.py +196 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/model_discovery.py +426 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/model_resolver.py +64 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/oauth_verifier.py +32 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/patch_coverage.py +273 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/paths.py +12 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/session.py +693 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/signing_keys.py +159 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/state.py +154 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/tokens.py +444 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/tool_telemetry.py +210 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/usage_tracker.py +242 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/wave_registry.py +354 -0
- snodo_foundation-0.7.2/src/snodo/infrastructure/worktree.py +360 -0
- snodo_foundation-0.7.2/src/snodo/protocols/__init__.py +174 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/2+n.yml +172 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/__init__.py +1 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/bugfix-surgeon.yml +86 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/feature-warden.yml +96 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/greenfield.yml +208 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/intent.yml +70 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/solo.yml +105 -0
- snodo_foundation-0.7.2/src/snodo/protocols/templates/team.yml +161 -0
- snodo_foundation-0.7.2/src/snodo_foundation.egg-info/PKG-INFO +16 -0
- snodo_foundation-0.7.2/src/snodo_foundation.egg-info/SOURCES.txt +40 -0
- snodo_foundation-0.7.2/src/snodo_foundation.egg-info/dependency_links.txt +1 -0
- snodo_foundation-0.7.2/src/snodo_foundation.egg-info/requires.txt +7 -0
- 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"]
|
|
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
|
+
)
|