devcouncil 0.1.1 → 0.2.1

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 (146) hide show
  1. package/README.md +218 -96
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +7 -11
  4. package/pyproject.toml +0 -34
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -158
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -143
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/cli/__init__.py +0 -0
  23. package/src/devcouncil/cli/commands/__init__.py +0 -0
  24. package/src/devcouncil/cli/commands/artifacts.py +0 -51
  25. package/src/devcouncil/cli/commands/ast.py +0 -22
  26. package/src/devcouncil/cli/commands/baseline.py +0 -35
  27. package/src/devcouncil/cli/commands/config.py +0 -76
  28. package/src/devcouncil/cli/commands/dashboard.py +0 -26
  29. package/src/devcouncil/cli/commands/doctor.py +0 -140
  30. package/src/devcouncil/cli/commands/go.py +0 -237
  31. package/src/devcouncil/cli/commands/hook.py +0 -128
  32. package/src/devcouncil/cli/commands/init.py +0 -153
  33. package/src/devcouncil/cli/commands/integrate.py +0 -726
  34. package/src/devcouncil/cli/commands/lsp.py +0 -20
  35. package/src/devcouncil/cli/commands/map.py +0 -42
  36. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  37. package/src/devcouncil/cli/commands/plan.py +0 -327
  38. package/src/devcouncil/cli/commands/prompt.py +0 -50
  39. package/src/devcouncil/cli/commands/repair.py +0 -72
  40. package/src/devcouncil/cli/commands/report.py +0 -129
  41. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -31
  42. package/src/devcouncil/cli/commands/rollback.py +0 -60
  43. package/src/devcouncil/cli/commands/run.py +0 -269
  44. package/src/devcouncil/cli/commands/setup.py +0 -223
  45. package/src/devcouncil/cli/commands/show.py +0 -76
  46. package/src/devcouncil/cli/commands/status.py +0 -117
  47. package/src/devcouncil/cli/commands/tasks.py +0 -55
  48. package/src/devcouncil/cli/commands/trace.py +0 -44
  49. package/src/devcouncil/cli/commands/verify.py +0 -193
  50. package/src/devcouncil/cli/commands/version.py +0 -20
  51. package/src/devcouncil/cli/commands/watch.py +0 -574
  52. package/src/devcouncil/cli/main.py +0 -88
  53. package/src/devcouncil/council/__init__.py +0 -0
  54. package/src/devcouncil/council/prompts/__init__.py +0 -0
  55. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  56. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  57. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  58. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  59. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  60. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  61. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  62. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  63. package/src/devcouncil/domain/__init__.py +0 -0
  64. package/src/devcouncil/domain/assumption.py +0 -17
  65. package/src/devcouncil/domain/critique.py +0 -32
  66. package/src/devcouncil/domain/evidence.py +0 -27
  67. package/src/devcouncil/domain/gap.py +0 -26
  68. package/src/devcouncil/domain/requirement.py +0 -22
  69. package/src/devcouncil/domain/task.py +0 -26
  70. package/src/devcouncil/execution/__init__.py +0 -1
  71. package/src/devcouncil/execution/context_builder.py +0 -60
  72. package/src/devcouncil/execution/executor.py +0 -15
  73. package/src/devcouncil/execution/hook_policy.py +0 -165
  74. package/src/devcouncil/execution/patch.py +0 -28
  75. package/src/devcouncil/execution/paths.py +0 -14
  76. package/src/devcouncil/execution/permissions.py +0 -92
  77. package/src/devcouncil/execution/prompt_builder.py +0 -59
  78. package/src/devcouncil/execution/task_runner.py +0 -166
  79. package/src/devcouncil/executors/__init__.py +0 -1
  80. package/src/devcouncil/executors/coding_cli.py +0 -112
  81. package/src/devcouncil/executors/mini_swe.py +0 -73
  82. package/src/devcouncil/executors/native/__init__.py +0 -0
  83. package/src/devcouncil/executors/native/agent.py +0 -107
  84. package/src/devcouncil/executors/openhands.py +0 -71
  85. package/src/devcouncil/gating/__init__.py +0 -1
  86. package/src/devcouncil/gating/checks/__init__.py +0 -0
  87. package/src/devcouncil/gating/checks/clean_git.py +0 -50
  88. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  89. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  90. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -34
  91. package/src/devcouncil/gating/policy.py +0 -190
  92. package/src/devcouncil/indexing/__init__.py +0 -1
  93. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  94. package/src/devcouncil/indexing/graph_index.py +0 -48
  95. package/src/devcouncil/indexing/lsp.py +0 -120
  96. package/src/devcouncil/indexing/repo_mapper.py +0 -208
  97. package/src/devcouncil/indexing/symbol_index.py +0 -0
  98. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  99. package/src/devcouncil/integrations/github.py +0 -39
  100. package/src/devcouncil/integrations/gitnexus.py +0 -27
  101. package/src/devcouncil/integrations/graphify.py +0 -34
  102. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  103. package/src/devcouncil/integrations/mcp/server.py +0 -599
  104. package/src/devcouncil/integrations/pr_comments.py +0 -62
  105. package/src/devcouncil/live/__init__.py +0 -2
  106. package/src/devcouncil/live/cards.py +0 -207
  107. package/src/devcouncil/live/models.py +0 -63
  108. package/src/devcouncil/live/repair_prompt.py +0 -83
  109. package/src/devcouncil/live/reviewer.py +0 -70
  110. package/src/devcouncil/live/signals.py +0 -135
  111. package/src/devcouncil/live/summary.py +0 -34
  112. package/src/devcouncil/live/tasks.py +0 -18
  113. package/src/devcouncil/live/transcripts.py +0 -138
  114. package/src/devcouncil/llm/__init__.py +0 -1
  115. package/src/devcouncil/llm/cache.py +0 -38
  116. package/src/devcouncil/llm/provider.py +0 -146
  117. package/src/devcouncil/llm/router.py +0 -125
  118. package/src/devcouncil/planning/__init__.py +0 -1
  119. package/src/devcouncil/planning/arbiter_service.py +0 -57
  120. package/src/devcouncil/planning/critique_service.py +0 -66
  121. package/src/devcouncil/planning/plan_service.py +0 -46
  122. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -86
  123. package/src/devcouncil/planning/repair_service.py +0 -39
  124. package/src/devcouncil/planning/spec_service.py +0 -44
  125. package/src/devcouncil/repo/__init__.py +0 -0
  126. package/src/devcouncil/reporting/__init__.py +0 -0
  127. package/src/devcouncil/reporting/github_check.py +0 -32
  128. package/src/devcouncil/reporting/json_report.py +0 -20
  129. package/src/devcouncil/reporting/markdown_report.py +0 -68
  130. package/src/devcouncil/reporting/report_builder.py +0 -14
  131. package/src/devcouncil/storage/__init__.py +0 -0
  132. package/src/devcouncil/storage/db.py +0 -66
  133. package/src/devcouncil/storage/models.py +0 -83
  134. package/src/devcouncil/storage/repositories.py +0 -423
  135. package/src/devcouncil/telemetry/__init__.py +0 -0
  136. package/src/devcouncil/telemetry/cost.py +0 -34
  137. package/src/devcouncil/telemetry/traces.py +0 -91
  138. package/src/devcouncil/telemetry/tracker.py +0 -49
  139. package/src/devcouncil/ui/__init__.py +0 -1
  140. package/src/devcouncil/ui/dashboard.py +0 -122
  141. package/src/devcouncil/utils/__init__.py +0 -1
  142. package/src/devcouncil/utils/redaction.py +0 -141
  143. package/src/devcouncil/verification/__init__.py +0 -1
  144. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  145. package/src/devcouncil/verification/verifier.py +0 -530
  146. package/uv.lock +0 -1085
@@ -1,138 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from pathlib import Path
5
- from typing import Any, Iterable
6
-
7
- from devcouncil.live.models import AgentSession, AgentTurn, session_id_from_path
8
-
9
-
10
- CLAUDE_TRANSCRIPT_ROOT = Path.home() / ".claude" / "projects"
11
-
12
-
13
- def discover_sessions(project_root: Path, client: str = "claude") -> list[AgentSession]:
14
- """Find local coding-agent transcripts DevCouncil can review."""
15
- client = client.lower()
16
- if client == "claude":
17
- candidates = _claude_transcript_candidates(project_root)
18
- else:
19
- candidates = sorted((project_root / ".devcouncil" / "live" / client).glob("*.jsonl"))
20
-
21
- sessions: list[AgentSession] = []
22
- for path in candidates:
23
- if not path.exists() or not path.is_file():
24
- continue
25
- stat = path.stat()
26
- sessions.append(AgentSession(
27
- id=session_id_from_path(path),
28
- client=client,
29
- transcript_path=str(path),
30
- updated_at=str(stat.st_mtime),
31
- turns=sum(1 for _ in _safe_lines(path)),
32
- ))
33
- return sorted(sessions, key=lambda item: item.updated_at or "", reverse=True)
34
-
35
-
36
- def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
37
- """Parse a transcript into normalized turns.
38
-
39
- Supports Claude Code JSONL plus generic JSONL records with role/content fields.
40
- """
41
- turns: list[AgentTurn] = []
42
- session_id = session_id_from_path(path)
43
- for index, raw in enumerate(_read_jsonl(path)):
44
- turn = _turn_from_record(raw, session_id=session_id, turn_index=index, client=client)
45
- if turn and turn.content.strip():
46
- turns.append(turn)
47
- return turns
48
-
49
-
50
- def latest_assistant_turn(path: Path, client: str = "generic") -> AgentTurn | None:
51
- for turn in reversed(load_turns(path, client=client)):
52
- if turn.role == "assistant":
53
- return turn
54
- return None
55
-
56
-
57
- def _claude_transcript_candidates(project_root: Path) -> list[Path]:
58
- local_runtime = project_root / ".devcouncil" / "live" / "claude"
59
- candidates = list(local_runtime.glob("*.jsonl"))
60
- if CLAUDE_TRANSCRIPT_ROOT.exists():
61
- candidates.extend(CLAUDE_TRANSCRIPT_ROOT.rglob("*.jsonl"))
62
- return sorted(set(candidates), key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True)
63
-
64
-
65
- def _safe_lines(path: Path) -> Iterable[str]:
66
- try:
67
- return path.read_text(encoding="utf-8", errors="replace").splitlines()
68
- except OSError:
69
- return []
70
-
71
-
72
- def _read_jsonl(path: Path) -> Iterable[dict[str, Any]]:
73
- for line in _safe_lines(path):
74
- if not line.strip():
75
- continue
76
- try:
77
- value = json.loads(line)
78
- except json.JSONDecodeError:
79
- continue
80
- if isinstance(value, dict):
81
- yield value
82
-
83
-
84
- def _turn_from_record(raw: dict[str, Any], session_id: str, turn_index: int, client: str) -> AgentTurn | None:
85
- role = _role(raw)
86
- content = _content(raw)
87
- if not content:
88
- return None
89
- turn_id = str(raw.get("uuid") or raw.get("id") or raw.get("message_id") or f"turn-{turn_index}")
90
- return AgentTurn(
91
- session_id=str(raw.get("sessionId") or raw.get("session_id") or session_id),
92
- turn_id=turn_id,
93
- source=client,
94
- role=role,
95
- content=content,
96
- timestamp=raw.get("timestamp") or raw.get("created_at"),
97
- raw=raw,
98
- )
99
-
100
-
101
- def _role(raw: dict[str, Any]) -> str:
102
- role = raw.get("role")
103
- if isinstance(role, str):
104
- return role if role in {"user", "assistant", "system", "tool"} else "unknown"
105
- message = raw.get("message")
106
- if isinstance(message, dict):
107
- nested = message.get("role")
108
- if isinstance(nested, str):
109
- return nested if nested in {"user", "assistant", "system", "tool"} else "unknown"
110
- record_type = raw.get("type")
111
- if record_type in {"user", "assistant", "system"}:
112
- return str(record_type)
113
- return "unknown"
114
-
115
-
116
- def _content(raw: dict[str, Any]) -> str:
117
- direct = raw.get("content") or raw.get("text")
118
- if isinstance(direct, str):
119
- return direct
120
- message = raw.get("message")
121
- if isinstance(message, dict):
122
- nested = message.get("content")
123
- if isinstance(nested, str):
124
- return nested
125
- if isinstance(nested, list):
126
- return "\n".join(_content_block_text(block) for block in nested).strip()
127
- if isinstance(direct, list):
128
- return "\n".join(_content_block_text(block) for block in direct).strip()
129
- return ""
130
-
131
-
132
- def _content_block_text(block: Any) -> str:
133
- if isinstance(block, str):
134
- return block
135
- if isinstance(block, dict):
136
- value = block.get("text") or block.get("content")
137
- return value if isinstance(value, str) else ""
138
- return ""
@@ -1 +0,0 @@
1
-
@@ -1,38 +0,0 @@
1
- import json
2
- import hashlib
3
- from pathlib import Path
4
- from typing import Optional
5
- from devcouncil.llm.provider import LLMResponse
6
-
7
- class LLMCache:
8
- def __init__(self, project_root: Path):
9
- self.cache_dir = project_root / ".devcouncil" / "cache" / "llm"
10
- self.cache_dir.mkdir(parents=True, exist_ok=True)
11
-
12
- def _get_key(self, model: str, messages: list, temp: float, json_mode: bool) -> str:
13
- data = {
14
- "model": model,
15
- "messages": messages,
16
- "temp": temp,
17
- "json_mode": json_mode
18
- }
19
- s = json.dumps(data, sort_keys=True)
20
- return hashlib.sha256(s.encode("utf-8")).hexdigest()
21
-
22
- def get(self, model: str, messages: list, temp: float, json_mode: bool) -> Optional[LLMResponse]:
23
- key = self._get_key(model, messages, temp, json_mode)
24
- cache_file = self.cache_dir / f"{key}.json"
25
- if cache_file.exists():
26
- try:
27
- with open(cache_file, "r") as f:
28
- data = json.load(f)
29
- return LLMResponse(**data)
30
- except Exception:
31
- pass
32
- return None
33
-
34
- def set(self, model: str, messages: list, temp: float, json_mode: bool, response: LLMResponse):
35
- key = self._get_key(model, messages, temp, json_mode)
36
- cache_file = self.cache_dir / f"{key}.json"
37
- with open(cache_file, "w") as f:
38
- json.dump(response.model_dump(), f)
@@ -1,146 +0,0 @@
1
- from abc import ABC, abstractmethod
2
- import copy
3
- from typing import List, Dict, Any, Optional
4
- from pydantic import BaseModel
5
- import httpx
6
- import json
7
- from pathlib import Path
8
-
9
- SUPPORTED_MODEL_PROVIDERS = ("openrouter",)
10
-
11
-
12
- class LLMResponse(BaseModel):
13
- content: str
14
- model: str
15
- usage: Dict[str, int]
16
- raw_response: Dict[str, Any]
17
-
18
- class Provider(ABC):
19
- @abstractmethod
20
- async def complete(
21
- self,
22
- model: str,
23
- messages: List[Dict[str, str]],
24
- temperature: float = 0.0,
25
- json_mode: bool = False
26
- ) -> LLMResponse:
27
- pass
28
-
29
-
30
- def validate_model_provider(provider_name: str) -> str:
31
- normalized = provider_name.strip().lower()
32
- if normalized in SUPPORTED_MODEL_PROVIDERS:
33
- return normalized
34
- supported = ", ".join(SUPPORTED_MODEL_PROVIDERS)
35
- raise ValueError(
36
- f"Unsupported model provider '{provider_name}'. "
37
- f"Supported providers: {supported}."
38
- )
39
-
40
-
41
- def create_provider(provider_name: str, api_key: str) -> Provider:
42
- normalized = validate_model_provider(provider_name)
43
- if normalized == "openrouter":
44
- return OpenRouterProvider(api_key)
45
- raise AssertionError(f"Provider validation passed for unhandled provider: {normalized}")
46
-
47
- class OpenRouterProvider(Provider):
48
- def __init__(self, api_key: str):
49
- self.api_key = api_key
50
- self.base_url = "https://openrouter.ai/api/v1"
51
-
52
- async def complete(
53
- self,
54
- model: str,
55
- messages: List[Dict[str, str]],
56
- temperature: float = 0.0,
57
- json_mode: bool = False
58
- ) -> LLMResponse:
59
- # Deep-copy to avoid mutating the caller's messages list
60
- msgs = copy.deepcopy(messages)
61
-
62
- headers = {
63
- "Authorization": f"Bearer {self.api_key}",
64
- "Content-Type": "application/json",
65
- "HTTP-Referer": "https://github.com/devcouncil/devcouncil", # Optional
66
- "X-Title": "DevCouncil", # Optional
67
- }
68
-
69
- payload = {
70
- "model": model,
71
- "messages": msgs,
72
- "temperature": temperature,
73
- }
74
-
75
- if json_mode:
76
- payload["response_format"] = {"type": "json_object"}
77
- # Ensure the user message mentions JSON
78
- if msgs[-1]["role"] == "user":
79
- msgs[-1]["content"] += "\n\nOutput must be a valid JSON object."
80
-
81
- async with httpx.AsyncClient(timeout=180.0) as client:
82
- response = await client.post(
83
- f"{self.base_url}/chat/completions",
84
- headers=headers,
85
- json=payload
86
- )
87
- response.raise_for_status()
88
- data = response.json()
89
-
90
- resp = LLMResponse(
91
- content=data["choices"][0]["message"]["content"],
92
- model=data["model"],
93
- usage=data.get("usage", {}),
94
- raw_response=data
95
- )
96
-
97
- # Log the call
98
- try:
99
- from devcouncil.utils.redaction import redact_dict
100
- log_dir = Path(".devcouncil/logs")
101
- log_dir.mkdir(parents=True, exist_ok=True)
102
- log_file = log_dir / "model_calls.jsonl"
103
-
104
- # Create a redacted copy of both request and response for logging
105
- log_payload = {
106
- "request": redact_dict(payload),
107
- "response": redact_dict(data),
108
- "usage": resp.usage,
109
- }
110
- with open(log_file, "a", encoding="utf-8") as f:
111
- f.write(json.dumps(log_payload) + "\n")
112
- except Exception as e:
113
- import logging as _log
114
- _log.getLogger(__name__).debug("Failed to log model call: %s", e)
115
-
116
- return resp
117
-
118
- class MockProvider(Provider):
119
- """Mock provider for dry runs and testing."""
120
- def __init__(self, responses: Optional[Dict[str, Any]] = None):
121
- # responses can be a dict of model -> str OR model -> list of str
122
- self.responses = responses or {}
123
- self._counts: Dict[str, int] = {}
124
-
125
- async def complete(
126
- self,
127
- model: str,
128
- messages: List[Dict[str, str]],
129
- temperature: float = 0.0,
130
- json_mode: bool = False
131
- ) -> LLMResponse:
132
- res = self.responses.get(model, '{"mock": "response"}')
133
-
134
- if isinstance(res, list):
135
- count = self._counts.get(model, 0)
136
- content = res[min(count, len(res)-1)]
137
- self._counts[model] = count + 1
138
- else:
139
- content = res
140
-
141
- return LLMResponse(
142
- content=content,
143
- model=f"mock/{model}",
144
- usage={"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20},
145
- raw_response={"choices": [{"message": {"content": content}}]}
146
- )
@@ -1,125 +0,0 @@
1
- from typing import List, Dict, Any, Type, Optional
2
- import copy
3
- import json
4
- import logging
5
- import asyncio
6
- from pathlib import Path
7
-
8
- from pydantic import BaseModel
9
- from devcouncil.llm.provider import Provider
10
- from devcouncil.llm.cache import LLMCache
11
- from devcouncil.telemetry.tracker import TelemetryTracker
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
- class ModelRouter:
16
- def __init__(self, provider: Provider, role_config: Dict[str, Dict[str, Any]]):
17
- self.provider = provider
18
- self.role_config = role_config
19
-
20
- async def complete_structured(
21
- self,
22
- role: str,
23
- messages: List[Dict[str, str]],
24
- schema: Type[BaseModel],
25
- temperature: Optional[float] = None,
26
- run_id: Optional[str] = None,
27
- ) -> BaseModel:
28
- config = self.role_config.get(role)
29
- if not config:
30
- raise ValueError(f"No config found for role: {role}")
31
-
32
- model = config["model"]
33
- temp = temperature if temperature is not None else config.get("temperature", 0.0)
34
-
35
- # Deep-copy to avoid mutating the caller's messages list
36
- msgs = copy.deepcopy(messages)
37
-
38
- # Add schema instructions to system or user message
39
- schema_json = json.dumps(schema.model_json_schema(), indent=2)
40
- instruction = f"\n\nYou MUST output a JSON object matching this schema:\n{schema_json}"
41
-
42
- found_system = False
43
- for msg in msgs:
44
- if msg["role"] == "system":
45
- msg["content"] += instruction
46
- found_system = True
47
- break
48
-
49
- if not found_system:
50
- msgs.insert(0, {"role": "system", "content": f"You are a helpful assistant.{instruction}"})
51
-
52
- logger.info("LLM call: role=%s model=%s run_id=%s", role, model, run_id)
53
-
54
- project_root = Path(".")
55
- cache = LLMCache(project_root)
56
- tracker = TelemetryTracker(project_root)
57
-
58
- # Check cache first
59
- response = cache.get(model, msgs, temp, True)
60
- cache_hit = response is not None
61
-
62
- if not response:
63
- for attempt in range(3):
64
- try:
65
- response = await self.provider.complete(
66
- model=model,
67
- messages=msgs,
68
- temperature=temp,
69
- json_mode=True
70
- )
71
- cache.set(model, msgs, temp, True, response)
72
- break
73
- except Exception as e:
74
- if attempt == 2:
75
- raise
76
- logger.warning(f"LLM request failed (attempt {attempt+1}): {e}. Retrying...")
77
- await asyncio.sleep(2 ** attempt)
78
-
79
- if not cache_hit:
80
- tracker.log_usage(model, response.usage)
81
-
82
- logger.info(
83
- "LLM response: role=%s model=%s tokens=%s",
84
- role, response.model, response.usage,
85
- )
86
-
87
- try:
88
- # Attempt to find JSON block if it's wrapped in markdown
89
- content = response.content.strip()
90
- if "```json" in content:
91
- content = content.split("```json")[1].split("```")[0].strip()
92
- elif "```" in content:
93
- content = content.split("```")[1].split("```")[0].strip()
94
-
95
- data = json.loads(content)
96
- return schema.model_validate(data)
97
- except Exception as e:
98
- logger.warning(f"Initial parse failed for {role}, attempting healing: {e}")
99
-
100
- # Healing attempt: Ask the model to fix its own JSON
101
- healing_prompt = f"""
102
- The following JSON was returned but failed to parse or validate against the schema.
103
- Error: {str(e)}
104
- Content:
105
- {response.content}
106
-
107
- Please return the corrected JSON object only. No prose.
108
- """
109
- # We use a lower temperature for healing
110
- healed_response = await self.provider.complete(
111
- model=model,
112
- messages=[{"role": "user", "content": healing_prompt}],
113
- temperature=0.0,
114
- json_mode=True
115
- )
116
-
117
- try:
118
- healed_content = healed_response.content.strip()
119
- if "```json" in healed_content:
120
- healed_content = healed_content.split("```json")[1].split("```")[0].strip()
121
- data = json.loads(healed_content)
122
- return schema.model_validate(data)
123
- except Exception as final_e:
124
- logger.error(f"Healing failed for {role}: {final_e}")
125
- raise ValueError(f"Failed to parse or validate LLM response after healing: {final_e}\nContent (truncated): {response.content[:200]}...")
@@ -1 +0,0 @@
1
-
@@ -1,57 +0,0 @@
1
- from typing import List, Dict
2
- from pydantic import BaseModel
3
- from devcouncil.domain.requirement import Requirement
4
- from devcouncil.domain.task import Task
5
- from devcouncil.llm.router import ModelRouter
6
-
7
- class ArbiterDecision(BaseModel):
8
- accepted_finding_ids: List[str]
9
- rejected_finding_ids: List[Dict[str, str]] # id, reason
10
- final_requirements: List[Requirement]
11
- final_tasks: List[Task]
12
-
13
- class ArbiterService:
14
- def __init__(self, router: ModelRouter):
15
- self.router = router
16
-
17
- async def arbitrate(
18
- self,
19
- goal: str,
20
- requirements_json: str,
21
- plan_a_json: str,
22
- plan_b_json: str,
23
- critique_a_json: str,
24
- critique_b_json: str,
25
- rebuttal_a_json: str,
26
- rebuttal_b_json: str
27
- ) -> ArbiterDecision:
28
- prompt = f"""
29
- Goal: {goal}
30
-
31
- Initial Requirements:
32
- {requirements_json}
33
-
34
- Plan A: {plan_a_json}
35
- Plan B: {plan_b_json}
36
-
37
- Critique of Plan B by Critic A: {critique_a_json}
38
- Critique of Plan A by Critic B: {critique_b_json}
39
-
40
- Rebuttal of Critic B by Planner A: {rebuttal_a_json}
41
- Rebuttal of Critic A by Planner B: {rebuttal_b_json}
42
-
43
- You are the arbiter engineering manager. Your goal is to produce the final, definitive set of requirements and tasks.
44
- - You do not decide by vibes.
45
- - High-severity unrefuted findings from critics must be incorporated into the final requirements or tasks.
46
- - If a planner successfully rebutted a finding, you may skip it.
47
- - Produce a single, coherent task graph.
48
- """
49
- messages = [
50
- {"role": "user", "content": prompt}
51
- ]
52
-
53
- return await self.router.complete_structured(
54
- role="arbiter",
55
- messages=messages,
56
- schema=ArbiterDecision
57
- )
@@ -1,66 +0,0 @@
1
- from typing import List
2
- from pydantic import BaseModel
3
- from devcouncil.domain.critique import CritiqueFinding
4
- from devcouncil.llm.router import ModelRouter
5
-
6
- class CritiqueOutput(BaseModel):
7
- findings: List[CritiqueFinding]
8
-
9
- class RebuttalItem(BaseModel):
10
- finding_id: str
11
- decision: str # "accepted", "rejected"
12
- reason: str
13
- suggested_change: str | None = None
14
-
15
- class RebuttalOutput(BaseModel):
16
- rebuttals: List[RebuttalItem]
17
-
18
- class CritiqueService:
19
- def __init__(self, router: ModelRouter):
20
- self.router = router
21
-
22
- async def generate_critique(self, role: str, target_plan_json: str, requirements_json: str) -> CritiqueOutput:
23
- prompt = f"""
24
- Requirements:
25
- {requirements_json}
26
-
27
- Target Plan:
28
- {target_plan_json}
29
-
30
- You are a hostile staff engineer reviewing another team's implementation plan.
31
- Find missing requirements, bad assumptions, missing tests, security risks, migration risks, and unverifiable claims.
32
- Do not praise. Do not rewrite the plan.
33
- Every finding must include a falsifiable_check.
34
- """
35
- messages = [
36
- {"role": "user", "content": prompt}
37
- ]
38
-
39
- return await self.router.complete_structured(
40
- role=role,
41
- messages=messages,
42
- schema=CritiqueOutput
43
- )
44
-
45
- async def generate_rebuttal(self, role: str, original_plan_json: str, findings_json: str) -> RebuttalOutput:
46
- prompt = f"""
47
- Original Plan:
48
- {original_plan_json}
49
-
50
- Critique Findings:
51
- {findings_json}
52
-
53
- You are the planner who created the original plan. Review the critique findings.
54
- - A finding can be rejected only with artifact evidence or strong justification.
55
- - A finding can be accepted and converted into a requirement/task/test.
56
- - No hand-wavy rebuttals.
57
- """
58
- messages = [
59
- {"role": "user", "content": prompt}
60
- ]
61
-
62
- return await self.router.complete_structured(
63
- role=role,
64
- messages=messages,
65
- schema=RebuttalOutput
66
- )
@@ -1,46 +0,0 @@
1
- from typing import List
2
- from pydantic import BaseModel
3
- from devcouncil.domain.task import Task
4
- from devcouncil.llm.router import ModelRouter
5
-
6
- class PlanOutput(BaseModel):
7
- id: str
8
- rationale: str
9
- tasks: List[Task]
10
-
11
- class PlanService:
12
- def __init__(self, router: ModelRouter):
13
- self.router = router
14
-
15
- async def generate_plan(self, role: str, goal: str, requirements_json: str, repo_map_json: str) -> PlanOutput:
16
- prompt = f"""
17
- Goal: {goal}
18
-
19
- Requirements:
20
- {requirements_json}
21
-
22
- Repository Map:
23
- {repo_map_json}
24
-
25
- Your task is to create a detailed implementation plan.
26
- - Break down the requirements into atomic implementation tasks.
27
- - For each task, specify which files will be created or modified.
28
- - Specify which tests are expected to verify the task.
29
- - Ensure each task maps back to at least one requirement.
30
-
31
- Role-specific instructions:
32
- """
33
- if role == "planner_a":
34
- prompt += "You are the pragmatic tech lead. Optimize for simplicity and minimal dependencies."
35
- else:
36
- prompt += "You are the production-readiness architect. Optimize for security, performance, and edge cases."
37
-
38
- messages = [
39
- {"role": "user", "content": prompt}
40
- ]
41
-
42
- return await self.router.complete_structured(
43
- role=role,
44
- messages=messages,
45
- schema=PlanOutput
46
- )