snodo-core 0.7.2__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.
snodo/config.py ADDED
@@ -0,0 +1,546 @@
1
+ """Configuration and API key management for Snodo.
2
+
3
+ FILE: snodo/config.py
4
+
5
+ Manages user configuration stored at ~/.snodo/config.yml:
6
+ - API key storage with secure file permissions (600)
7
+ - Model selection defaults
8
+ - Key validation via liteLLM
9
+ """
10
+
11
+ import os
12
+ import stat
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Optional
16
+
17
+ import yaml
18
+ from pydantic import BaseModel, Field
19
+
20
+ from snodo.paths import resolve_home
21
+
22
+
23
+ class ProviderConfig(BaseModel):
24
+ """Provider configuration with API credential env var and /models endpoint."""
25
+ api_key: str = ""
26
+ api_key_env: str = ""
27
+ models_endpoint: str = ""
28
+ account_id: str = ""
29
+ account_id_env: str = ""
30
+ base_url: str = ""
31
+ litellm_provider: str = ""
32
+ extra_headers: Dict[str, str] = Field(default_factory=dict)
33
+ probe_model: str = ""
34
+
35
+
36
+ DEFAULT_PROVIDER_CATALOG: Dict[str, ProviderConfig] = {
37
+ "anthropic": ProviderConfig(
38
+ api_key_env="ANTHROPIC_API_KEY",
39
+ models_endpoint="https://api.anthropic.com/v1/models",
40
+ probe_model="claude-3-haiku-20240307",
41
+ ),
42
+ "openai": ProviderConfig(
43
+ api_key_env="OPENAI_API_KEY",
44
+ models_endpoint="https://api.openai.com/v1/models",
45
+ probe_model="gpt-4o-mini",
46
+ ),
47
+ "openrouter": ProviderConfig(
48
+ api_key_env="OPENROUTER_API_KEY",
49
+ models_endpoint="https://openrouter.ai/api/v1/models",
50
+ probe_model="openai/gpt-4o-mini",
51
+ ),
52
+ "google": ProviderConfig(
53
+ api_key_env="GEMINI_API_KEY",
54
+ models_endpoint="https://generativelanguage.googleapis.com/v1beta/models",
55
+ probe_model="gemini/gemini-2.0-flash",
56
+ ),
57
+ "cloudflare": ProviderConfig(
58
+ api_key_env="CLOUDFLARE_API_KEY",
59
+ account_id_env="CLOUDFLARE_ACCOUNT_ID",
60
+ models_endpoint="https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/models",
61
+ extra_headers={"x-session-affinity": "{task_id}"},
62
+ probe_model="@cf/meta/llama-3.1-8b-instruct",
63
+ ),
64
+ "deepseek": ProviderConfig(
65
+ api_key_env="DEEPSEEK_API_KEY",
66
+ models_endpoint="https://api.deepseek.com/models",
67
+ probe_model="deepseek/deepseek-chat",
68
+ ),
69
+ }
70
+
71
+
72
+ DEFAULT_MODEL = "claude-sonnet-4-20250514"
73
+
74
+
75
+ # Provider-to-model prefix mapping for key resolution
76
+ PROVIDER_MODEL_PREFIXES = {
77
+ "openai": ["gpt-", "o1-", "o3-"],
78
+ "anthropic": ["claude-"],
79
+ "google": ["gemini/", "gemini-"],
80
+ "cloudflare": ["cloudflare/", "openai/@cf/", "@cf/"],
81
+ "deepseek": ["deepseek/"],
82
+ }
83
+
84
+ # Alias map: litellm provider name → snodo config key.
85
+ # litellm.get_llm_provider() returns "gemini" but snodo's config
86
+ # key is "google". Other names pass through unchanged.
87
+ _PROVIDER_ALIASES: dict[str, str] = {
88
+ "gemini": "google",
89
+ }
90
+
91
+
92
+ def _set_api_key_env(mgr: "ConfigManager", model: str) -> None:
93
+ """Set API key in environment if available from config."""
94
+ api_key = mgr.get_key_for_model(model)
95
+ if api_key:
96
+ provider_name = ConfigManager._provider_for_model(model)
97
+ if provider_name:
98
+ providers = mgr.get_providers()
99
+ pc = providers.get(provider_name)
100
+ if pc and pc.api_key_env:
101
+ os.environ[pc.api_key_env] = api_key
102
+ if pc and pc.account_id_env and pc.account_id:
103
+ os.environ[pc.account_id_env] = pc.account_id
104
+ if pc and pc.litellm_provider:
105
+ target_pc = DEFAULT_PROVIDER_CATALOG.get(pc.litellm_provider)
106
+ if target_pc and target_pc.api_key_env:
107
+ os.environ[target_pc.api_key_env] = api_key
108
+
109
+
110
+ @contextmanager
111
+ def provider_env(model: str):
112
+ """Injects provider API keys for model into os.environ."""
113
+ mgr = ConfigManager()
114
+ _set_api_key_env(mgr, model)
115
+ try:
116
+ yield mgr
117
+ finally:
118
+ pass # env vars intentionally left set
119
+
120
+
121
+ class ConfigError(Exception):
122
+ """Configuration error."""
123
+
124
+
125
+ class ConfigManager:
126
+ """Manages Snodo user configuration.
127
+
128
+ Config file: ~/.snodo/config.yml
129
+ Enforces 600 permissions on the config file.
130
+ """
131
+
132
+ def __init__(self, config_dir: Optional[Path] = None):
133
+ """Initialize config manager.
134
+
135
+ Args:
136
+ config_dir: Override config directory (default: ~/.snodo)
137
+ """
138
+ self.config_dir = config_dir or resolve_home()
139
+ self.config_path = self.config_dir / "config.yml"
140
+
141
+ def load(self) -> dict:
142
+ """Load configuration from disk.
143
+
144
+ Returns:
145
+ Configuration dict (empty dict if file doesn't exist)
146
+
147
+ Raises:
148
+ ConfigError: If config.yml uses the legacy ``api_keys`` format
149
+ instead of the ``providers`` section.
150
+ """
151
+ if not self.config_path.exists():
152
+ return self._default_config()
153
+
154
+ try:
155
+ with open(self.config_path) as f:
156
+ data = yaml.safe_load(f) or {}
157
+ except yaml.YAMLError as e:
158
+ raise ConfigError(f"Invalid config file: {e}") from e
159
+
160
+ # Reject legacy api_keys-only configs — providers is the only schema
161
+ if data.get("api_keys") and not data.get("providers"):
162
+ raise ConfigError(
163
+ "Legacy api_keys config detected. "
164
+ "Migrate to the providers section. See docs."
165
+ )
166
+
167
+ # Ensure required keys exist
168
+ data.setdefault("model", DEFAULT_MODEL)
169
+ data.setdefault("engine", {"max_subtask_depth": 3, "max_session_age_days": 30, "token_ttl_seconds": 600})
170
+ return data
171
+
172
+ def _default_config(self) -> dict:
173
+ return {
174
+ "model": DEFAULT_MODEL,
175
+ "engine": {"max_subtask_depth": 3, "max_session_age_days": 30, "token_ttl_seconds": 600},
176
+ "cloud": {
177
+ "api_key": "",
178
+ "api_url": "https://api.snodo.dev",
179
+ "sync_enabled": False,
180
+ },
181
+ "mcp": {
182
+ "port": 55441,
183
+ },
184
+ "opencode": {
185
+ "session_token_warning": 150000,
186
+ "session_reset_on_model_change": False,
187
+ },
188
+ }
189
+
190
+ def get_providers(self) -> Dict[str, ProviderConfig]:
191
+ """Return configured providers, merged with defaults.
192
+
193
+ Config.yml ``providers`` section overrides default catalog entries.
194
+ Unlisted providers get their default values.
195
+ """
196
+ config = self.load()
197
+ providers_raw = config.get("providers", {})
198
+
199
+ result: Dict[str, ProviderConfig] = {}
200
+ if isinstance(providers_raw, dict):
201
+ for name, raw in providers_raw.items():
202
+ if isinstance(raw, dict):
203
+ result[name] = ProviderConfig(**raw)
204
+
205
+ # Merge in defaults for providers not in config
206
+ for name, pc in DEFAULT_PROVIDER_CATALOG.items():
207
+ if name not in result:
208
+ result[name] = pc
209
+ return result
210
+
211
+ @staticmethod
212
+ def resolve_api_base(model: str) -> Optional[str]:
213
+ """Return the API base URL for *model*'s provider, or ``None``.
214
+
215
+ Checks the provider's ``base_url`` in the merged provider config.
216
+ """
217
+ provider = ConfigManager._provider_for_model(model)
218
+ if provider is None:
219
+ return None
220
+ pc = ConfigManager().get_providers().get(provider)
221
+ if pc and pc.base_url:
222
+ return pc.base_url
223
+ return None
224
+
225
+ @staticmethod
226
+ def resolve_litellm_provider(model: str) -> Optional[str]:
227
+ """Return the provider string litellm should route as for *model*."""
228
+ provider_key = ConfigManager._provider_for_model(model)
229
+ if not provider_key:
230
+ return None
231
+ pc = ConfigManager().get_providers().get(provider_key)
232
+ if pc and pc.litellm_provider:
233
+ return pc.litellm_provider
234
+ return provider_key
235
+
236
+ @staticmethod
237
+ def resolve_litellm_model(model: str) -> str:
238
+ """Return the model string formatted for litellm completion.
239
+
240
+ If a provider specifies a litellm_provider (e.g. litellm_provider="openai"
241
+ for provider block "ollama"), formats model as "openai/<model_id>" so litellm
242
+ routes it properly to the OpenAI-compatible endpoint.
243
+ """
244
+ provider_key = ConfigManager._provider_for_model(model)
245
+ if not provider_key:
246
+ return model
247
+ pc = ConfigManager().get_providers().get(provider_key)
248
+ if pc and pc.litellm_provider:
249
+ if model.startswith(f"{provider_key}/"):
250
+ raw_id = model[len(provider_key) + 1:]
251
+ else:
252
+ raw_id = model
253
+ return f"{pc.litellm_provider}/{raw_id}"
254
+ return model
255
+
256
+ @staticmethod
257
+ def resolve_extra_headers(model: str, task_id: Optional[str] = None) -> Optional[Dict[str, str]]:
258
+ """Return extra headers configured for model's provider.
259
+
260
+ Evaluates template variables like ``{task_id}`` in header values.
261
+ """
262
+ provider_key = ConfigManager._provider_for_model(model)
263
+ if not provider_key:
264
+ return None
265
+ pc = ConfigManager().get_providers().get(provider_key)
266
+ if not pc or not pc.extra_headers:
267
+ return None
268
+ tid = task_id or "unknown"
269
+ headers = {}
270
+ for k, v in pc.extra_headers.items():
271
+ headers[k] = v.replace("{task_id}", tid)
272
+ return headers or None
273
+
274
+ @staticmethod
275
+ def _provider_for_model(model: str) -> Optional[str]:
276
+ """Return the provider (snodo config key) for a model string.
277
+
278
+ Checks PROVIDER_MODEL_PREFIXES first, then configured provider block
279
+ names (e.g. "ollama/llama3" → "ollama"), and falls back to litellm's
280
+ get_llm_provider().
281
+ """
282
+ if not model:
283
+ return None
284
+
285
+ # 1. Match against PROVIDER_MODEL_PREFIXES
286
+ for provider, prefixes in PROVIDER_MODEL_PREFIXES.items():
287
+ for prefix in prefixes:
288
+ if model.startswith(prefix):
289
+ return provider
290
+
291
+ # 2. Check if model starts with a configured provider key + "/"
292
+ providers = ConfigManager().get_providers()
293
+ if "/" in model:
294
+ prefix = model.split("/")[0]
295
+ if prefix in providers:
296
+ return prefix
297
+
298
+ # 3. litellm.get_llm_provider()
299
+ try:
300
+ from litellm import get_llm_provider
301
+ _model, provider_name, _, _ = get_llm_provider(model)
302
+ except Exception:
303
+ provider_name = None
304
+
305
+ if provider_name:
306
+ aliased = _PROVIDER_ALIASES.get(provider_name, provider_name)
307
+ if aliased in providers:
308
+ return aliased
309
+ return provider_name
310
+
311
+ return None
312
+
313
+ def save(self, config: dict) -> None:
314
+ """Save configuration to disk with secure permissions.
315
+
316
+ Args:
317
+ config: Configuration dict to save
318
+ """
319
+ self.config_dir.mkdir(parents=True, exist_ok=True)
320
+
321
+ with open(self.config_path, "w") as f:
322
+ yaml.dump(config, f, default_flow_style=False)
323
+
324
+ # Set file permissions to 600 (owner read/write only)
325
+ os.chmod(self.config_path, stat.S_IRUSR | stat.S_IWUSR)
326
+
327
+ def add_key(self, provider: str, key: str) -> None:
328
+ """Store an API key for a provider.
329
+
330
+ Args:
331
+ provider: Provider name (e.g., "openai", "anthropic", "google")
332
+ key: API key string
333
+ """
334
+ if not provider:
335
+ raise ConfigError("Provider name cannot be empty")
336
+ if not key:
337
+ raise ConfigError("API key cannot be empty")
338
+
339
+ config = self.load()
340
+ providers = config.setdefault("providers", {})
341
+ provider_data = providers.setdefault(provider, {})
342
+ provider_data["api_key"] = key
343
+ self.save(config)
344
+
345
+ def get_key(self, provider: str) -> Optional[str]:
346
+ """Get an API key for a provider.
347
+
348
+ Args:
349
+ provider: Provider name
350
+
351
+ Returns:
352
+ API key string, or None if not configured
353
+ """
354
+ pc = self.get_providers().get(provider)
355
+ if pc and pc.api_key:
356
+ return pc.api_key
357
+ return None
358
+
359
+ def remove_key(self, provider: str) -> bool:
360
+ """Remove an API key for a provider.
361
+
362
+ Args:
363
+ provider: Provider name
364
+
365
+ Returns:
366
+ True if key was removed, False if it didn't exist
367
+ """
368
+ config = self.load()
369
+ providers_raw = config.get("providers", {})
370
+ if isinstance(providers_raw, dict):
371
+ entry = providers_raw.get(provider, {})
372
+ if isinstance(entry, dict) and "api_key" in entry:
373
+ del entry["api_key"]
374
+ self.save(config)
375
+ return True
376
+ return False
377
+
378
+ def get_key_for_model(self, model: str) -> Optional[str]:
379
+ """Resolve the API key needed for a given model.
380
+
381
+ Args:
382
+ model: Model identifier (e.g., "claude-sonnet-4-20250514", "gpt-4o")
383
+
384
+ Returns:
385
+ API key string, or None if no matching key found
386
+ """
387
+ provider = self._provider_for_model(model)
388
+ if provider is None:
389
+ return None
390
+ return self.get_key(provider)
391
+
392
+ def set_model(self, model: str) -> None:
393
+ """Set the default model.
394
+
395
+ Args:
396
+ model: Model identifier
397
+ """
398
+ config = self.load()
399
+ config["model"] = model
400
+ self.save(config)
401
+
402
+ def get_model(self) -> str:
403
+ """Get the configured default model.
404
+
405
+ Returns:
406
+ Model identifier
407
+ """
408
+ config = self.load()
409
+ return config.get("default_model") or config.get("model", DEFAULT_MODEL)
410
+
411
+ def get_engine_value(self, key: str, default: Any = None) -> Any:
412
+ """Get an engine configuration value.
413
+
414
+ Args:
415
+ key: Engine config key (e.g., "max_subtask_depth")
416
+ default: Default value if key not found
417
+
418
+ Returns:
419
+ Config value
420
+ """
421
+ config = self.load()
422
+ return config.get("engine", {}).get(key, default)
423
+
424
+ def set_engine_value(self, key: str, value: Any) -> None:
425
+ """Set an engine configuration value.
426
+
427
+ Args:
428
+ key: Engine config key
429
+ value: Value to set
430
+
431
+ Raises:
432
+ ValueError: If value fails validation
433
+ """
434
+ if key == "max_subtask_depth":
435
+ if not isinstance(value, int) or value < 1 or value > 10:
436
+ raise ValueError(f"max_subtask_depth must be an integer between 1 and 10, got {value}")
437
+ elif key == "max_session_age_days":
438
+ if not isinstance(value, int) or value < 1 or value > 365:
439
+ raise ValueError(f"max_session_age_days must be an integer between 1 and 365, got {value}")
440
+ elif key == "token_ttl_seconds":
441
+ if not isinstance(value, int) or value < 60 or value > 86400:
442
+ raise ValueError(f"token_ttl_seconds must be an integer between 60 and 86400, got {value}")
443
+
444
+ config = self.load()
445
+ engine = config.setdefault("engine", {})
446
+ engine[key] = value
447
+ self.save(config)
448
+
449
+ def test_keys(self) -> Dict[str, str]:
450
+ """Test all configured API keys via liteLLM.
451
+
452
+ Returns:
453
+ Dict of provider -> result status ("valid", "invalid", "untestable")
454
+ """
455
+ results = {}
456
+ providers = self.get_providers()
457
+
458
+ for name, pc in providers.items():
459
+ key = self.get_key(name)
460
+ if not key:
461
+ continue
462
+ results[name] = self._test_single_key(name, key, pc)
463
+
464
+ return results
465
+
466
+ def _test_single_key(self, provider: str, key: str, pc: Optional[ProviderConfig] = None) -> str:
467
+ """Test a single API key by making a minimal LLM call.
468
+
469
+ Args:
470
+ provider: Provider name
471
+ key: API key to test
472
+ pc: ProviderConfig with api_key_env and probe_model
473
+
474
+ Returns:
475
+ "valid", "invalid", or "untestable"
476
+ """
477
+ if pc is None:
478
+ pc = self.get_providers().get(provider) or DEFAULT_PROVIDER_CATALOG.get(provider)
479
+
480
+ if pc is None or not pc.probe_model:
481
+ return "untestable"
482
+
483
+ model = pc.probe_model
484
+
485
+ env_var = pc.api_key_env
486
+ if not env_var and not pc.litellm_provider:
487
+ return "untestable"
488
+
489
+ try:
490
+ from litellm import completion
491
+ except (ImportError, Exception):
492
+ return "untestable"
493
+
494
+ target_env_vars = []
495
+ if env_var:
496
+ target_env_vars.append((env_var, key))
497
+ if pc.litellm_provider:
498
+ target_pc = DEFAULT_PROVIDER_CATALOG.get(pc.litellm_provider)
499
+ if target_pc and target_pc.api_key_env:
500
+ target_env_vars.append((target_pc.api_key_env, key))
501
+ if pc.account_id_env and pc.account_id:
502
+ target_env_vars.append((pc.account_id_env, pc.account_id))
503
+
504
+ saved_env = {}
505
+ for ev, val in target_env_vars:
506
+ saved_env[ev] = os.environ.get(ev)
507
+ os.environ[ev] = val
508
+
509
+ try:
510
+ kwargs: dict[str, Any] = {
511
+ "model": ConfigManager.resolve_litellm_model(model),
512
+ "messages": [{"role": "user", "content": "hi"}],
513
+ "max_tokens": 1,
514
+ }
515
+ api_base = ConfigManager.resolve_api_base(model) or pc.base_url
516
+ if api_base:
517
+ kwargs["api_base"] = api_base
518
+ extra_headers = ConfigManager.resolve_extra_headers(model) or (pc.extra_headers if pc.extra_headers else None)
519
+ if extra_headers:
520
+ kwargs["extra_headers"] = extra_headers
521
+
522
+ completion(**kwargs)
523
+ return "valid"
524
+ except Exception:
525
+ return "invalid"
526
+ finally:
527
+ for ev, _ in target_env_vars:
528
+ old = saved_env.get(ev)
529
+ if old is not None:
530
+ os.environ[ev] = old
531
+ elif ev in os.environ:
532
+ del os.environ[ev]
533
+
534
+ @staticmethod
535
+ def mask_key(key: str) -> str:
536
+ """Mask an API key for display, showing only prefix and suffix.
537
+
538
+ Args:
539
+ key: Full API key
540
+
541
+ Returns:
542
+ Masked key (e.g., "sk-ab...xyz")
543
+ """
544
+ if len(key) <= 8:
545
+ return key[:2] + "***"
546
+ return key[:5] + "***" + key[-3:]
snodo/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """"""
@@ -0,0 +1,132 @@
1
+ """Core interfaces for the Snodo protocol engine.
2
+
3
+ All other modules implement against these contracts.
4
+ """
5
+
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any, Dict, List, Literal, Optional
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class AuditError(Exception):
13
+ """Audit log operation failed (e.g., I/O write failure)."""
14
+
15
+
16
+ class ExecutionError(Exception):
17
+ """Task execution produced no usable artifacts."""
18
+
19
+
20
+ class Coder(ABC):
21
+ """Implements tasks. Can be LLM or human or traditional tooling.
22
+
23
+ The engine offers every adapter several optional capabilities (a progress
24
+ sink, a workspace, a job/task id for correlation, and two behavioural
25
+ switches). These are DECLARED here, with defaults, so that "this adapter
26
+ does not support X" is a visible fact rather than a silently skipped
27
+ ``hasattr`` line (docs/architecture/coder-adapter-contract.md §3.1, #68).
28
+ An adapter that does not override a capability inherits the default; the
29
+ engine sets these attributes unconditionally, never behind a guard.
30
+ """
31
+
32
+ #: Workspace the coder reads/writes, injected by the engine when the task
33
+ #: runs under a workspace. None for adapters that do not use one.
34
+ workspace_mcp: Optional[Any] = None
35
+ #: Progress sink handed to the coder by the engine; an adapter that wants
36
+ #: per-turn progress emits here. None = the adapter reports no progress.
37
+ progress_callback: Optional[Any] = None
38
+ #: When True, the coder writes its changes to the working tree directly
39
+ #: and the executor must NOT replay the returned artifacts through
40
+ #: WorkspaceMCP (e.g. in-place adapters). Default False: the executor
41
+ #: writes the artifacts.
42
+ skip_workspace_write: bool = False
43
+ #: When True, the coder (or its base class) owns the commit and the
44
+ #: executor must NOT stage/commit. This does NOT waive the obligation that
45
+ #: produced work be observable and attributable — "coder produced nothing"
46
+ #: is a fault regardless of who commits. Default False: the executor
47
+ #: commits.
48
+ skip_engine_commit: bool = False
49
+ #: Correlation ids the engine injects so adapter-side logging/telemetry
50
+ #: can be attributed to a job and task. Empty when not set.
51
+ _job_id: str = ""
52
+ _task_id: str = ""
53
+ #: Recovery depth and attempt number (1-based) of the task being executed,
54
+ #: injected by the engine so per-turn telemetry can be grouped by depth.
55
+ _depth: int = 0
56
+ _attempt: int = 1
57
+ #: Model identifier the adapter is bound to. Used for default-model
58
+ #: resolution and coder-respawn checks; may be empty on simple adapters.
59
+ model: str = ""
60
+ #: When True, the coder has access to and observes test execution feedback
61
+ #: during implementation (e.g. LiteLLMAdapter with test runner access).
62
+ #: Default False: the coder does not observe tests.
63
+ observes_tests: bool = False
64
+
65
+ @abstractmethod
66
+ def implement(self, spec: 'TaskSpec') -> 'CodeArtifact':
67
+ """Generate code from specification."""
68
+
69
+
70
+ class MCPServer(ABC):
71
+ """Tool boundary enforcement."""
72
+
73
+ @abstractmethod
74
+ def execute_tool(self, tool_name: str, params: Dict[str, Any]) -> Any:
75
+ """Execute tool within capability boundary."""
76
+
77
+
78
+ class Task(BaseModel):
79
+ """A unit of work."""
80
+ id: str
81
+ spec: str
82
+ parent_task_ref: Optional[str] = None
83
+ # The original task at the root of a recovery chain. Recovery subtasks
84
+ # derive their id (``<root>_fix_N``) and their spec (original intent +
85
+ # accumulated failures) from the root, never from the immediately previous
86
+ # attempt — see ADR 021.
87
+ root_task_ref: Optional[str] = None
88
+ root_spec: Optional[str] = None
89
+ prior_failures: List[Dict[str, Any]] = Field(default_factory=list)
90
+ # Recovery provenance: files earlier attempts in the same recovery chain
91
+ # wrote in the cumulative worktree. This is ownership context, not a
92
+ # rewrite request.
93
+ attempt_provenance: List[Dict[str, Any]] = Field(default_factory=list)
94
+ # Recovery read-set: paths earlier attempts inspected (files read,
95
+ # directories listed). Paths only, never contents — the tree changes
96
+ # between attempts and a cached version must not become authoritative.
97
+ attempt_reads: List[Dict[str, Any]] = Field(default_factory=list)
98
+ depth: int = 0
99
+ flow_type: Optional[str] = None
100
+ wave_id: Optional[str] = None
101
+
102
+
103
+ class ValidatorResult(BaseModel):
104
+ """Output from a single validator."""
105
+ validator_id: str
106
+ severity: Literal["pass", "warn", "blocker"]
107
+ justification: str
108
+ error: bool = False
109
+ cited_criteria: Optional[List[str]] = None
110
+ #: Pre-cap severity when a severity_cap downgraded this result; None otherwise.
111
+ severity_original: Optional[str] = None
112
+
113
+
114
+ class TaskSpec(BaseModel):
115
+ """Specification for code generation."""
116
+ description: str
117
+ constraints: List[str]
118
+ memory_summary: str = ""
119
+ project_context: Dict[str, Any] = Field(default_factory=dict)
120
+
121
+
122
+ class FileArtifact(BaseModel):
123
+ """A file operation emitted by the coder."""
124
+ path: str
125
+ content: str = ""
126
+ action: str = "write" # "write" | "delete"
127
+
128
+
129
+ class CodeArtifact(BaseModel):
130
+ """Generated code output — list of file operations."""
131
+ files: List[FileArtifact] = Field(default_factory=list)
132
+ metadata: Dict[str, Any] = Field(default_factory=dict)