Skip to content

Snodo protocol.yml — DSL Reference

The protocol file (protocol.yml) declares your team's intent: what work can be done, by whom, under which rules, and with what enforcement. The engine reads this declaration and enforces it structurally — no after-the-fact review.

Security note: protocol files are executable input.

A protocol.yml is not passive configuration. Protocol fields such as tooling.test_command and prepare_command (for example execution.prepare_command) are executed through shell=True, and snodo does not sandbox protocol-authored commands. Trust a protocol file like you would trust a Makefile, package.json script, or CI config. Running a protocol you did not write is running code you did not read.

Minimal protocol

protocol_id: "my_protocol"
name: "My Protocol"
version: "1.0.0"
modes:
  - mode_id: "producer"
    name: "Producer"
    tools: ["edit"]
    validators: ["security"]
validators:
  - validator_id: "security"
    validator_type: "security"
    criteria:
      - "Check for injection risks"
disagreement_policy: "unanimous"
initial_mode: "producer"

This declares one mode (producer) with one tool (edit) and one validator (security checks). The unanimous disagreement policy requires the validator to pass before execution proceeds.


Protocol — top-level

Field Type Required Description
protocol_id string yes Unique identifier
name string yes Human-readable name
version string no Semantic version (default "1.0.0")
modes list[Mode] yes One or more operational modes
roles list[Role] no Participant roles
validators list[Validator] yes One or more validator configurations
disagreement_policy string no How to resolve validator conflicts: "unanimous", "majority", "quorum", "any" (default "unanimous")
initial_mode string yes Mode ID to start in
global_constraints list[Constraint] no Protocol-wide constraints (see Constraints)
execution ExecutionConfig no Execution and recovery configuration (see Execution configuration)
metadata dict no Arbitrary key/value metadata

Execution configuration

execution:
  max_recovery_depth: 3
  max_total_fix_attempts: 10
  auto_merge: false
Field Type Required Description
max_recovery_depth int no Maximum recursive subtask recovery depth along a single branch (default 3, range 0–20)
max_total_fix_attempts int no Maximum total fix subtasks spawned across the task tree (default 10, range 1–100)
auto_merge bool no Whether a completed task's branch merges into the base branch automatically (default false)
prepare_command string no Command executed after worktree setup to prepare environment (e.g. npm ci, uv sync)

Security note: execution.prepare_command is protocol-authored shell input. snodo does not sandbox it. Treat it like a Makefile target, package.json script, or CI config.

max_recovery_depth tradeoff

The recovery depth cap controls how deep the engine recurses when spawning subtasks to fix validator rejections. It is configured at the protocol level (execution.max_recovery_depth, default 3) and can be overridden per mode (mode.max_recovery_depth). When a mode is silent (null), it inherits the protocol's setting.

  • Established Repositories / Feature Build Modes (max_recovery_depth: 3, default): When a codebase has a stable, verified build and test harness, validator rejections stem from implementation bugs or criterion mismatches. Legitimate fixes often require 2–3 incremental subtask turns (e.g., fixing the primary implementation, then addressing edge-case test failures). Recovery stall detection (which halts after 2 identical validator verdicts) and max_total_fix_attempts prevent runaway loops if non-convergence occurs.
  • Greenfield Setup Modes (max_recovery_depth: 1): In bootstrap phases like decide or scaffold, early validator failures are setup or environment faults (unrecorded ADRs, missing lockfiles, placeholder test commands like REPLACE_ME) that subtasks cannot fix without human intervention. Setting max_recovery_depth: 1 on setup modes bounds expenditure to a single recovery attempt, while allowing the downstream build mode to use max_recovery_depth: 3 once the harness is verified.

Mode — operational stages

Each mode defines what tools are available, which validators run, and what happens when work is complete. The engine runs single-mode per invocation; cross-mode handoffs are explicit user actions.

modes:
  - mode_id: "producer"
    name: "Producer Mode"
    tools:
      - "edit"
      - "dispatch"
    validators:
      - "security"
      - "architecture"
    transitions:
      complete: "reviewer"
Field Type Required Description
mode_id string yes Unique identifier within the protocol
name string yes Human-readable name
tools list[string] no Available logical tools — see Tool table below
validators list[string] no Validator IDs active in this mode
transitions dict[string, string] no Declarative event→target-mode mappings (documented, not engine-executed)
constraints list[Constraint] no Mode-specific constraints
coder string no Coder backend ("litellm", "mock"; "opencode" and "opencode-cli" are experimental — see below)
coder_config dict no Coder backend configuration
auto_merge bool no Override protocol-level execution.auto_merge for this mode (default null)
max_recovery_depth int no Override protocol-level execution.max_recovery_depth for this mode (default null)

Coder backends

snodo supports interchangeable code generation backends declared via --coder or a mode's coder field:

  • litellm (default, supported): Routes completions via LiteLLM (~100+ providers). The engine writes artifacts via WorkspaceMCP and manages commits and per-turn telemetry.
  • agy (host CLI): Shells out to Antigravity CLI (agy -p) on the host. Edits files in place and commits to git upon completion.
  • opencode-cli (host CLI, experimental): Shells out to host opencode run. Edits files in place and commits to git upon completion.
  • opencode (container, experimental): OpenCode server running in Docker over HTTP (POST /session, POST /session/{id}/message). Edits files in place in the volume-mounted workspace and commits to git upon completion.
  • mock (supported): Deterministic stub for dry runs and testing.

Selection Precedence

  1. --mock / use_mock_coder=True option (always returns 'mock').
  2. --coder <name> CLI option on snodo run / snodo plan run.
  3. coder: <name> field in the active protocol mode definition.
  4. Model prefix mapping (opencode-cli/, opencode/, agy/, gpt/o1/o3, claude, gemini).
  5. Default (litellm).

Model Role Separation (Judging vs Execution)

  • -m / --model sets the JUDGING model: Passing -m specifies the model used by LiteLLM for validators (pre/post-execute gates) and the classifier.
  • External CLI Coders use their own model catalogs: External CLI tools (agy, opencode-cli) use their own CLI configuration and internal catalogs. Non-prefixed model names passed to -m are stripped by SubprocessCoderAdapter._bare_model() so the CLI uses its own default model.
  • Explicit Coder Model: To set an external coder's model explicitly while keeping -m for validators, use the adapter namespace prefix (e.g. --model agy/gemini-2.5-pro or --model opencode-cli/claude-3-7-sonnet).

In-Place Coders & Governance

External in-place coders (opencode, opencode-cli, agy) inherit InPlaceCoderAdapter (skip_engine_commit = True, skip_workspace_write = True). They write directly to the working tree, snapshot and enforce .snodo/ mutation boundaries (ADR 027), and commit the result so post-execute validators reviewing git diff HEAD~1..HEAD see the exact change (ADR 030).

Per ADR 034, the absence of per-turn usage or token metrics for external coders is a stated decision (non-goal), not an attestation gap: token and cost metrics reside in per-job state.json telemetry (snodo meta), while snodo's audit log attests to governance decisions and verification evidence.

Tool set restrictions (WF1)

Approval-conferring tools (approve, merge by default) must appear in at most one mode. If any two modes share an exclusive tool, the protocol fails to load with WF1Violation. Non-exclusive tools (e.g. edit, test) may be shared across modes. This preserves the no-self-approval guarantee — the producer (which holds edit) and the reviewer (which holds approve/merge) can never both hold an approval tool — without requiring total disjointness. The exclusive set is declared via exclusive_tools at the protocol root and defaults to {approve, merge}; it may be extended but not shrunk.

Concrete tool mapping

Each logical tool maps to one or more MCP operations:

Protocol tool Concrete MCP tools
edit read_file, list_files
dispatch dispatch_task
resolve resolve_disagreement
test run_tests
validate run_tests
review read_file, list_files, read_diff, get_status
approve stage_files, commit
commit stage_files, commit
merge create_branch, stage_files, commit, merge_branch, delete_branch
pr create_pr, read_pr_diff, post_review_comment, approve_pr, reject_pr, merge_pr
plan decompose, generate_spec, validate_plan
assess read_file, list_files

Reference modes

The shipped templates implement three standard modes:

Producer mode — generates code. Typical tools: edit, dispatch, test, validate. Validators check security, architecture, conventions before execution.

Reviewer mode — reviews and integrates. Typical tools: review, approve, merge, pr. Validators re-check security at review time.

Planner mode — decomposes work. Typical tools: assess, plan. Validators check intent clarity, scope, completeness.


Role — participant identity

roles:
  - role_id: "lead"
    name: "Tech Lead"
    permissions: ["review", "approve"]
    responsibilities: ["architecture decisions", "code review"]
Field Type Required Description
role_id string yes Unique role identifier
name string yes Human-readable name
permissions list[string] no Allowed actions
responsibilities list[string] no Expected duties

Roles declare intent; the engine does not enforce role-based access at runtime. They are reference documentation for protocols with human-in-the-loop participants.


Validator — evaluation gate

validators:
  - validator_id: "security"
    validator_type: "security"
    evaluation_phase: "pre_execute"
    criteria:
      - "Check for SQL injection"
      - "Validate input sanitization"
    severity_cap: "blocker"
Field Type Required Description
validator_id string yes Unique identifier
validator_type string yes Backend type: "security", "architecture", "quality", "conventions", "planning", "protocol", or custom
evaluation_phase string no When to run: "pre_execute", "post_execute", "mode_transition" (default "pre_execute")
criteria list[string] no Prompts for LLM-backed validators; ignored by non-LLM backends
constraints list[Constraint] no Additional predicate constraints
tooling dict no Backend tooling configuration (e.g. test_command for the quality validator)
severity_cap string no Maximum severity this validator can emit. "warn" caps blocker to warn — useful for experimental validators. "blocker" or absent = full power.

Security note: tooling.test_command (used by the quality validator) is protocol-authored shell input. snodo does not sandbox it. Treat it like a Makefile target, package.json script, or CI config.

Validator types

Type Backend What it does
security LLM Reviews task spec against security criteria
architecture LLM Reviews task spec against design criteria
conventions LLM Reviews against naming/file/doc conventions
planning LLM Reviews plan intents against planning criteria
protocol LLM Checks whether work belongs in the current mode
quality subprocess Runs the repo's test suite (auto-detects test command)
custom your code Register any string via the ValidatorRegistry

Severity

Every validator result carries one of three severities, ordered pass < warn < blocker:

Severity Meaning Effect on execution
pass No issues found Counts toward policy threshold
warn Advisory concern Withholds approval — does NOT count toward policy threshold (post-policy-fix: warn ≠ approval)
blocker Critical issue Halts execution unconditionally (INV3) — bypasses all policy thresholds

DisagreementPolicy — validator consensus

Four policies determine how validator results combine into a proceed/block decision. All threshold on pass_count only; warn withholds approval.

disagreement_policy: "majority"
Policy Rule When used
"unanimous" pass_count == total_count Every validator must approve — critical systems
"majority" pass_count > total_count / 2.0 >50% approval — team workflow
"quorum" pass_count >= total_count * 0.67 Configurable 2/3 threshold
"any" pass_count >= 1 At least one approval — permissive front-end

The threshold is evaluated per phase, and total_count is the number of validators that ran in that phase. Under "unanimous" a phase with exactly one validator is therefore an unopposed veto: that validator alone decides whether the phase passes. This is worth noticing for post_execute — the phase that reviews completed work — because quality and acceptance both run there in the shipped templates, and a single post-execute validator under unanimous can block every task on operational noise (a flaky test, a tool outage) with no second opinion. The verifier warns at load time when a protocol has exactly one post_execute validator under "unanimous"; either add a second post-execute judge or choose a different policy.

INV3: blocker_count > 0 halts execution before any policy logic runs. A single blocker overrides every policy — by design. This is the structural guarantee that no critical defect can be voted down.

Actions

Action When
PROCEED Policy threshold met, zero warns
PROCEED_WITH_LOG Policy threshold met, one or more warns present
ESCALATE Policy threshold not met, no blockers — requires human resolution
HALT One or more blockers present (INV3 override)

When ESCALATE fires, the task is blocked and a structured payload is emitted. Use snodo authorize <task_id> to review and sign the pending decision. The engine tracks the decision in the session checkpoint and takes the declared action on resume.


Constraint — predicate-enforced rules

global_constraints:
  - constraint_id: "files_in_scope"
    description: "Modified files must be within project scope"
    predicate: "files_in_scope"
    params:
      scope_paths: ["src/**", "tests/**"]
    severity: "blocker"
Field Type Required Description
constraint_id string yes Unique identifier
description string yes Human-readable description
expression string no Boolean expression (legacy; documentation-only when predicate is set)
predicate string no Registered predicate name to evaluate
params dict no Parameters passed to the predicate
severity string no "pass", "warn", or "blocker" (default "blocker")

Constraints can be placed at three levels: - global_constraints — enforced on every task - mode.constraints — enforced only in that mode - validator.constraints — enforced by that validator

Shipped predicates: files_in_scope, tests_exist_for_modified, no_secrets_in_diff. Custom predicates can be registered via the PredicateRegistry API.


Token

The engine issues a JWT validation token when the policy threshold is met with no blockers. Mutating tools (write, commit, merge, etc.) require a valid token at invocation time — enforced by _enforce_wf1 in the MCP server layer. Tokens are single-use per task and expire at the configured TTL (default 600 seconds).

Configure in ~/.snodo/config.yml:

engine:
  token_ttl_seconds: 600

Or in code via TokenIssuer(ttl_seconds=...).


Well-formedness — WF1 through WF5

Every protocol is verified at load time. A violation raises a ProtocolWellFormednessError with a list of specific failures. The protocol will not load if any check fails.

Check Enforces
WF1 — Mode Separation Approval-conferring tools (approve, merge by default) must be exclusive to one mode. Non-exclusive tools may be shared.
WF2 — Role Uniqueness Role IDs must be unique across the protocol.
WF3 — Validator Coverage Every validator referenced by a mode must exist in the validators list. The initial_mode must exist. Any mode with dispatch must have at least one pre_execute validator.
WF4 — Policy Completeness Disagreement policy must match the validator count: unanimous needs ≥1, majority needs ≥2, quorum warns at <3.
WF5 — Constraint Consistency Constraint IDs must be unique. Predicate names, if set, must be registered.

Templates

The template registry is derived from the YAML files in snodo/protocols/templates/ — adding a template file is sufficient to make it selectable. Shipped templates:

Template Modes Signature
solo producer only Single-coder, no reviewer handoff
team producer → reviewer → planner Two-stage with separate approval authority
2+n producer → reviewer Paper's reference config with predicate constraints (files_in_scope, tests_exist_for_modified, no_secrets_in_diff)
intent producer Intent-driven; warn-only spec validators
bugfix-surgeon producer Bug-fix flow with post-execute review gate
feature-warden producer Feature flow with scope guard
greenfield decide → scaffold → build Phased greenfield build with per-phase exit gates

Use snodo init --template <name> to start from a template, or run snodo init to choose from the interactive menu.