deviatdd 2.5.1__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 (124) hide show
  1. deviatdd-2.5.1.dist-info/METADATA +386 -0
  2. deviatdd-2.5.1.dist-info/RECORD +124 -0
  3. deviatdd-2.5.1.dist-info/WHEEL +4 -0
  4. deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
  5. deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
  6. deviate/__init__.py +3 -0
  7. deviate/cli/__init__.py +824 -0
  8. deviate/cli/_common.py +155 -0
  9. deviate/cli/adhoc.py +183 -0
  10. deviate/cli/constitution.py +113 -0
  11. deviate/cli/feature.py +94 -0
  12. deviate/cli/init.py +485 -0
  13. deviate/cli/inspect.py +208 -0
  14. deviate/cli/macro.py +937 -0
  15. deviate/cli/meso.py +1894 -0
  16. deviate/cli/micro.py +3249 -0
  17. deviate/cli/review.py +441 -0
  18. deviate/core/__init__.py +0 -0
  19. deviate/core/_shared.py +20 -0
  20. deviate/core/agent.py +505 -0
  21. deviate/core/cache_discipline.py +65 -0
  22. deviate/core/commands.py +202 -0
  23. deviate/core/commit.py +86 -0
  24. deviate/core/complexity.py +36 -0
  25. deviate/core/constitution.py +85 -0
  26. deviate/core/contract.py +17 -0
  27. deviate/core/convention.py +147 -0
  28. deviate/core/epic.py +73 -0
  29. deviate/core/issues.py +46 -0
  30. deviate/core/prd.py +18 -0
  31. deviate/core/profile.py +33 -0
  32. deviate/core/repo.py +47 -0
  33. deviate/core/run_logger.py +50 -0
  34. deviate/core/tasks_ledger.py +69 -0
  35. deviate/core/treesitter/__init__.py +31 -0
  36. deviate/core/treesitter/analysis.py +457 -0
  37. deviate/core/treesitter/models.py +35 -0
  38. deviate/core/treesitter/parser.py +184 -0
  39. deviate/core/treesitter/queries/bash.scm +7 -0
  40. deviate/core/treesitter/queries/c_sharp.scm +11 -0
  41. deviate/core/treesitter/queries/cpp.scm +9 -0
  42. deviate/core/treesitter/queries/css.scm +4 -0
  43. deviate/core/treesitter/queries/dockerfile.scm +8 -0
  44. deviate/core/treesitter/queries/elixir.scm +6 -0
  45. deviate/core/treesitter/queries/go.scm +9 -0
  46. deviate/core/treesitter/queries/hcl.scm +3 -0
  47. deviate/core/treesitter/queries/html.scm +3 -0
  48. deviate/core/treesitter/queries/javascript.scm +12 -0
  49. deviate/core/treesitter/queries/json.scm +4 -0
  50. deviate/core/treesitter/queries/kotlin.scm +8 -0
  51. deviate/core/treesitter/queries/markdown.scm +4 -0
  52. deviate/core/treesitter/queries/python.scm +10 -0
  53. deviate/core/treesitter/queries/rust.scm +12 -0
  54. deviate/core/treesitter/queries/sql.scm +7 -0
  55. deviate/core/treesitter/queries/swift.scm +10 -0
  56. deviate/core/treesitter/queries/toml.scm +3 -0
  57. deviate/core/treesitter/queries/tsx.scm +14 -0
  58. deviate/core/treesitter/queries/typescript.scm +14 -0
  59. deviate/core/treesitter/queries/yaml.scm +4 -0
  60. deviate/core/validation.py +153 -0
  61. deviate/core/worktree.py +141 -0
  62. deviate/main.py +4 -0
  63. deviate/prompts/__init__.py +0 -0
  64. deviate/prompts/assembly.py +122 -0
  65. deviate/prompts/auto/__init__.py +1 -0
  66. deviate/prompts/auto/execute.md +62 -0
  67. deviate/prompts/auto/explore.md +103 -0
  68. deviate/prompts/auto/green.md +137 -0
  69. deviate/prompts/auto/judge.md +260 -0
  70. deviate/prompts/auto/plan.md +127 -0
  71. deviate/prompts/auto/prd.md +108 -0
  72. deviate/prompts/auto/red.md +156 -0
  73. deviate/prompts/auto/refactor.md +166 -0
  74. deviate/prompts/auto/research.md +111 -0
  75. deviate/prompts/auto/shard.md +90 -0
  76. deviate/prompts/auto/specify.md +77 -0
  77. deviate/prompts/auto/tasks.md +191 -0
  78. deviate/prompts/commands/deviate-adhoc.md +216 -0
  79. deviate/prompts/commands/deviate-architecture.md +147 -0
  80. deviate/prompts/commands/deviate-constitution.md +215 -0
  81. deviate/prompts/commands/deviate-e2e.md +264 -0
  82. deviate/prompts/commands/deviate-execute.md +223 -0
  83. deviate/prompts/commands/deviate-explore.md +253 -0
  84. deviate/prompts/commands/deviate-flows.md +226 -0
  85. deviate/prompts/commands/deviate-green.md +217 -0
  86. deviate/prompts/commands/deviate-hotfix.md +188 -0
  87. deviate/prompts/commands/deviate-init.md +170 -0
  88. deviate/prompts/commands/deviate-judge.md +193 -0
  89. deviate/prompts/commands/deviate-merge.md +221 -0
  90. deviate/prompts/commands/deviate-plan.md +158 -0
  91. deviate/prompts/commands/deviate-pr.md +144 -0
  92. deviate/prompts/commands/deviate-prd.md +216 -0
  93. deviate/prompts/commands/deviate-prune.md +260 -0
  94. deviate/prompts/commands/deviate-red.md +231 -0
  95. deviate/prompts/commands/deviate-refactor.md +211 -0
  96. deviate/prompts/commands/deviate-release.md +123 -0
  97. deviate/prompts/commands/deviate-research.md +359 -0
  98. deviate/prompts/commands/deviate-review.md +289 -0
  99. deviate/prompts/commands/deviate-shard.md +226 -0
  100. deviate/prompts/commands/deviate-tasks.md +281 -0
  101. deviate/prompts/commands/deviate-triage.md +141 -0
  102. deviate/prompts/constitution_seed.md +51 -0
  103. deviate/prompts/core/core.md +21 -0
  104. deviate/prompts/core/macro-auto.md +59 -0
  105. deviate/prompts/core/macro-command.md +54 -0
  106. deviate/prompts/core/macro-skill.md +54 -0
  107. deviate/prompts/core/meso-auto.md +63 -0
  108. deviate/prompts/core/meso-command.md +58 -0
  109. deviate/prompts/core/meso-skill.md +58 -0
  110. deviate/prompts/core/micro-auto.md +76 -0
  111. deviate/prompts/core/micro-command.md +67 -0
  112. deviate/prompts/core/micro-skill.md +67 -0
  113. deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
  114. deviate/prompts/governance/__init__.py +0 -0
  115. deviate/prompts/governance/agents_seed.md +1 -0
  116. deviate/prompts/governance/claudemd_seed.md +1 -0
  117. deviate/prompts/governance/graphite_seed.md +18 -0
  118. deviate/prompts/governance/libref_seed.md +3 -0
  119. deviate/state/__init__.py +0 -0
  120. deviate/state/config.py +257 -0
  121. deviate/state/ledger.py +407 -0
  122. deviate/ui/__init__.py +5 -0
  123. deviate/ui/monitor.py +187 -0
  124. deviate/ui/render.py +26 -0
deviate/core/agent.py ADDED
@@ -0,0 +1,505 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import Any, Literal
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, ValidationError
13
+
14
+ from deviate.state.config import AgentConfig
15
+
16
+ OutputCallback = Callable[[str], None]
17
+
18
+ BackendName = Literal["opencode", "claude", "droid", "pi", "omp", "stub"]
19
+
20
+
21
+ class HandoverManifest(BaseModel):
22
+ phase: str
23
+ status: str
24
+ task_id: str | None = None
25
+ test_file: str | None = None
26
+ verification_command: str | None = None
27
+ expected_failure_node: str | None = None
28
+ rationale: str | None = None
29
+ next_phase: str | None = None
30
+
31
+ model_config = {"extra": "allow"}
32
+
33
+
34
+ class AgentTimeoutError(Exception):
35
+ def __init__(
36
+ self, message: str, partial_stdout: str = "", partial_stderr: str = ""
37
+ ):
38
+ self.partial_stdout = partial_stdout
39
+ self.partial_stderr = partial_stderr
40
+ super().__init__(message)
41
+
42
+
43
+ class AgentSubprocessError(Exception):
44
+ def __init__(self, message: str, exit_code: int = 1) -> None:
45
+ self.exit_code = exit_code
46
+ super().__init__(message)
47
+
48
+
49
+ class MalformedHandoverManifestError(Exception):
50
+ pass
51
+
52
+
53
+ class AgentBinaryNotFoundError(Exception):
54
+ pass
55
+
56
+
57
+ class EmptyOutputError(Exception):
58
+ pass
59
+
60
+
61
+ BACKEND_COMMANDS: dict[str, str] = {
62
+ "opencode": "opencode run",
63
+ "claude": "claude -p --permission-mode auto",
64
+ "droid": "droid exec",
65
+ "pi": "pi -p",
66
+ # Oh-My-Pi: a distinct CLI binary that wraps Pi internally but is
67
+ # invoked directly (``omp -p``). The dispatch layer treats ``omp``
68
+ # as a first-class backend, not an alias for ``pi`` — model flag,
69
+ # timeout, and YAML manifest extraction all apply.
70
+ "omp": "omp -p",
71
+ "stub": "stub",
72
+ }
73
+
74
+ # Map a user-facing agent name (CLI ``--agent`` value, or ``[agent].backend``
75
+ # in ``.deviate/config.toml``) to the canonical backend that the dispatch
76
+ # layer invokes. Only ``factory`` remains as an alias — the Factory Droid
77
+ # IDE drives the ``droid`` binary under the hood. All other names are
78
+ # canonical: ``omp`` is its own backend (not an alias for ``pi``); the
79
+ # remaining names already match the dispatch-layer identifier.
80
+ AGENT_TO_BACKEND: dict[str, str] = {
81
+ "factory": "droid",
82
+ "droid": "droid",
83
+ "claude": "claude",
84
+ "opencode": "opencode",
85
+ "pi": "pi",
86
+ "omp": "omp",
87
+ }
88
+
89
+
90
+ def resolve_agent_to_backend(agent: str) -> str:
91
+ """Return the canonical backend for *agent*.
92
+
93
+ User-facing aliases (``factory``) are mapped to their underlying
94
+ backend binary. Already-canonical names (``opencode``, ``claude``,
95
+ ``droid``, ``pi``, ``omp``) pass through unchanged. Unknown values
96
+ are returned unchanged so the caller can surface a validation error
97
+ against :class:`~deviate.state.config.AgentConfig`'s ``backend``
98
+ Literal.
99
+ """
100
+ return AGENT_TO_BACKEND.get(agent, agent)
101
+
102
+
103
+ PI_RPC_COMMAND: list[str] = ["pi", "--mode", "rpc", "--no-session"]
104
+
105
+
106
+ # Per-backend model-flag dispatch. ``None`` means the backend does not
107
+ # accept ``--model`` on the CLI (model routing is the operator's
108
+ # responsibility — claude ignores model config entirely).
109
+ # ``["--model"]`` means the backend accepts the ``--model <id>`` flag
110
+ # (``opencode``, ``droid``, ``pi``, and ``omp`` all do).
111
+ MODEL_FLAGS: dict[str, list[str] | None] = {
112
+ "pi": ["--model"],
113
+ "claude": None,
114
+ "opencode": ["--model"],
115
+ "droid": ["--model"],
116
+ "omp": ["--model"],
117
+ }
118
+
119
+ # Backends whose CLI expects the prompt as a positional argument rather
120
+ # than via stdin. The prompt gets appended as the last element of the
121
+ # command list before spawning the subprocess.
122
+ PROMPT_AS_ARG_BACKENDS: frozenset[str] = frozenset({"omp"})
123
+
124
+
125
+ _YAML_BLOCK_RE = re.compile(r"```(?:yaml)?\s*\n(.*?)```", re.DOTALL)
126
+ _YAML_MAPPING_START_RE = re.compile(r"^[\w_]+:\s", re.MULTILINE)
127
+ _YAML_HANDOVER_MARKER_RE = re.compile(
128
+ r"<handover_manifest>\s*(?:\n```(?:yaml)?\s*\n)?(.*?)(?:\n```\s*)?$",
129
+ re.DOTALL,
130
+ )
131
+
132
+
133
+ def _strip_md_for_yaml(text: str) -> str:
134
+ """Strip markdown artifacts that confuse YAML parsing in bare output."""
135
+ text = re.sub(r"^<handover_manifest>\s*$", "", text, flags=re.MULTILINE)
136
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
137
+ text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"\1", text)
138
+ text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
139
+ return text.strip()
140
+
141
+
142
+ class AgentBackend:
143
+ def __init__(self, config: AgentConfig | None = None) -> None:
144
+ self.config = config or AgentConfig()
145
+
146
+ @staticmethod
147
+ def _extract_yaml_block(text: str) -> str:
148
+ m = _YAML_BLOCK_RE.search(text)
149
+ if m:
150
+ return m.group(1).strip()
151
+
152
+ m = _YAML_HANDOVER_MARKER_RE.search(text)
153
+ if m:
154
+ candidate = m.group(1).strip()
155
+ if candidate:
156
+ return candidate
157
+
158
+ m = _YAML_MAPPING_START_RE.search(text)
159
+ if m:
160
+ return text[m.start() :].strip()
161
+
162
+ cleaned = _strip_md_for_yaml(text)
163
+ if cleaned:
164
+ try:
165
+ yaml.safe_load(cleaned)
166
+ except yaml.YAMLError:
167
+ return ""
168
+ return cleaned
169
+
170
+ return ""
171
+
172
+ @staticmethod
173
+ def _yaml_error_hint(text: str) -> str:
174
+ has_yaml_fence = bool(re.search(r"```\s*yaml", text, re.IGNORECASE))
175
+ has_yaml_content = bool(_YAML_MAPPING_START_RE.search(text))
176
+ has_handover_marker = bool(re.search(r"<handover_manifest>", text))
177
+ if has_handover_marker and not has_yaml_fence:
178
+ return (
179
+ " Found <handover_manifest> tag but could not extract YAML —"
180
+ " ensure the YAML content follows the tag,"
181
+ " optionally inside a ```yaml block."
182
+ )
183
+ if not has_yaml_fence and has_yaml_content:
184
+ return (
185
+ " Expected ```yaml block, found inline YAML —"
186
+ " wrap in ```yaml for reliability, or ensure"
187
+ " no explanatory text precedes the YAML content."
188
+ )
189
+ if not has_yaml_fence:
190
+ return (
191
+ " No YAML handover manifest detected in agent output."
192
+ " The agent must emit a YAML manifest (plain or in a ```yaml block)."
193
+ )
194
+ if re.search(r"(?<!\"):\s+\w", text):
195
+ return " Check that all YAML string values are double-quoted."
196
+ return ""
197
+
198
+ @staticmethod
199
+ def parse_output(
200
+ stdout: str,
201
+ backend_name: str,
202
+ ) -> HandoverManifest:
203
+ if not stdout.strip():
204
+ raise EmptyOutputError(
205
+ f"Agent backend '{backend_name}' returned empty output"
206
+ )
207
+
208
+ yaml_text = AgentBackend._extract_yaml_block(stdout)
209
+
210
+ if not yaml_text:
211
+ hint = AgentBackend._yaml_error_hint(stdout)
212
+ raise MalformedHandoverManifestError(
213
+ f"No YAML handover manifest detected in agent output.{hint}"
214
+ )
215
+
216
+ try:
217
+ data = yaml.safe_load(yaml_text)
218
+ except yaml.YAMLError as e:
219
+ hint = AgentBackend._yaml_error_hint(stdout)
220
+ raise MalformedHandoverManifestError(
221
+ f"Failed to parse YAML handover manifest: {e}{hint}"
222
+ )
223
+
224
+ if not isinstance(data, dict):
225
+ hint = AgentBackend._yaml_error_hint(stdout)
226
+ raise MalformedHandoverManifestError(
227
+ f"YAML handover manifest is not a mapping (got {type(data).__name__})."
228
+ f" The manifest must be a key: value mapping.{hint}"
229
+ )
230
+
231
+ try:
232
+ return HandoverManifest(**data)
233
+ except ValidationError as e:
234
+ raise MalformedHandoverManifestError(
235
+ f"Handover manifest failed schema validation: {e}"
236
+ )
237
+
238
+ def _invoke_blocking(
239
+ self,
240
+ proc: subprocess.Popen[bytes],
241
+ cmd: list[str],
242
+ prompt: str,
243
+ timeout_secs: int,
244
+ backend_name: str,
245
+ ) -> tuple[str, str]:
246
+ try:
247
+ stdout_bytes, stderr_bytes = proc.communicate(
248
+ input=prompt.encode("utf-8"),
249
+ timeout=timeout_secs,
250
+ )
251
+ except subprocess.TimeoutExpired as e:
252
+ proc.kill()
253
+ proc.wait()
254
+ partial_out = e.output.decode("utf-8") if e.output else ""
255
+ partial_err = e.stderr.decode("utf-8") if e.stderr else ""
256
+ raise AgentTimeoutError(
257
+ f"Agent backend '{backend_name}' timed out "
258
+ f"after {timeout_secs}s"
259
+ f" (retried once with 30s backoff)",
260
+ partial_stdout=partial_out,
261
+ partial_stderr=partial_err,
262
+ )
263
+ stdout = stdout_bytes.decode("utf-8") if stdout_bytes else ""
264
+ stderr = stderr_bytes.decode("utf-8") if stderr_bytes else ""
265
+ if proc.returncode != 0:
266
+ raise AgentSubprocessError(
267
+ message=stderr or f"Agent exited with code {proc.returncode}",
268
+ exit_code=proc.returncode,
269
+ )
270
+ return stdout, stderr
271
+
272
+ def _invoke_streaming(
273
+ self,
274
+ proc: subprocess.Popen[bytes],
275
+ cmd: list[str],
276
+ prompt: str,
277
+ timeout_secs: int,
278
+ backend_name: str,
279
+ output_callback: OutputCallback,
280
+ ) -> tuple[str, str]:
281
+ proc.stdin.write(prompt.encode("utf-8"))
282
+ proc.stdin.close()
283
+
284
+ stdout_lines: list[str] = []
285
+ stderr_lines: list[str] = []
286
+ stdout_done = False
287
+
288
+ def read_stdout() -> None:
289
+ nonlocal stdout_done
290
+ for raw_line in proc.stdout:
291
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
292
+ stdout_lines.append(line)
293
+ output_callback(line)
294
+ stdout_done = True
295
+
296
+ def read_stderr() -> None:
297
+ for raw_line in proc.stderr:
298
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
299
+ stderr_lines.append(line)
300
+
301
+ threads = [
302
+ threading.Thread(target=read_stdout),
303
+ threading.Thread(target=read_stderr),
304
+ ]
305
+ for t in threads:
306
+ t.start()
307
+ for t in threads:
308
+ t.join(timeout=timeout_secs)
309
+
310
+ if not stdout_done or any(t.is_alive() for t in threads):
311
+ proc.kill()
312
+ for t in threads:
313
+ t.join(timeout=5)
314
+ raise AgentTimeoutError(
315
+ f"Agent backend '{backend_name}' timed out after {timeout_secs}s",
316
+ partial_stdout="\n".join(stdout_lines),
317
+ partial_stderr="\n".join(stderr_lines),
318
+ )
319
+
320
+ proc.wait()
321
+ stdout = "\n".join(stdout_lines)
322
+ stderr = "\n".join(stderr_lines)
323
+
324
+ if proc.returncode != 0:
325
+ raise AgentSubprocessError(
326
+ message=stderr or f"Agent exited with code {proc.returncode}",
327
+ exit_code=proc.returncode,
328
+ )
329
+ return stdout, stderr
330
+
331
+ def _invoke_rpc_blocking(
332
+ self,
333
+ proc: subprocess.Popen[bytes],
334
+ cmd: list[str],
335
+ prompt: str,
336
+ timeout_secs: int,
337
+ backend_name: str,
338
+ ) -> tuple[str, str]:
339
+ payload = (json.dumps({"type": "prompt", "content": prompt}) + "\n").encode(
340
+ "utf-8"
341
+ )
342
+ try:
343
+ stdout_bytes, stderr_bytes = proc.communicate(
344
+ input=payload, timeout=timeout_secs
345
+ )
346
+ except subprocess.TimeoutExpired as e:
347
+ proc.kill()
348
+ proc.wait()
349
+ partial_out = e.output.decode("utf-8") if e.output else ""
350
+ partial_err = e.stderr.decode("utf-8") if e.stderr else ""
351
+ raise AgentTimeoutError(
352
+ f"Agent backend '{backend_name}' timed out after {timeout_secs}s",
353
+ partial_stdout=partial_out,
354
+ partial_stderr=partial_err,
355
+ )
356
+ stdout = stdout_bytes.decode("utf-8") if stdout_bytes else ""
357
+ stderr = stderr_bytes.decode("utf-8") if stderr_bytes else ""
358
+ if proc.returncode != 0:
359
+ raise AgentSubprocessError(
360
+ message=stderr or f"Agent exited with code {proc.returncode}",
361
+ exit_code=proc.returncode,
362
+ )
363
+ manifest_text = ""
364
+ for raw_line in stdout.splitlines():
365
+ line = raw_line.strip()
366
+ if not line:
367
+ continue
368
+ try:
369
+ event = json.loads(line)
370
+ except json.JSONDecodeError:
371
+ continue
372
+ if not isinstance(event, dict):
373
+ continue
374
+ if event.get("type") != "agent_end":
375
+ continue
376
+ message = event.get("message")
377
+ if not isinstance(message, dict):
378
+ continue
379
+ content = message.get("content", "")
380
+ if isinstance(content, str):
381
+ manifest_text = content
382
+ break
383
+ return manifest_text, stderr
384
+
385
+ def invoke(
386
+ self,
387
+ prompt: str,
388
+ backend: BackendName | None = None,
389
+ timeout: int | None = None,
390
+ output_callback: OutputCallback | None = None,
391
+ cwd: str | None = None,
392
+ model: str | None = None,
393
+ ) -> HandoverManifest:
394
+ backend_name: BackendName = backend or self.config.backend
395
+ use_rpc = backend_name == "pi" and self.config.pi_rpc
396
+
397
+ if use_rpc:
398
+ cmd = list(PI_RPC_COMMAND)
399
+ else:
400
+ backend_cmd = BACKEND_COMMANDS.get(backend_name)
401
+ if backend_cmd is None:
402
+ raise AgentBinaryNotFoundError(f"Unknown backend: {backend_name}")
403
+
404
+ cmd = backend_cmd.split()
405
+ model_flag = MODEL_FLAGS.get(backend_name, ["--model"])
406
+ if model is not None and model_flag is not None:
407
+ cmd.extend([model_flag[0], model])
408
+ # Backends that expect the prompt as a positional CLI argument
409
+ # (e.g. ``omp -p "prompt"``) get the prompt appended to the
410
+ # command. The ``prompt`` variable is then cleared so the
411
+ # subprocess dispatch does not send it via stdin.
412
+ if backend_name in PROMPT_AS_ARG_BACKENDS:
413
+ cmd.append(prompt)
414
+ prompt = ""
415
+ effective_timeout = timeout or self.config.timeout
416
+
417
+ popen_kwargs: dict[str, Any] = dict(
418
+ stdin=subprocess.PIPE,
419
+ stdout=subprocess.PIPE,
420
+ stderr=subprocess.PIPE,
421
+ )
422
+ if cwd is not None:
423
+ popen_kwargs["cwd"] = cwd
424
+
425
+ try:
426
+ proc = subprocess.Popen(cmd, **popen_kwargs)
427
+ except FileNotFoundError:
428
+ raise AgentBinaryNotFoundError(
429
+ f"Agent binary not found on PATH for backend: {backend_name}"
430
+ )
431
+
432
+ try:
433
+ stdout, stderr = self._dispatch_invocation(
434
+ proc,
435
+ cmd,
436
+ prompt,
437
+ effective_timeout,
438
+ backend_name,
439
+ output_callback,
440
+ use_rpc,
441
+ )
442
+ except AgentTimeoutError:
443
+ time.sleep(30)
444
+ retry_proc = subprocess.Popen(cmd, **popen_kwargs)
445
+ stdout, stderr = self._dispatch_invocation(
446
+ retry_proc,
447
+ cmd,
448
+ prompt,
449
+ effective_timeout,
450
+ backend_name,
451
+ output_callback,
452
+ use_rpc,
453
+ )
454
+
455
+ return self.parse_output(stdout, backend_name)
456
+
457
+ def _dispatch_invocation(
458
+ self,
459
+ proc: subprocess.Popen[bytes],
460
+ cmd: list[str],
461
+ prompt: str,
462
+ timeout_secs: int,
463
+ backend_name: str,
464
+ output_callback: OutputCallback | None,
465
+ use_rpc: bool,
466
+ ) -> tuple[str, str]:
467
+ if use_rpc:
468
+ return self._invoke_rpc_blocking(
469
+ proc, cmd, prompt, timeout_secs, backend_name
470
+ )
471
+ if output_callback is not None:
472
+ return self._invoke_streaming(
473
+ proc, cmd, prompt, timeout_secs, backend_name, output_callback
474
+ )
475
+ return self._invoke_blocking(proc, cmd, prompt, timeout_secs, backend_name)
476
+
477
+
478
+ class StubAgentBackend(AgentBackend):
479
+ def __init__(self, config: AgentConfig | None = None) -> None:
480
+ super().__init__(config)
481
+ self._invoked = False
482
+
483
+ def invoke(
484
+ self,
485
+ prompt: str,
486
+ backend: BackendName | None = None,
487
+ timeout: int | None = None,
488
+ output_callback: OutputCallback | None = None,
489
+ cwd: str | None = None,
490
+ model: str | None = None,
491
+ ) -> HandoverManifest:
492
+ self._invoked = True
493
+ if output_callback is not None:
494
+ output_callback(prompt)
495
+ return HandoverManifest(phase="RED", status="success")
496
+
497
+
498
+ class StubPiBackend(StubAgentBackend):
499
+ """Pi-shaped stub backend for downstream test isolation.
500
+
501
+ Marker subclass of :class:`StubAgentBackend` — shares the inherited
502
+ ``invoke()``, ``_invoked`` flag, callable surface, and
503
+ :class:`HandoverManifest` contract. Provides Pi-specific identity for
504
+ downstream fixtures that need to distinguish Pi-stub from generic stub.
505
+ """
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class CacheStore:
8
+ model: str
9
+ tool_definitions: list = field(default_factory=list)
10
+ system_prompt: str = ""
11
+ test_files: dict[str, str] = field(default_factory=dict)
12
+
13
+
14
+ class CacheDisciplineViolation(Exception):
15
+ pass
16
+
17
+
18
+ class CacheDiscipline:
19
+ @staticmethod
20
+ def _check_drift(
21
+ current_val: object,
22
+ previous_val: object,
23
+ label: str,
24
+ message: str,
25
+ ) -> None:
26
+ if current_val != previous_val:
27
+ raise CacheDisciplineViolation(message)
28
+
29
+ @staticmethod
30
+ def validate(
31
+ phase: str,
32
+ current: CacheStore,
33
+ previous: CacheStore | None = None,
34
+ repo_path: str | None = None,
35
+ ) -> None:
36
+ if previous is None:
37
+ return
38
+
39
+ CacheDiscipline._check_drift(
40
+ current.model,
41
+ previous.model,
42
+ "model",
43
+ f"model_switch: {previous.model} -> {current.model}",
44
+ )
45
+
46
+ CacheDiscipline._check_drift(
47
+ current.tool_definitions,
48
+ previous.tool_definitions,
49
+ "tool_definitions",
50
+ "tool_change: tool definitions differ between phases",
51
+ )
52
+
53
+ CacheDiscipline._check_drift(
54
+ current.system_prompt,
55
+ previous.system_prompt,
56
+ "system_prompt",
57
+ "prompt_change: system prompt mutated between phases",
58
+ )
59
+
60
+ CacheDiscipline._check_drift(
61
+ current.test_files,
62
+ previous.test_files,
63
+ "test_files",
64
+ "test_change: test files modified between phases",
65
+ )