Extending Snodo¶
Four extension points. Each maps to an interface or registry in the codebase. You implement the interface, register your implementation, and reference it from protocol.yml.
1. Custom validators¶
Interface¶
Subclass ValidatorBase (snodo/validators/context.py:34-48) and implement two methods:
from snodo.validators.context import ValidatorBase, ValidatorContext
from snodo.core.interfaces import ValidatorResult
class MyValidator(ValidatorBase):
@classmethod
def registered_type(cls) -> str:
return "my_type"
def evaluate(self, context: ValidatorContext) -> ValidatorResult:
...
registered_type() returns the string you'll use in protocol.yml as the validator_type. evaluate(context) receives a ValidatorContext with the task, current mode, protocol, artifacts, working directory, and an optional LLM completion function — read what you need.
Registration¶
Register with the default registry:
from snodo.validators.registry import _default_registry
_default_registry.register("my_type", MyValidator)
For a validator that handles multiple types, use register_compound:
_default_registry.register_compound({"my_type", "my_alias"}, MyValidator)
Registration must happen at import time — put it at the bottom of your validator module. If your module is imported (directly or via snodo.validators.__init__), the registration fires automatically.
Wiring into the protocol¶
validators:
- validator_id: "my_check"
validator_type: "my_type"
evaluation_phase: "pre_execute"
criteria:
- "Custom check description"
severity_cap: "blocker" # optional — cap at "warn" for experimental validators
The engine dispatches to your validator during _dispatch_one() in the orchestration loop (loop.py:776-804). The evaluation_phase controls when it runs: pre_execute before code generation, post_execute after, mode_transition on mode change.
Worked example¶
The test suite includes a complete third-party validator proof (tests/validators/test_custom_validator.py:32-62):
class CustomValidator(ValidatorBase):
def __init__(self, validator_spec: Validator):
self.validator_spec = validator_spec
self.validator_id = validator_spec.validator_id
@classmethod
def registered_type(cls) -> str:
return "custom_type"
def evaluate(self, context: ValidatorContext) -> ValidatorResult:
if "safe" in context.task.spec.lower():
return ValidatorResult(
validator_id=self.validator_id,
severity="pass",
justification="Task spec mentions 'safe'.",
)
return ValidatorResult(
validator_id=self.validator_id,
severity="warn",
justification="Task spec does not mention 'safe'.",
)
from snodo.validators.registry import _default_registry
_default_registry.register("custom_type", CustomValidator)
ADR 005 for the design rationale.
2. Custom predicates¶
Interface¶
Subclass Predicate (snodo/predicates/base.py:37-57) and implement one method:
from snodo.predicates.base import Predicate, PredicateContext, PredicateResult
class MyPredicate(Predicate):
def evaluate(self, context: PredicateContext, **params) -> PredicateResult:
# context.artifacts — list of file paths produced so far
# context.mode — current mode ID
# context.workspace_mcp — WorkspaceMCP (or None)
# **params — constraint-specific params from the YAML
...
return PredicateResult(
passed=True,
justification="All files in scope",
evidence={"matched": [...]},
)
Predicates are deterministic — no LLM calls, no write side effects. They must handle both "governance" and "post_validate" phases (context.phase), passing trivially when context is insufficient.
Registration¶
from snodo.predicates.registry import _default_registry
_default_registry.register("my_predicate", MyPredicate())
Note the difference from validators: predicates register instances, not classes. Put registration at import time in your predicate module. WF5 verifies that referenced predicate names are registered at protocol load time.
Wiring into the protocol¶
global_constraints:
- constraint_id: "my_check"
description: "All artifacts must pass my check"
predicate: "my_predicate"
params:
my_param: "value"
severity: "blocker"
Constraints can be placed at three levels: global_constraints (every task), mode.constraints (per-mode), or validator.constraints (per-validator).
Shipped predicates¶
Three predicates ship for reference (snodo/predicates/):
files_in_scope— verifies all modified files match configured scope pathstests_exist_for_modified— requires test files for each modified implementation fileno_secrets_in_diff— scans git diff for credential patterns
ADR 004 for the design rationale.
3. Coder adapters¶
Interface¶
Implement Coder (snodo/core/interfaces.py:11-17):
from snodo.core.interfaces import Coder, TaskSpec, CodeArtifact
class MyCoder(Coder):
def implement(self, spec: TaskSpec) -> CodeArtifact:
# spec.description — the task description
# spec.constraints — declared constraints
# spec.memory_summary — agent memory context
# spec.project_context — project-level metadata
...
return CodeArtifact(files=[...])
A CodeArtifact is a list of FileArtifact objects (path, content, action="write"|"delete"). Two adapters ship: LiteLLMAdapter (routes to 100+ LLM backends via litellm) and MockAdapter (deterministic stub for testing).
Wiring¶
No registry — coders are not plugin-resolved. Set the coder backend on the mode:
modes:
- mode_id: "producer"
coder: "litellm"
coder_config:
model: "claude-sonnet-4-20250514"
temperature: 0.7
The engine resolves coder to an adapter class at graph build time (loop.py passes the coder parameter to GraphBuilder). The --mock CLI flag overrides to MockAdapter.
ADR 007 for the design rationale.
4. Code-host providers¶
Interface¶
Implement CodeHostProvider (snodo/providers/base.py:16-106):
from snodo.providers.base import CodeHostProvider
class GitLabProvider(CodeHostProvider):
def __init__(self, project_root: str = "", metadata: dict | None = None):
...
def create_pr(self, branch: str, title: str, body: str) -> str: ...
def read_pr_diff(self, pr_number: int) -> str: ...
def post_review_comment(self, pr_number: int, comment: str) -> str: ...
def approve_pr(self, pr_number: int) -> str: ...
def reject_pr(self, pr_number: int, reason: str) -> str: ...
def merge_pr(self, pr_number: int) -> str: ...
def read_pr_comments(self, pr_number: int) -> str: ...
All seven methods must be implemented. Return types are strings (PR URLs, confirmation messages, JSON payloads). Raise ProviderError for failures.
Registration¶
Two paths:
Setuptools entry point (recommended for pip-installable plugins):
# pyproject.toml
[project.entry-points."snodo.providers"]
gitlab = "my_package.gitlab:GitLabProvider"
The provider registry (providers/registry.py:178-197) loads entry points from the snodo.providers group.
Explicit metadata (for in-project providers):
# protocol.yml
metadata:
provider: "gitlab"
gitlab_repo: "my-org/my-project"
gitlab_token: "${GITLAB_TOKEN}"
Resolution order¶
metadata.providerif set- Auto-detect from git remote URL (
github.com→ GitHub) - Entry points in the
snodo.providersgroup - Fallback to
LocalProvider(no remote)
Shipped providers¶
Two ship: GitHubProvider (snodo/providers/github.py, backed by PyGithub) and LocalProvider (no-op remote, PR operations raise ProviderError).
ADR 007 for the design rationale.
Where extensions run in the loop¶
Every extension plugs into the orchestration graph at a specific point:
Governance → Validate → Execute → Post-validate → Move-next → Complete
│ │ │ │
│ validators coder predicates
│ (pre_execute) adapter validators
│ (post_execute)
predicates
(governance)
providers
(via PrMCP
during execute)
- Validators run in the
validatenode (pre_executephase) or thepost_validatenode (post_executephase). The engine builds a singleValidatorContextper pass and dispatches each validator spec through the registry. - Predicates run in the
governancenode (pre-execute constraints) orpost_validatenode (post-execute constraints). The engine builds aPredicateContextfromLoopStateand callsevaluate(context, **params). - Coders run in the
executenode. The engine passes aTaskSpecand receives aCodeArtifact— file operations are then applied via WorkspaceMCP and committed via GitMCP. - Providers are used by
PrMCPduring theexecutenode when PR operations are requested. The provider is resolved at MCP server construction time viadetect_provider().
All extensions are referenced from protocol.yml — no code changes needed in the engine.