devcouncil 0.1.0 → 0.2.0

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 (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class ReviewSignal(BaseModel):
13
+ client: str = "generic"
14
+ payload: dict[str, Any] = Field(default_factory=dict)
15
+ transcript_path: str | None = None
16
+ session_id: str | None = None
17
+ task_id: str | None = None
18
+ created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
19
+ review_command: str | None = None
20
+ path: str | None = None
21
+
22
+
23
+ def signal_dir(project_root: Path) -> Path:
24
+ return project_root / ".devcouncil" / "live" / "signals"
25
+
26
+
27
+ def processed_signal_dir(project_root: Path) -> Path:
28
+ return signal_dir(project_root) / "processed"
29
+
30
+
31
+ def write_signal(project_root: Path, client: str, payload: dict[str, Any]) -> Path:
32
+ directory = signal_dir(project_root)
33
+ directory.mkdir(parents=True, exist_ok=True)
34
+ transcript_path = extract_transcript_path(payload)
35
+ session_id = _string_value(payload, "session_id", "sessionId", "session", "cwd")
36
+ task_id = extract_task_id(payload)
37
+ signal = ReviewSignal(
38
+ client=client.lower(),
39
+ payload=payload,
40
+ transcript_path=transcript_path,
41
+ session_id=session_id,
42
+ task_id=task_id,
43
+ review_command=_review_command(client.lower(), transcript_path, task_id),
44
+ )
45
+ key = transcript_path or session_id or json.dumps(payload, sort_keys=True, default=str)
46
+
47
+ digest = hashlib.sha256(key.encode("utf-8", errors="replace")).hexdigest()[:12]
48
+ path = directory / f"{client.lower()}-{digest}.json"
49
+ signal.path = str(path)
50
+ path.write_text(signal.model_dump_json(indent=2) + "\n", encoding="utf-8")
51
+ return path
52
+
53
+
54
+ def load_signals(project_root: Path) -> list[ReviewSignal]:
55
+ directory = signal_dir(project_root)
56
+ if not directory.exists():
57
+ return []
58
+ signals: list[ReviewSignal] = []
59
+ for path in sorted(directory.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
60
+ try:
61
+ raw = json.loads(path.read_text(encoding="utf-8"))
62
+ signal = ReviewSignal.model_validate(raw)
63
+ signal.path = str(path)
64
+ signals.append(signal)
65
+ except Exception:
66
+ continue
67
+ return signals
68
+
69
+
70
+ def mark_processed(signal: ReviewSignal, project_root: Path) -> Path | None:
71
+ if not signal.path:
72
+ return None
73
+ source = Path(signal.path)
74
+ if not source.exists():
75
+ return None
76
+ target_dir = processed_signal_dir(project_root)
77
+ target_dir.mkdir(parents=True, exist_ok=True)
78
+ target = target_dir / source.name
79
+ if target.exists():
80
+ target.unlink()
81
+ source.replace(target)
82
+ return target
83
+
84
+
85
+ def extract_transcript_path(payload: dict[str, Any]) -> str | None:
86
+ direct = _string_value(
87
+ payload,
88
+ "transcript_path",
89
+ "transcriptPath",
90
+ "transcript",
91
+ "conversation_path",
92
+ "conversationPath",
93
+ "file",
94
+ "path",
95
+ )
96
+ if direct:
97
+ return direct
98
+ for key in ("session", "message", "hook_event", "event"):
99
+ nested = payload.get(key)
100
+ if isinstance(nested, dict):
101
+ value = extract_transcript_path(nested)
102
+ if value:
103
+ return value
104
+ return None
105
+
106
+
107
+ def extract_task_id(payload: dict[str, Any]) -> str | None:
108
+ direct = _string_value(payload, "task_id", "taskId", "task", "active_task", "activeTask")
109
+ if direct:
110
+ return direct
111
+ for key in ("session", "message", "hook_event", "event", "metadata"):
112
+ nested = payload.get(key)
113
+ if isinstance(nested, dict):
114
+ value = extract_task_id(nested)
115
+ if value:
116
+ return value
117
+ return None
118
+
119
+
120
+ def _string_value(payload: dict[str, Any], *keys: str) -> str | None:
121
+ for key in keys:
122
+ value = payload.get(key)
123
+ if isinstance(value, str) and value.strip():
124
+ return value
125
+ return None
126
+
127
+
128
+ def _review_command(client: str, transcript_path: str | None, task_id: str | None = None) -> str:
129
+ if transcript_path:
130
+ command = f"dev watch review --client {client} --transcript {transcript_path}"
131
+ else:
132
+ command = f"dev watch pending --client {client}"
133
+ if task_id:
134
+ command += f" --task-id {task_id}"
135
+ return command
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from devcouncil.live.cards import load_cards, unresolved_blocking_cards
6
+ from devcouncil.live.signals import load_signals
7
+ from devcouncil.live.tasks import active_task_id
8
+
9
+
10
+ def live_review_summary(project_root: Path, task_id: str | None = None) -> dict:
11
+ cards = load_cards(project_root)
12
+ signals = load_signals(project_root)
13
+ active_id = active_task_id(project_root)
14
+ scoped_task_id = task_id or active_id
15
+ blockers = unresolved_blocking_cards(project_root, task_id=scoped_task_id)
16
+ pending_signal_items = [signal.model_dump() for signal in signals]
17
+ return {
18
+ "active_task_id": active_id,
19
+ "scope_task_id": scoped_task_id,
20
+ "pending_signals": len(signals),
21
+ "pending_signal_items": pending_signal_items[:10],
22
+ "cards": {
23
+ "total": len(cards),
24
+ "open": len([card for card in cards if card.status == "open"]),
25
+ "resolved": len([card for card in cards if card.status == "resolved"]),
26
+ "ignored": len([card for card in cards if card.status == "ignored"]),
27
+ "critical_open": len([
28
+ card for card in cards
29
+ if card.status == "open" and card.verdict == "Critical Issues"
30
+ ]),
31
+ },
32
+ "blocking_cards": [card.model_dump() for card in blockers],
33
+ "recent_cards": [card.model_dump() for card in cards[:5]],
34
+ }
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from devcouncil.storage.db import get_db
6
+ from devcouncil.storage.repositories import TaskRepository
7
+
8
+
9
+ def active_task_id(project_root: Path) -> str | None:
10
+ """Return the single running DevCouncil task ID, if one is unambiguous."""
11
+ db = get_db(project_root)
12
+ if not db:
13
+ return None
14
+ with db.get_session() as session:
15
+ running = [task for task in TaskRepository(session).get_all() if task.status == "running"]
16
+ if len(running) != 1:
17
+ return None
18
+ return running[0].id
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Iterable, Literal
6
+
7
+ from devcouncil.live.models import AgentSession, AgentTurn, session_id_from_path
8
+
9
+ RoleName = Literal["user", "assistant", "system", "tool", "unknown"]
10
+ KNOWN_ROLES: set[RoleName] = {"user", "assistant", "system", "tool"}
11
+
12
+
13
+ CLAUDE_TRANSCRIPT_ROOT = Path.home() / ".claude" / "projects"
14
+
15
+
16
+ def discover_sessions(project_root: Path, client: str = "claude") -> list[AgentSession]:
17
+ """Find local coding-agent transcripts DevCouncil can review."""
18
+ client = client.lower()
19
+ if client == "claude":
20
+ candidates = _claude_transcript_candidates(project_root)
21
+ else:
22
+ candidates = sorted((project_root / ".devcouncil" / "live" / client).glob("*.jsonl"))
23
+
24
+ sessions: list[AgentSession] = []
25
+ for path in candidates:
26
+ if not path.exists() or not path.is_file():
27
+ continue
28
+ stat = path.stat()
29
+ sessions.append(AgentSession(
30
+ id=session_id_from_path(path),
31
+ client=client,
32
+ transcript_path=str(path),
33
+ updated_at=str(stat.st_mtime),
34
+ turns=sum(1 for _ in _safe_lines(path)),
35
+ ))
36
+ return sorted(sessions, key=lambda item: item.updated_at or "", reverse=True)
37
+
38
+
39
+ def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
40
+ """Parse a transcript into normalized turns.
41
+
42
+ Supports Claude Code JSONL plus generic JSONL records with role/content fields.
43
+ """
44
+ turns: list[AgentTurn] = []
45
+ session_id = session_id_from_path(path)
46
+ for index, raw in enumerate(_read_jsonl(path)):
47
+ turn = _turn_from_record(raw, session_id=session_id, turn_index=index, client=client)
48
+ if turn and turn.content.strip():
49
+ turns.append(turn)
50
+ return turns
51
+
52
+
53
+ def latest_assistant_turn(path: Path, client: str = "generic") -> AgentTurn | None:
54
+ for turn in reversed(load_turns(path, client=client)):
55
+ if turn.role == "assistant":
56
+ return turn
57
+ return None
58
+
59
+
60
+ def _claude_transcript_candidates(project_root: Path) -> list[Path]:
61
+ local_runtime = project_root / ".devcouncil" / "live" / "claude"
62
+ candidates = list(local_runtime.glob("*.jsonl"))
63
+ if CLAUDE_TRANSCRIPT_ROOT.exists():
64
+ candidates.extend(CLAUDE_TRANSCRIPT_ROOT.rglob("*.jsonl"))
65
+ return sorted(set(candidates), key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True)
66
+
67
+
68
+ def _safe_lines(path: Path) -> Iterable[str]:
69
+ try:
70
+ return path.read_text(encoding="utf-8", errors="replace").splitlines()
71
+ except OSError:
72
+ return []
73
+
74
+
75
+ def _read_jsonl(path: Path) -> Iterable[dict[str, Any]]:
76
+ for line in _safe_lines(path):
77
+ if not line.strip():
78
+ continue
79
+ try:
80
+ value = json.loads(line)
81
+ except json.JSONDecodeError:
82
+ continue
83
+ if isinstance(value, dict):
84
+ yield value
85
+
86
+
87
+ def _turn_from_record(raw: dict[str, Any], session_id: str, turn_index: int, client: str) -> AgentTurn | None:
88
+ role = _role(raw)
89
+ content = _content(raw)
90
+ if not content:
91
+ return None
92
+ turn_id = str(raw.get("uuid") or raw.get("id") or raw.get("message_id") or f"turn-{turn_index}")
93
+ return AgentTurn(
94
+ session_id=str(raw.get("sessionId") or raw.get("session_id") or session_id),
95
+ turn_id=turn_id,
96
+ source=client,
97
+ role=role,
98
+ content=content,
99
+ timestamp=raw.get("timestamp") or raw.get("created_at"),
100
+ raw=raw,
101
+ )
102
+
103
+
104
+ def _role(raw: dict[str, Any]) -> RoleName:
105
+ role = raw.get("role")
106
+ if isinstance(role, str):
107
+ return role if role in KNOWN_ROLES else "unknown"
108
+ message = raw.get("message")
109
+ if isinstance(message, dict):
110
+ nested = message.get("role")
111
+ if isinstance(nested, str):
112
+ return nested if nested in KNOWN_ROLES else "unknown"
113
+ record_type = raw.get("type")
114
+ if record_type in KNOWN_ROLES:
115
+ return record_type
116
+ return "unknown"
117
+
118
+
119
+ def _content(raw: dict[str, Any]) -> str:
120
+ direct = raw.get("content") or raw.get("text")
121
+ if isinstance(direct, str):
122
+ return direct
123
+ message = raw.get("message")
124
+ if isinstance(message, dict):
125
+ nested = message.get("content")
126
+ if isinstance(nested, str):
127
+ return nested
128
+ if isinstance(nested, list):
129
+ return "\n".join(_content_block_text(block) for block in nested).strip()
130
+ if isinstance(direct, list):
131
+ return "\n".join(_content_block_text(block) for block in direct).strip()
132
+ return ""
133
+
134
+
135
+ def _content_block_text(block: Any) -> str:
136
+ if isinstance(block, str):
137
+ return block
138
+ if isinstance(block, dict):
139
+ value = block.get("text") or block.get("content")
140
+ return value if isinstance(value, str) else ""
141
+ return ""
@@ -1 +1 @@
1
-
1
+
@@ -1,38 +1,42 @@
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
+ 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, provider_fingerprint: str = "") -> str:
13
+ data = {
14
+ "model": model,
15
+ "messages": messages,
16
+ "temp": temp,
17
+ "json_mode": json_mode,
18
+ # Provider-specific knobs that change the output for an identical prompt
19
+ # (e.g. Ollama's num_ctx / base_url). Empty for providers without such knobs,
20
+ # so their cache keys are unchanged.
21
+ "provider": provider_fingerprint,
22
+ }
23
+ s = json.dumps(data, sort_keys=True)
24
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
25
+
26
+ def get(self, model: str, messages: list, temp: float, json_mode: bool, provider_fingerprint: str = "") -> Optional[LLMResponse]:
27
+ key = self._get_key(model, messages, temp, json_mode, provider_fingerprint)
28
+ cache_file = self.cache_dir / f"{key}.json"
29
+ if cache_file.exists():
30
+ try:
31
+ with open(cache_file, "r") as f:
32
+ data = json.load(f)
33
+ return LLMResponse(**data)
34
+ except Exception:
35
+ pass
36
+ return None
37
+
38
+ def set(self, model: str, messages: list, temp: float, json_mode: bool, response: LLMResponse, provider_fingerprint: str = ""):
39
+ key = self._get_key(model, messages, temp, json_mode, provider_fingerprint)
40
+ cache_file = self.cache_dir / f"{key}.json"
41
+ with open(cache_file, "w") as f:
42
+ json.dump(response.model_dump(), f)
@@ -0,0 +1,44 @@
1
+ openrouter:
2
+ spec_writer: anthropic/claude-sonnet-4.6
3
+ prompt_enhancer: anthropic/claude-sonnet-4.6
4
+ planner_a: anthropic/claude-sonnet-4.6
5
+ planner_b: google/gemini-2.5-pro
6
+ critic_a: openai/gpt-5.5
7
+ critic_b: anthropic/claude-opus-4.8
8
+ arbiter: openai/gpt-5.5
9
+ native_agent: anthropic/claude-sonnet-4.6
10
+ implementation_reviewer: openai/gpt-5.5
11
+ live_reviewer: openai/gpt-5.5
12
+ vertexai:
13
+ spec_writer: google/gemini-2.5-flash
14
+ prompt_enhancer: google/gemini-2.5-flash
15
+ planner_a: google/gemini-2.5-flash
16
+ planner_b: google/gemini-2.5-flash
17
+ critic_a: google/gemini-2.5-flash
18
+ critic_b: google/gemini-2.5-flash
19
+ arbiter: google/gemini-2.5-flash
20
+ native_agent: google/gemini-2.5-flash
21
+ implementation_reviewer: google/gemini-2.5-flash
22
+ live_reviewer: google/gemini-2.5-flash
23
+ doubleword:
24
+ spec_writer: deepseek/deepseek-v4
25
+ prompt_enhancer: deepseek/deepseek-v4
26
+ planner_a: deepseek/deepseek-v4
27
+ planner_b: deepseek/deepseek-v4
28
+ critic_a: deepseek/deepseek-v4
29
+ critic_b: deepseek/deepseek-v4
30
+ arbiter: deepseek/deepseek-v4
31
+ native_agent: deepseek/deepseek-v4
32
+ implementation_reviewer: deepseek/deepseek-v4
33
+ live_reviewer: deepseek/deepseek-v4
34
+ ollama:
35
+ spec_writer: qwen2.5-coder:7b
36
+ prompt_enhancer: qwen2.5-coder:7b
37
+ planner_a: qwen2.5-coder:7b
38
+ planner_b: qwen2.5-coder:7b
39
+ critic_a: qwen2.5-coder:7b
40
+ critic_b: qwen2.5-coder:7b
41
+ arbiter: qwen2.5-coder:7b
42
+ native_agent: qwen2.5-coder:7b
43
+ implementation_reviewer: qwen2.5-coder:7b
44
+ live_reviewer: qwen2.5-coder:7b