snodo 0.1.0__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.
Files changed (92) hide show
  1. snodo/__init__.py +1 -0
  2. snodo/__main__.py +6 -0
  3. snodo/agents/__init__.py +0 -0
  4. snodo/agents/adapter.py +18 -0
  5. snodo/cli/__init__.py +0 -0
  6. snodo/cli/commands/__init__.py +79 -0
  7. snodo/cli/commands/agent_cmd.py +101 -0
  8. snodo/cli/commands/config_cmd.py +138 -0
  9. snodo/cli/commands/dashboard_cmd.py +38 -0
  10. snodo/cli/commands/init_cmd.py +156 -0
  11. snodo/cli/commands/install_cmd.py +217 -0
  12. snodo/cli/commands/job_cmd.py +143 -0
  13. snodo/cli/commands/mode_cmd.py +107 -0
  14. snodo/cli/commands/plan_cmd.py +127 -0
  15. snodo/cli/commands/resolve_cmd.py +53 -0
  16. snodo/cli/commands/run_cmd.py +871 -0
  17. snodo/cli/commands/sandbox_cmd.py +103 -0
  18. snodo/cli/commands/serve_cmd.py +144 -0
  19. snodo/cli/commands/session_cmd.py +92 -0
  20. snodo/cli/config.py +287 -0
  21. snodo/cli/main.py +572 -0
  22. snodo/coders/__init__.py +67 -0
  23. snodo/coders/base.py +26 -0
  24. snodo/coders/litellm.py +164 -0
  25. snodo/coders/mock.py +36 -0
  26. snodo/compiler/__init__.py +0 -0
  27. snodo/compiler/models.py +262 -0
  28. snodo/compiler/verifier.py +307 -0
  29. snodo/core/__init__.py +0 -0
  30. snodo/core/interfaces.py +86 -0
  31. snodo/dashboard/__init__.py +10 -0
  32. snodo/dashboard/app.py +109 -0
  33. snodo/dashboard/panels/__init__.py +11 -0
  34. snodo/dashboard/panels/agents.py +69 -0
  35. snodo/dashboard/panels/events.py +73 -0
  36. snodo/dashboard/panels/jobs.py +84 -0
  37. snodo/dashboard/panels/plans.py +72 -0
  38. snodo/engine/__init__.py +0 -0
  39. snodo/engine/loop.py +1205 -0
  40. snodo/engine/policy.py +324 -0
  41. snodo/infrastructure/__init__.py +0 -0
  42. snodo/infrastructure/audit.py +231 -0
  43. snodo/infrastructure/memory.py +287 -0
  44. snodo/infrastructure/paths.py +26 -0
  45. snodo/infrastructure/session.py +351 -0
  46. snodo/infrastructure/state.py +56 -0
  47. snodo/infrastructure/tokens.py +250 -0
  48. snodo/jobs/__init__.py +298 -0
  49. snodo/jobs/runner.py +80 -0
  50. snodo/jobs/wrapper.py +74 -0
  51. snodo/mcp/__init__.py +0 -0
  52. snodo/mcp/git.py +212 -0
  53. snodo/mcp/installer.py +442 -0
  54. snodo/mcp/planner.py +665 -0
  55. snodo/mcp/pr.py +110 -0
  56. snodo/mcp/resolution.py +74 -0
  57. snodo/mcp/server.py +742 -0
  58. snodo/mcp/shell.py +323 -0
  59. snodo/mcp/transport.py +119 -0
  60. snodo/mcp/workspace.py +239 -0
  61. snodo/predicates/__init__.py +11 -0
  62. snodo/predicates/base.py +57 -0
  63. snodo/predicates/registry.py +53 -0
  64. snodo/predicates/scope.py +53 -0
  65. snodo/predicates/secrets.py +92 -0
  66. snodo/predicates/tests.py +78 -0
  67. snodo/protocols/__init__.py +1 -0
  68. snodo/protocols/templates/2+n.yml +124 -0
  69. snodo/protocols/templates/__init__.py +1 -0
  70. snodo/protocols/templates/solo.yml +54 -0
  71. snodo/protocols/templates/team.yml +117 -0
  72. snodo/providers/__init__.py +16 -0
  73. snodo/providers/base.py +106 -0
  74. snodo/providers/github.py +158 -0
  75. snodo/providers/local.py +54 -0
  76. snodo/providers/registry.py +219 -0
  77. snodo/sandbox/__init__.py +39 -0
  78. snodo/sandbox/base.py +96 -0
  79. snodo/sandbox/docker_sandbox.py +201 -0
  80. snodo/sandbox/local_sandbox.py +101 -0
  81. snodo/validators/__init__.py +5 -0
  82. snodo/validators/context.py +49 -0
  83. snodo/validators/llm_validator.py +202 -0
  84. snodo/validators/protocol_adherence.py +259 -0
  85. snodo/validators/quality.py +177 -0
  86. snodo/validators/registry.py +52 -0
  87. snodo-0.1.0.dist-info/METADATA +1035 -0
  88. snodo-0.1.0.dist-info/RECORD +92 -0
  89. snodo-0.1.0.dist-info/WHEEL +5 -0
  90. snodo-0.1.0.dist-info/entry_points.txt +3 -0
  91. snodo-0.1.0.dist-info/licenses/LICENSE +663 -0
  92. snodo-0.1.0.dist-info/top_level.txt +1 -0
snodo/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
snodo/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow python -m snodo invocation (e.g. sys.executable -m snodo)."""
2
+
3
+ from snodo.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,18 @@
1
+ """Agent adapter layer - backward compatibility re-exports.
2
+
3
+ FILE: snodo/agents/adapter.py
4
+
5
+ All adapter classes now live in snodo.coders.
6
+ This module re-exports them for backward compatibility.
7
+ """
8
+
9
+ from snodo.coders import ( # noqa: F401
10
+ LiteLLMAdapter,
11
+ MockAdapter,
12
+ BasicCoderAdapter,
13
+ MockCoderAdapter,
14
+ create_coder,
15
+ AdapterError,
16
+ LLMCallError,
17
+ ParseError,
18
+ )
snodo/cli/__init__.py ADDED
File without changes
@@ -0,0 +1,79 @@
1
+ """Snodo CLI command modules.
2
+
3
+ Shared utilities used across command modules live here.
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import yaml
11
+
12
+ from snodo.compiler.models import Protocol
13
+ from snodo.compiler.verifier import verify_protocol, ProtocolWellFormednessError
14
+
15
+
16
+ _TEMPLATES_DIR = Path(__file__).parent.parent.parent / "protocols" / "templates"
17
+
18
+
19
+ def _load_template(name: str) -> str:
20
+ """Load a protocol template YAML file from disk.
21
+
22
+ Templates live as standalone YAML files in snodo/protocols/templates/.
23
+ This replaces the previous approach of embedding templates as Python
24
+ triple-quoted string constants, making protocols reviewable documents
25
+ and editable without code changes (Paper Section 6.4).
26
+
27
+ Args:
28
+ name: Template name without extension (e.g., "solo", "team", "2+n")
29
+
30
+ Returns:
31
+ Raw YAML content as a string
32
+ """
33
+ return (_TEMPLATES_DIR / f"{name}.yml").read_text()
34
+
35
+
36
+ # Default protocol template
37
+ DEFAULT_PROTOCOL = _load_template("team")
38
+
39
+ # Alias for clarity
40
+ TEAM_PROTOCOL = DEFAULT_PROTOCOL
41
+
42
+ SOLO_PROTOCOL = _load_template("solo")
43
+
44
+ TWO_PLUS_N_PROTOCOL = _load_template("2+n")
45
+
46
+ PROTOCOL_TEMPLATES = {"solo": SOLO_PROTOCOL, "team": TEAM_PROTOCOL, "2+n": TWO_PLUS_N_PROTOCOL}
47
+
48
+
49
+ def load_protocol(protocol_path: Path) -> Optional[Protocol]:
50
+ """Load, parse, and verify protocol from YAML file.
51
+
52
+ Runs all WF1-WF5 well-formedness checks after parsing.
53
+ """
54
+ try:
55
+ with open(protocol_path) as f:
56
+ data = yaml.safe_load(f)
57
+
58
+ protocol = Protocol(**data)
59
+
60
+ # WF1-WF5 verification (Section 4.4)
61
+ result = verify_protocol(protocol)
62
+ if not result.passed:
63
+ raise ProtocolWellFormednessError(result.errors)
64
+
65
+ return protocol
66
+
67
+ except FileNotFoundError:
68
+ print(f"Error: Protocol file not found: {protocol_path}", file=sys.stderr)
69
+ print("Run 'snodo init' to create default protocol.", file=sys.stderr)
70
+ return None
71
+ except yaml.YAMLError as e:
72
+ print(f"Error: Invalid YAML in protocol file: {e}", file=sys.stderr)
73
+ return None
74
+ except ProtocolWellFormednessError as e:
75
+ print(f"Error: {e}", file=sys.stderr)
76
+ return None
77
+ except Exception as e:
78
+ print(f"Error: Failed to parse protocol: {e}", file=sys.stderr)
79
+ return None
@@ -0,0 +1,101 @@
1
+ """Agent command - Manage agent memory and threads.
2
+
3
+ FILE: snodo/cli/commands/agent_cmd.py (Task 5.2)
4
+ """
5
+
6
+ import sys
7
+ import time
8
+
9
+
10
+ def agent_command(args) -> int:
11
+ """Manage agent memory and threads."""
12
+ from snodo.infrastructure.memory import AgentMemoryManager, MemoryError
13
+
14
+ try:
15
+ manager = AgentMemoryManager()
16
+ except Exception as e:
17
+ print(f"Error: {e}", file=sys.stderr)
18
+ return 1
19
+
20
+ action = args.agent_action
21
+ try:
22
+ if action == "list":
23
+ return _agent_list(manager)
24
+ elif action == "memory":
25
+ return _agent_memory(manager, args.agent_id)
26
+ elif action == "reset":
27
+ return _agent_reset(manager, args.agent_id)
28
+ elif action == "rotate":
29
+ return _agent_rotate(manager, args.agent_id)
30
+ else:
31
+ print("Unknown agent action. Use: list, memory, reset, rotate", file=sys.stderr)
32
+ return 1
33
+ except MemoryError as e:
34
+ print(f"Error: {e}", file=sys.stderr)
35
+ return 1
36
+
37
+
38
+ def _agent_list(manager) -> int:
39
+ """List all registered agents."""
40
+ agents = manager.list_agents()
41
+ if not agents:
42
+ print("No agents found.")
43
+ print("Agents are created automatically when you run tasks.")
44
+ return 0
45
+
46
+ print(f"{'ID':<30} {'Thread':<12} {'Tasks':<8} {'Last Used'}")
47
+ print("-" * 72)
48
+ for agent in agents:
49
+ thread_short = agent["thread_id"][:8] + "..."
50
+ task_count = agent.get("task_count", 0)
51
+ last_used = _format_time(agent.get("last_used_at"))
52
+ print(f"{agent['id']:<30} {thread_short:<12} {task_count:<8} {last_used}")
53
+ return 0
54
+
55
+
56
+ def _agent_memory(manager, agent_id: str) -> int:
57
+ """Show memory summary for an agent."""
58
+ agent = manager.get_agent(agent_id)
59
+ if agent is None:
60
+ print(f"Error: Agent not found: {agent_id}", file=sys.stderr)
61
+ return 1
62
+
63
+ summary = manager.get_memory_summary(agent_id)
64
+
65
+ print(f"Agent: {agent_id}")
66
+ print(f"Thread ID: {agent['thread_id']}")
67
+ print(f"Project: {agent.get('project', 'N/A')}")
68
+ print(f"Mode: {agent.get('mode', 'N/A')}")
69
+ print(f"Tasks completed: {agent.get('task_count', 0)}")
70
+ print(f"Created: {_format_time(agent.get('created_at'))}")
71
+ print(f"Last used: {_format_time(agent.get('last_used_at'))}")
72
+ print()
73
+ print(f"Checkpoints: {summary.get('checkpoint_count', 0)}")
74
+ print(f"Database: {'exists' if summary.get('db_exists') else 'not created'}")
75
+ return 0
76
+
77
+
78
+ def _agent_reset(manager, agent_id: str) -> int:
79
+ """Reset agent memory (clear checkpoints and assign new thread)."""
80
+ result = manager.reset_memory(agent_id)
81
+ print(f"Agent {agent_id} memory cleared.")
82
+ print(f"New thread ID: {result['thread_id']}")
83
+ return 0
84
+
85
+
86
+ def _agent_rotate(manager, agent_id: str) -> int:
87
+ """Rotate agent thread ID (keeps old checkpoints)."""
88
+ result = manager.rotate_thread(agent_id)
89
+ print(f"Agent {agent_id} thread rotated.")
90
+ print(f"New thread ID: {result['thread_id']}")
91
+ return 0
92
+
93
+
94
+ def _format_time(ts) -> str:
95
+ """Format a timestamp for display."""
96
+ if not ts:
97
+ return "N/A"
98
+ try:
99
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
100
+ except (TypeError, ValueError, OSError):
101
+ return "N/A"
@@ -0,0 +1,138 @@
1
+ """Config command - Manage Snodo configuration and API keys.
2
+
3
+ FILE: snodo/cli/commands/config_cmd.py
4
+ """
5
+
6
+ import sys
7
+
8
+ from snodo.cli.config import ConfigManager, ConfigError, DEFAULT_MODEL
9
+
10
+
11
+ def config_command(args) -> int:
12
+ """Manage Snodo configuration and API keys."""
13
+ mgr = ConfigManager()
14
+
15
+ if args.config_action == "show":
16
+ return _config_show(mgr)
17
+ elif args.config_action == "add":
18
+ return _config_add(mgr, args.provider, args.key)
19
+ elif args.config_action == "remove":
20
+ return _config_remove(mgr, args.provider)
21
+ elif args.config_action == "test":
22
+ return _config_test(mgr)
23
+ elif args.config_action == "set":
24
+ return _config_set(mgr, args.key, args.value)
25
+ elif args.config_action == "get":
26
+ return _config_get(mgr, args.key)
27
+ else:
28
+ print("Unknown config action. Use: show, add, remove, test, set, get", file=sys.stderr)
29
+ return 1
30
+
31
+
32
+ def _config_show(mgr: ConfigManager) -> int:
33
+ """Show current configuration."""
34
+ config = mgr.load()
35
+ keys = config.get("api_keys", {})
36
+ model = config.get("model", DEFAULT_MODEL)
37
+
38
+ print(f"Config: {mgr.config_path}")
39
+ print(f"Model: {model}")
40
+ print()
41
+
42
+ if keys:
43
+ print("API Keys:")
44
+ for provider, key in keys.items():
45
+ print(f" {provider}: {ConfigManager.mask_key(key)}")
46
+ else:
47
+ print("No API keys configured.")
48
+ print(" Add one: snodo config add <provider> <key>")
49
+ return 0
50
+
51
+
52
+ def _config_add(mgr: ConfigManager, provider: str, key: str) -> int:
53
+ """Add an API key."""
54
+ try:
55
+ mgr.add_key(provider, key)
56
+ masked = ConfigManager.mask_key(key)
57
+ print(f"✓ Stored {provider} key: {masked}")
58
+ return 0
59
+ except Exception as e:
60
+ print(f"Error: {e}", file=sys.stderr)
61
+ return 1
62
+
63
+
64
+ def _config_remove(mgr: ConfigManager, provider: str) -> int:
65
+ """Remove an API key."""
66
+ if mgr.remove_key(provider):
67
+ print(f"✓ Removed {provider} key")
68
+ return 0
69
+ else:
70
+ print(f"No key found for provider: {provider}", file=sys.stderr)
71
+ return 1
72
+
73
+
74
+ def _config_test(mgr: ConfigManager) -> int:
75
+ """Test all configured API keys."""
76
+ config = mgr.load()
77
+ keys = config.get("api_keys", {})
78
+ if not keys:
79
+ print("No API keys configured. Add one first:")
80
+ print(" snodo config add <provider> <key>")
81
+ return 1
82
+
83
+ print("Testing API keys...")
84
+ results = mgr.test_keys()
85
+ all_ok = True
86
+ for provider, ok in results.items():
87
+ status = "✓ valid" if ok else "✗ invalid"
88
+ print(f" {provider}: {status}")
89
+ if not ok:
90
+ all_ok = False
91
+ return 0 if all_ok else 1
92
+
93
+
94
+ def _config_set(mgr: ConfigManager, key: str, value: str) -> int:
95
+ """Set a config value using dot notation."""
96
+ parts = key.split(".", 1)
97
+ if len(parts) == 2 and parts[0] == "engine":
98
+ engine_key = parts[1]
99
+ if engine_key in ("max_subtask_depth", "max_session_age_days", "token_ttl_seconds"):
100
+ try:
101
+ int_value = int(value)
102
+ except ValueError:
103
+ print(f"Error: {engine_key} must be an integer", file=sys.stderr)
104
+ return 1
105
+ try:
106
+ mgr.set_engine_value(engine_key, int_value)
107
+ except (ValueError, ConfigError) as e:
108
+ print(f"Error: {e}", file=sys.stderr)
109
+ return 1
110
+ else:
111
+ mgr.set_engine_value(engine_key, value)
112
+ print(f"Set {key} = {value}")
113
+ return 0
114
+ elif key == "model":
115
+ mgr.set_model(value)
116
+ print(f"Set model = {value}")
117
+ return 0
118
+ else:
119
+ print(f"Error: Unknown config key: {key}", file=sys.stderr)
120
+ return 1
121
+
122
+
123
+ def _config_get(mgr: ConfigManager, key: str) -> int:
124
+ """Get a config value using dot notation."""
125
+ parts = key.split(".", 1)
126
+ if len(parts) == 2 and parts[0] == "engine":
127
+ value = mgr.get_engine_value(parts[1])
128
+ if value is None:
129
+ print(f"Not set: {key}", file=sys.stderr)
130
+ return 1
131
+ print(value)
132
+ return 0
133
+ elif key == "model":
134
+ print(mgr.get_model())
135
+ return 0
136
+ else:
137
+ print(f"Error: Unknown config key: {key}", file=sys.stderr)
138
+ return 1
@@ -0,0 +1,38 @@
1
+ """Dashboard command - Launch TUI dashboard.
2
+
3
+ FILE: snodo/cli/commands/dashboard_cmd.py (Task 5.3)
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def snop_entry():
11
+ """Entry point for the 'snop' shortcut command."""
12
+ from types import SimpleNamespace
13
+ args = SimpleNamespace()
14
+ sys.exit(dashboard_command(args))
15
+
16
+
17
+ def dashboard_command(args) -> int:
18
+ """Launch the Snodo TUI dashboard."""
19
+ project_root = str(Path.cwd())
20
+
21
+ # Verify .snodo/ exists
22
+ snodo_dir = Path(project_root) / ".snodo"
23
+ if not snodo_dir.is_dir():
24
+ print("Error: Not a snodo project (no .snodo/ directory)", file=sys.stderr)
25
+ print("Run 'snodo init' first.", file=sys.stderr)
26
+ return 1
27
+
28
+ try:
29
+ from snodo.dashboard.app import run_dashboard
30
+ run_dashboard(project_root=project_root)
31
+ return 0
32
+ except ImportError as e:
33
+ print(f"Error: Dashboard requires 'textual' package: {e}", file=sys.stderr)
34
+ print("Install with: pip install textual", file=sys.stderr)
35
+ return 1
36
+ except Exception as e:
37
+ print(f"Error: Dashboard failed: {e}", file=sys.stderr)
38
+ return 1
@@ -0,0 +1,156 @@
1
+ """Init command - Initialize Snodo project structure.
2
+
3
+ FILE: snodo/cli/commands/init_cmd.py
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ from snodo.cli.commands import PROTOCOL_TEMPLATES
12
+ from snodo.infrastructure.state import ProjectState, write_state
13
+
14
+
15
+ def _select_template(args) -> str:
16
+ """Select protocol template from flag or interactive prompt.
17
+
18
+ Returns:
19
+ The selected template YAML string.
20
+ """
21
+ template_name = getattr(args, "template", None)
22
+
23
+ if template_name:
24
+ return PROTOCOL_TEMPLATES[template_name]
25
+
26
+ # Interactive prompt
27
+ print("Choose protocol template:")
28
+ print(" 1. solo - Single developer (producer merges directly)")
29
+ print(" 2. team - Team workflow (producer + reviewer + planner)")
30
+ print(" 3. 2+n - Paper reference config (producer + reviewer)")
31
+ choice = input("Select [1/2/3]: ").strip()
32
+
33
+ if choice == "1":
34
+ return PROTOCOL_TEMPLATES["solo"]
35
+ elif choice == "2":
36
+ return PROTOCOL_TEMPLATES["team"]
37
+ elif choice == "3":
38
+ return PROTOCOL_TEMPLATES["2+n"]
39
+ else:
40
+ print(f"Invalid choice: {choice!r}. Using team template.", file=sys.stderr)
41
+ return PROTOCOL_TEMPLATES["team"]
42
+
43
+
44
+ def _pick_mode(args, modes: list, default_mode: str) -> str:
45
+ """Interactive mode picker. Returns selected mode_id.
46
+
47
+ Skips picker when:
48
+ - --mode <m> passed (validated against available modes)
49
+ - Not a TTY (piped / CI — keep default silently)
50
+ """
51
+ cli_mode = getattr(args, "mode", None)
52
+
53
+ # Build mode_id -> info lookup
54
+ mode_info: dict = {}
55
+ for m in modes:
56
+ mid = m.get("mode_id", "")
57
+ name = m.get("name", mid)
58
+ tools = m.get("tools", [])
59
+ mode_info[mid] = {"name": name, "tools": tools}
60
+
61
+ if cli_mode:
62
+ if cli_mode not in mode_info:
63
+ available = ", ".join(sorted(mode_info.keys()))
64
+ print(
65
+ f"Error: Mode '{cli_mode}' not in protocol. "
66
+ f"Available: {available}",
67
+ file=sys.stderr,
68
+ )
69
+ raise SystemExit(1)
70
+ return cli_mode
71
+
72
+ # Non-TTY → keep default silently (CI / piped)
73
+ if not sys.stdin.isatty():
74
+ return default_mode
75
+
76
+ # Single-mode protocol → no choice needed
77
+ if len(mode_info) <= 1:
78
+ return default_mode
79
+
80
+ # Interactive picker
81
+ print()
82
+ print("Select your starting mode:")
83
+ mode_ids = sorted(mode_info.keys())
84
+ default_idx = mode_ids.index(default_mode) if default_mode in mode_ids else 0
85
+
86
+ for i, mid in enumerate(mode_ids):
87
+ info = mode_info[mid]
88
+ tools_str = ", ".join(info["tools"]) if info["tools"] else "none"
89
+ marker = " [default]" if mid == default_mode else ""
90
+ print(f" {i + 1}. {info['name']} ({mid}) tools: {tools_str}{marker}")
91
+
92
+ try:
93
+ choice = input(f"Select [1-{len(mode_ids)}, default={default_idx + 1}]: ").strip()
94
+ if not choice:
95
+ return default_mode
96
+ idx = int(choice) - 1
97
+ if 0 <= idx < len(mode_ids):
98
+ return mode_ids[idx]
99
+ except (ValueError, KeyboardInterrupt):
100
+ pass
101
+
102
+ print(f"Using default: {default_mode}")
103
+ return default_mode
104
+
105
+
106
+ def init_command(args) -> int:
107
+ """Initialize Snodo project structure."""
108
+ snodo_dir = Path(".snodo")
109
+
110
+ if snodo_dir.exists():
111
+ if not args.force:
112
+ print("Error: .snodo/ already exists. Use --force to overwrite.", file=sys.stderr)
113
+ return 1
114
+ print("Warning: Overwriting existing .snodo/ directory")
115
+
116
+ try:
117
+ snodo_dir.mkdir(exist_ok=True)
118
+ print(f"Created {snodo_dir}/")
119
+ except Exception as e:
120
+ print(f"Error: Failed to create .snodo/ directory: {e}", file=sys.stderr)
121
+ return 1
122
+
123
+ # Select template
124
+ template = _select_template(args)
125
+
126
+ protocol_file = snodo_dir / "protocol.yml"
127
+ try:
128
+ protocol_file.write_text(template + "\n")
129
+ print(f"Created {protocol_file}")
130
+ except Exception as e:
131
+ print(f"Error: Failed to create protocol.yml: {e}", file=sys.stderr)
132
+ return 1
133
+
134
+ # Write .snodo/state.json — set current_mode from protocol.initial_mode
135
+ # Ctrl-C safe: this write IS the state; no subsequent prompt can kill it.
136
+ try:
137
+ data = yaml.safe_load(template)
138
+ initial_mode = data.get("initial_mode", "")
139
+ modes = data.get("modes", [])
140
+ if initial_mode:
141
+ write_state(".", ProjectState(current_mode=initial_mode))
142
+
143
+ # Interactive mode picker (or --mode flag skips it)
144
+ selected_mode = _pick_mode(args, modes, initial_mode)
145
+ if selected_mode and selected_mode != initial_mode:
146
+ write_state(".", ProjectState(current_mode=selected_mode))
147
+ print(f"Active mode: {selected_mode or initial_mode}")
148
+ except Exception as e:
149
+ print(f"Warning: Could not write state.json: {e}", file=sys.stderr)
150
+
151
+ print("\nSnodo initialized successfully!")
152
+ print("\nNext steps:")
153
+ print(" 1. Edit .snodo/protocol.yml to customize your protocol")
154
+ print(" 2. Run: snodo run \"your task description\"")
155
+
156
+ return 0