devcouncil 0.1.1 → 0.3.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 (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -5,16 +5,37 @@ Replaces scattered yaml.safe_load() calls with a single validated config service
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import logging
8
9
  import os
10
+ import shutil
11
+ import subprocess
12
+ import time
9
13
  from pathlib import Path
10
- from typing import Dict, List
14
+ from typing import Dict, List, Optional, Tuple
11
15
 
12
- from pydantic import BaseModel, Field
16
+ from pydantic import BaseModel, Field, field_validator
17
+
18
+ logger = logging.getLogger(__name__)
13
19
 
14
20
 
15
21
  class ModelRoleConfig(BaseModel):
16
22
  model: str
17
23
  temperature: float = 0.0
24
+ # Optional per-role provider override. When unset, the role uses
25
+ # ``models.provider``. Lets a single run route some roles to one provider
26
+ # (e.g. planning on OpenRouter) and others to another (e.g. live review on
27
+ # local Ollama). Validated/normalized against the supported provider list.
28
+ provider: str | None = None
29
+
30
+ @field_validator("provider")
31
+ @classmethod
32
+ def _normalize_provider(cls, value: str | None) -> str | None:
33
+ if value is None:
34
+ return None
35
+ # Lazy import avoids a circular import (llm.provider imports app.config).
36
+ from devcouncil.llm.provider import validate_model_provider
37
+
38
+ return validate_model_provider(value)
18
39
 
19
40
 
20
41
  class ModelsConfig(BaseModel):
@@ -34,6 +55,79 @@ class CommandsConfig(BaseModel):
34
55
  typecheck: List[str] = Field(default_factory=list)
35
56
 
36
57
 
58
+ class VerificationSandboxConfig(BaseModel):
59
+ docker_image: str = "python:3.12-slim"
60
+ docker_setup_commands: List[str] = Field(default_factory=list)
61
+ nix_flake_attr: str | None = None
62
+
63
+
64
+ class DiffCoverageConfig(BaseModel):
65
+ """Diff↔coverage gating: prove the *changed* lines were exercised by tests.
66
+
67
+ ``measure`` runs the diff-coverage analysis and records it as evidence (and a
68
+ non-blocking signal) whenever the target repo's coverage tooling is present.
69
+ ``enforce`` promotes an unexercised diff to a *blocking* gap. Enforcement is
70
+ off by default so the signal is visible before it ever gates — a passing test
71
+ that does not touch the new code is surfaced first, then teams opt in to
72
+ blocking. ``min_ratio`` of 0.0 means "require at least one changed executable
73
+ line to be exercised"; a higher value demands that fraction of changed lines.
74
+ """
75
+
76
+ measure: bool = True
77
+ enforce: bool = False
78
+ min_ratio: float = 0.0
79
+
80
+
81
+ class AcceptanceCheckConfig(BaseModel):
82
+ """Tuning for DevCouncil's per-criterion compiled acceptance checks.
83
+
84
+ These default to single-shot behavior (``samples=1``, ``repair_attempts=1``)
85
+ so a strong cloud model is unaffected. They exist to make a WEAK/LOCAL model
86
+ (e.g. an Ollama reviewer) trustworthy: such a model frequently emits a check
87
+ that does not run (wrong import, broken one-liner) — recorded as ``incomplete``
88
+ — or a single mis-asserting check that false-``blocked`` correct code.
89
+
90
+ - ``repair_attempts``: when a compiled check FAILS TO RUN (malformed/unrunnable,
91
+ proves nothing), feed the error back and regenerate the COMMAND up to this many
92
+ times. Safe by construction — a check that never ran cannot weaken the gate.
93
+ - ``samples``: generate this many INDEPENDENT checks per criterion and decide by
94
+ majority vote (proven iff a strict majority pass; unanimous-fail blocks; a split
95
+ stays unproven with a non-blocking advisory). Local sampling is cost-free, so
96
+ raising this (e.g. 3) outvotes a single mis-generated check without ever
97
+ auto-passing a real defect. ``1`` reproduces today's single-check behavior.
98
+ """
99
+
100
+ samples: int = 1
101
+ repair_attempts: int = 1
102
+ # Compile one criterion per model call instead of batching them all into a single
103
+ # prompt. A weak/local model batching N criteria into one JSON routinely omits or
104
+ # mis-attributes some (a false "incomplete"); a focused single-criterion prompt is far
105
+ # more reliable. Costs N× the calls — cheap on a local monitor, so opt-in. Off keeps
106
+ # the single batched call.
107
+ per_criterion: bool = False
108
+
109
+
110
+ class ReviewerCheckConfig(BaseModel):
111
+ """Self-consistency voting for the LLM live reviewer.
112
+
113
+ A weak/local reviewer can emit a lone, mis-calibrated "Critical Issues" verdict that
114
+ falsely BLOCKS (the live card becomes a blocking gap). Sampling ``samples`` independent
115
+ reviews and majority-voting the verdict outvotes a single bad judgment: the gate only
116
+ escalates to the blocking verdict when a strict majority agree; a split de-escalates to
117
+ the non-blocking "Concerns". ``samples=1`` reproduces today's single-review behavior, so
118
+ a strong cloud model is unaffected. Local sampling is cost-free — raise it (e.g. 3) there.
119
+ """
120
+
121
+ samples: int = 1
122
+
123
+
124
+ class VerificationConfig(BaseModel):
125
+ sandbox: VerificationSandboxConfig = Field(default_factory=VerificationSandboxConfig)
126
+ diff_coverage: DiffCoverageConfig = Field(default_factory=DiffCoverageConfig)
127
+ acceptance_checks: AcceptanceCheckConfig = Field(default_factory=AcceptanceCheckConfig)
128
+ reviewer_checks: ReviewerCheckConfig = Field(default_factory=ReviewerCheckConfig)
129
+
130
+
37
131
  class GatesConfig(BaseModel):
38
132
  require_clean_git_before_task: bool = True
39
133
  block_orphan_diffs: bool = True
@@ -48,6 +142,23 @@ class ExecutionConfig(BaseModel):
48
142
  max_repair_attempts: int = 3
49
143
  checkpoint_before_each_task: bool = True
50
144
  command_timeout: int = 300
145
+ stream_cli_output: bool = False
146
+ # Default lifetime of an MCP task lease. A crashed/disconnected agent's lease
147
+ # auto-expires after this, so the task frees up without a human running force.
148
+ lease_ttl_seconds: int = 1800
149
+ # When true, the post-task coding-CLI hook runs deterministic verification of the
150
+ # active task (and records gaps) instead of only printing a reminder. Off by default
151
+ # so hooks stay fast/cheap unless a team opts in.
152
+ verify_on_post_task: bool = False
153
+ cursor_resume_mode: str = "off"
154
+ coding_cli_probe_order: List[str] = Field(default_factory=list)
155
+ # Opt-in scope gate for executors WITHOUT a pre-write hook (CLI subprocesses that write
156
+ # directly to disk). When true, DevCouncil re-checks every file a coding-CLI subprocess
157
+ # changed against the task's authorization right after it exits and REVERTS any the task
158
+ # did not allow — so unplanned drift never reaches the verify gate or a commit, instead
159
+ # of only being flagged post-verify by orphan_diff. Off by default (it reverts the
160
+ # agent's writes; teams opt in once their plans declare planned_files reliably).
161
+ enforce_file_scope_pre_verify: bool = False
51
162
 
52
163
 
53
164
  class PrivacyConfig(BaseModel):
@@ -75,10 +186,106 @@ class LiveReviewIntegrationConfig(BaseModel):
75
186
  default_client: str = "claude"
76
187
 
77
188
 
189
+ class WarpIntegrationConfig(BaseModel):
190
+ enabled: bool = False
191
+ command: str = "oz"
192
+ run_mode: str = "local"
193
+ mcp_config_path: str = ".devcouncil/integrations/warp-mcp.json"
194
+ profile: str | None = None
195
+ model: str | None = None
196
+ environment: str | None = None
197
+ share: List[str] = Field(default_factory=list)
198
+
199
+
200
+ class OpenCodeIntegrationConfig(BaseModel):
201
+ enabled: bool = False
202
+ config_path: str = "opencode.json"
203
+
204
+
205
+ class AntigravityIntegrationConfig(BaseModel):
206
+ enabled: bool = False
207
+ mcp_config_path: str = ".agents/mcp_config.json"
208
+
209
+
210
+ class CursorIntegrationConfig(BaseModel):
211
+ enabled: bool = False
212
+ config_path: str = ".cursor/mcp.json"
213
+ hooks_path: str = ".cursor/hooks.json"
214
+
215
+
216
+ class AiderIntegrationConfig(BaseModel):
217
+ enabled: bool = False
218
+
219
+
220
+ class CliAgentProfileConfig(BaseModel):
221
+ description: str = ""
222
+ timeout_seconds: int | None = None
223
+ prompt_preamble: str = ""
224
+ require_explicit_confirmation: bool = False
225
+ # Per-profile CLI containment overrides. Empty/None reproduce today's behavior
226
+ # exactly so a profile that only sets a prompt preamble is a no-op on the
227
+ # subprocess invocation. ``extra_args`` are appended verbatim to the resolved
228
+ # command, ``permission_mode`` is translated into the right per-CLI flag where
229
+ # known (and overly-permissive flags are dropped for stricter modes), and
230
+ # ``model`` overrides the model flag for CLIs that accept one.
231
+ extra_args: List[str] = Field(default_factory=list)
232
+ permission_mode: str | None = None
233
+ model: str | None = None
234
+
235
+
236
+ class CustomCliAgentConfig(BaseModel):
237
+ command: str
238
+ args: List[str] = Field(default_factory=list)
239
+ input_mode: str = "stdin"
240
+ prompt_arg: str | None = None
241
+ timeout_seconds: int | None = None
242
+ env: Dict[str, str] = Field(default_factory=dict)
243
+ display_name: str | None = None
244
+ kind: str = "custom"
245
+ supports_mcp: bool = False
246
+ supports_diff_review: bool = False
247
+ default_profile: str = "default"
248
+ help_command: List[str] = Field(default_factory=list)
249
+
250
+
251
+ class CliAgentsIntegrationConfig(BaseModel):
252
+ enabled: bool = True
253
+ profiles: Dict[str, CliAgentProfileConfig] = Field(default_factory=dict)
254
+ agents: Dict[str, CustomCliAgentConfig] = Field(default_factory=dict)
255
+
256
+
257
+ class McpIntegrationConfig(BaseModel):
258
+ write_task_scope_to_config: bool = False
259
+
260
+
78
261
  class IntegrationsConfig(BaseModel):
262
+ mcp: McpIntegrationConfig = Field(default_factory=McpIntegrationConfig)
79
263
  agent_flow: AgentFlowIntegrationConfig = Field(default_factory=AgentFlowIntegrationConfig)
80
264
  code_review_graph: CodeReviewGraphIntegrationConfig = Field(default_factory=CodeReviewGraphIntegrationConfig)
81
265
  live_review: LiveReviewIntegrationConfig = Field(default_factory=LiveReviewIntegrationConfig)
266
+ cursor: CursorIntegrationConfig = Field(default_factory=CursorIntegrationConfig)
267
+ aider: AiderIntegrationConfig = Field(default_factory=AiderIntegrationConfig)
268
+ antigravity: AntigravityIntegrationConfig = Field(default_factory=AntigravityIntegrationConfig)
269
+ warp: WarpIntegrationConfig = Field(default_factory=WarpIntegrationConfig)
270
+ opencode: OpenCodeIntegrationConfig = Field(default_factory=OpenCodeIntegrationConfig)
271
+ cli_agents: CliAgentsIntegrationConfig = Field(default_factory=CliAgentsIntegrationConfig)
272
+
273
+
274
+ class KnowledgeConfig(BaseModel):
275
+ """Ingested knowledge (Open Knowledge Format bundles + a project design.md) that gets
276
+ injected into planning/council/task prompts.
277
+
278
+ Sources live under ``<directory>/{okf,design}``. A design system is always selected
279
+ (``design_always``) because a coding agent should honor it on every UI task; OKF
280
+ knowledge is selected by goal keywords / document tags. The ``*_max_chars`` budgets
281
+ bound how much rides inline so a large knowledge base can't crowd out file context.
282
+ """
283
+
284
+ enabled: bool = True
285
+ directory: str = ".devcouncil/knowledge"
286
+ design_always: bool = True
287
+ okf_max_chars: int = 3000
288
+ design_max_chars: int = 4000
82
289
 
83
290
 
84
291
  class ProviderConfig(BaseModel):
@@ -97,42 +304,102 @@ class DevCouncilConfig(BaseModel):
97
304
  commands: CommandsConfig = Field(default_factory=CommandsConfig)
98
305
  gates: GatesConfig = Field(default_factory=GatesConfig)
99
306
  execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
307
+ verification: VerificationConfig = Field(default_factory=VerificationConfig)
100
308
  privacy: PrivacyConfig = Field(default_factory=PrivacyConfig)
101
309
  integrations: IntegrationsConfig = Field(default_factory=IntegrationsConfig)
310
+ knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig)
311
+
312
+
313
+ # Memoized parsed configs keyed by resolved config path. Each entry stores the
314
+ # file's stat signature (mtime_ns, size, inode) so a rewritten config.yaml — common in
315
+ # tests that mutate config mid-process, including delete+recreate — is re-read instead
316
+ # of served stale.
317
+ _CONFIG_CACHE: Dict[Path, Tuple[Tuple[int, int, int], DevCouncilConfig]] = {}
102
318
 
103
319
 
104
320
  def load_config(project_root: Path = Path(".")) -> DevCouncilConfig:
105
321
  """Load and validate .devcouncil/config.yaml.
106
-
322
+
107
323
  Returns DevCouncilConfig with defaults for any missing fields.
108
324
  Raises FileNotFoundError if config doesn't exist.
325
+
326
+ Parsed results are memoized per resolved config path and invalidated when the
327
+ file's mtime/size changes, so repeated callers avoid redundant disk reads while
328
+ still picking up a rewritten config.
109
329
  """
110
- import yaml
330
+ import yaml # type: ignore[import-untyped]
111
331
 
112
332
  config_path = project_root / ".devcouncil" / "config.yaml"
113
- if not config_path.exists():
333
+ try:
334
+ stat = config_path.stat()
335
+ except FileNotFoundError:
114
336
  raise FileNotFoundError(f"Config not found at {config_path}. Run 'dev init' first.")
115
337
 
116
- with open(config_path) as f:
117
- raw = yaml.safe_load(f) or {}
118
-
119
- return DevCouncilConfig.model_validate(raw)
338
+ cache_key = config_path.resolve()
339
+ # Include the inode so a delete+recreate under the same path (new inode) is treated
340
+ # as changed even if the rewritten file lands with the same mtime and size.
341
+ signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
342
+ cached = _CONFIG_CACHE.get(cache_key)
343
+ if cached is not None and cached[0] == signature:
344
+ # The cached instance is shared across callers; treat it as read-only. Nothing
345
+ # in the codebase mutates a loaded DevCouncilConfig (settings are rewritten on
346
+ # disk via the raw-config helpers, then re-read), so we avoid a per-call copy.
347
+ return cached[1]
348
+
349
+ logger.debug("Loading config from %s", config_path)
350
+ with open(config_path, encoding="utf-8") as f:
351
+ try:
352
+ raw = yaml.safe_load(f) or {}
353
+ except yaml.YAMLError as exc:
354
+ logger.error("Invalid YAML in %s: %s", config_path, exc)
355
+ raise ValueError(
356
+ f"Invalid YAML in {config_path}: {exc}. Fix the syntax or re-run 'dev init'."
357
+ ) from exc
358
+
359
+ config = DevCouncilConfig.model_validate(raw)
360
+ _CONFIG_CACHE[cache_key] = (signature, config)
361
+ logger.debug("Config loaded: provider=%s", config.models.provider)
362
+ return config
120
363
 
121
364
 
122
365
  def provider_api_key_env_var(provider: str = "openrouter") -> str:
366
+ normalized = provider.strip().lower().replace("-", "").replace("_", "")
123
367
  env_map = {
124
368
  "openrouter": "OPENROUTER_API_KEY",
369
+ "vertexai": "VERTEXAI_ACCESS_TOKEN",
370
+ "doubleword": "DOUBLEWORD_API_KEY",
371
+ "ollama": "OLLAMA_API_KEY",
125
372
  "openai": "OPENAI_API_KEY",
126
373
  "anthropic": "ANTHROPIC_API_KEY",
127
374
  }
128
- return env_map.get(provider, f"{provider.upper()}_API_KEY")
375
+ return env_map.get(normalized, f"{normalized.upper()}_API_KEY")
376
+
377
+
378
+ def _normalized_provider_name(provider: str) -> str:
379
+ return provider.strip().lower().replace("-", "").replace("_", "")
380
+
381
+
382
+ # Memoized parsed secrets keyed by resolved secrets path, with the same
383
+ # stat-signature (mtime_ns, size) invalidation as load_config.
384
+ _SECRETS_CACHE: Dict[Path, Tuple[Tuple[int, int, int], Dict[str, str]]] = {}
129
385
 
130
386
 
131
387
  def load_local_secrets(project_root: Path = Path(".")) -> Dict[str, str]:
132
388
  secrets_path = project_root / ".devcouncil" / "secrets.env"
133
- if not secrets_path.exists():
389
+ try:
390
+ stat = secrets_path.stat()
391
+ except FileNotFoundError:
134
392
  return {}
135
393
 
394
+ cache_key = secrets_path.resolve()
395
+ # Include the inode so a delete+recreate under the same path (new inode) is treated
396
+ # as changed even if the rewritten file lands with the same mtime and size.
397
+ signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
398
+ cached = _SECRETS_CACHE.get(cache_key)
399
+ if cached is not None and cached[0] == signature:
400
+ # Copy so callers can't mutate the cached mapping.
401
+ return dict(cached[1])
402
+
136
403
  secrets: Dict[str, str] = {}
137
404
  for line in secrets_path.read_text(encoding="utf-8").splitlines():
138
405
  stripped = line.strip()
@@ -140,9 +407,66 @@ def load_local_secrets(project_root: Path = Path(".")) -> Dict[str, str]:
140
407
  continue
141
408
  key, value = stripped.split("=", 1)
142
409
  secrets[key.strip()] = value.strip().strip('"').strip("'")
410
+ _SECRETS_CACHE[cache_key] = (signature, dict(secrets))
143
411
  return secrets
144
412
 
145
413
 
414
+ def get_gcloud_access_token() -> str | None:
415
+ """Fetch a fresh gcloud access token by shelling out to ``gcloud``.
416
+
417
+ This always spawns the subprocess (no caching) so callers that need a guaranteed
418
+ fresh token — e.g. the Vertex provider refreshing after a 401/403 — get one. For
419
+ the hot path (per-provider-construction key lookups) use
420
+ :func:`get_cached_gcloud_access_token`, which memoizes the result with a TTL.
421
+ """
422
+ executable = shutil.which("gcloud")
423
+ if not executable:
424
+ return None
425
+ try:
426
+ token = subprocess.check_output(
427
+ [executable, "auth", "print-access-token"],
428
+ stderr=subprocess.STDOUT,
429
+ text=True,
430
+ encoding="utf-8",
431
+ errors="replace",
432
+ timeout=10,
433
+ ).strip()
434
+ except Exception:
435
+ return None
436
+ return token or None
437
+
438
+
439
+ # Cached gcloud access token + monotonic expiry. gcloud tokens last ~60 min, so the
440
+ # hot path (a fresh provider per role/run calling get_api_key) reuses a fetched token
441
+ # for a conservative window instead of spawning ``gcloud auth print-access-token`` on
442
+ # every lookup. A single gcloud identity is assumed, so no cache key is needed. A
443
+ # failed fetch (None) is never cached.
444
+ _GCLOUD_TOKEN_TTL_SECONDS = 50 * 60
445
+ _gcloud_token_cache: Optional[Tuple[str, float]] = None
446
+
447
+
448
+ def get_cached_gcloud_access_token() -> str | None:
449
+ """Return a gcloud access token, reusing a recent one within the TTL window.
450
+
451
+ Falls back to :func:`get_gcloud_access_token` on a cache miss/expiry. ``gcloud``
452
+ absence is checked first so an environment without gcloud short-circuits to None
453
+ without ever consulting the cache (preserving the uncached error/None behavior).
454
+ """
455
+ global _gcloud_token_cache
456
+
457
+ if shutil.which("gcloud") is None:
458
+ return None
459
+
460
+ cached = _gcloud_token_cache
461
+ if cached is not None and time.monotonic() < cached[1]:
462
+ return cached[0]
463
+
464
+ token = get_gcloud_access_token()
465
+ if token:
466
+ _gcloud_token_cache = (token, time.monotonic() + _GCLOUD_TOKEN_TTL_SECONDS)
467
+ return token
468
+
469
+
146
470
  def get_api_key(provider: str = "openrouter", project_root: Path = Path(".")) -> str:
147
471
  """Retrieve the API key for the configured provider from environment.
148
472
 
@@ -150,9 +474,21 @@ def get_api_key(provider: str = "openrouter", project_root: Path = Path(".")) ->
150
474
  """
151
475
  env_var = provider_api_key_env_var(provider)
152
476
  key = os.environ.get(env_var) or load_local_secrets(project_root).get(env_var)
477
+ if not key and _normalized_provider_name(provider) == "vertexai":
478
+ key = get_cached_gcloud_access_token()
479
+ if not key and _normalized_provider_name(provider) == "ollama":
480
+ # Ollama is a local server and needs no API key; an explicitly-set
481
+ # OLLAMA_API_KEY still flows through above if present.
482
+ return ""
153
483
  if not key:
484
+ logger.warning("API key not found for provider %s (env var %s)", provider, env_var)
485
+ extra = (
486
+ " You can also authenticate with 'gcloud auth login' for vertexai."
487
+ if _normalized_provider_name(provider) == "vertexai"
488
+ else ""
489
+ )
154
490
  raise ValueError(
155
491
  f"API key not found. Set {env_var} in your environment or run 'dev setup'. "
156
- f"Provider: {provider}"
492
+ f"Provider: {provider}.{extra}"
157
493
  )
158
494
  return key
@@ -18,9 +18,13 @@ class Orchestrator:
18
18
  self.project_root = project_root
19
19
  self.persist_state = persist_state
20
20
 
21
- db = get_db(self.project_root)
22
- if db:
23
- with db.get_session() as session:
21
+ # Cache the Database handle once: get_db() rebuilds the SQLAlchemy engine
22
+ # and runs a schema check on every call, and transition_to() is invoked
23
+ # several times per run. The handle is reusable, so build it here and
24
+ # reuse it everywhere in this orchestrator instance.
25
+ self.db = get_db(self.project_root)
26
+ if self.db:
27
+ with self.db.get_session() as session:
24
28
  repo = StateRepository(session)
25
29
  state = repo.get_state()
26
30
  if state:
@@ -45,6 +49,7 @@ class Orchestrator:
45
49
  goal=goal
46
50
  )
47
51
  self.current_run.initialize()
52
+ logger.info("Run started: run_id=%s goal=%r", run_id, goal)
48
53
  TraceLogger(self.project_root).log_event(
49
54
  "planning_started",
50
55
  {"goal": goal},
@@ -60,9 +65,8 @@ class Orchestrator:
60
65
  old_phase = self.state_machine.phase
61
66
  self.state_machine.transition(target_phase)
62
67
 
63
- db = get_db(self.project_root)
64
- if db and self.persist_state:
65
- with db.get_session() as session:
68
+ if self.db and self.persist_state:
69
+ with self.db.get_session() as session:
66
70
  repo = StateRepository(session)
67
71
  repo.save_state(
68
72
  self.state_machine.phase.value,
@@ -9,9 +9,12 @@ States (from §12):
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import logging
12
13
  from enum import Enum
13
14
  from typing import Dict, List, Set
14
15
 
16
+ logger = logging.getLogger(__name__)
17
+
15
18
 
16
19
 
17
20
  class ProjectPhase(str, Enum):
@@ -99,6 +102,7 @@ class StateMachine:
99
102
  Raises InvalidTransitionError if the transition is not allowed.
100
103
  """
101
104
  if not self.can_transition(target):
105
+ logger.error("Invalid phase transition: %s -> %s", self._phase.value, target.value)
102
106
  raise InvalidTransitionError(self._phase, target)
103
107
  self._phase = target
104
108
  self._history.append(target)
@@ -18,7 +18,7 @@ from typing import Any, Dict, List, Set, Tuple
18
18
  from devcouncil.domain.requirement import Requirement, AcceptanceCriterion
19
19
  from devcouncil.domain.task import Task
20
20
  from devcouncil.domain.assumption import Assumption
21
- from devcouncil.domain.evidence import CommandResult, DiffEvidence, TestEvidence
21
+ from devcouncil.domain.evidence import CommandResult, DiffCoverageEvidence, DiffEvidence, TestEvidence
22
22
  from devcouncil.domain.gap import Gap
23
23
  from devcouncil.domain.critique import CritiqueFinding
24
24
 
@@ -35,6 +35,7 @@ class ArtifactGraph:
35
35
  test_evidence: List[TestEvidence] = field(default_factory=list)
36
36
  diff_evidence: List[DiffEvidence] = field(default_factory=list)
37
37
  command_results: List[CommandResult] = field(default_factory=list)
38
+ diff_coverage_evidence: List[DiffCoverageEvidence] = field(default_factory=list)
38
39
 
39
40
  # --- Mutation ---
40
41
 
@@ -62,6 +63,17 @@ class ArtifactGraph:
62
63
  def add_command_result(self, cr: CommandResult) -> None:
63
64
  self.command_results.append(cr)
64
65
 
66
+ def add_diff_coverage_evidence(self, ev: DiffCoverageEvidence) -> None:
67
+ self.diff_coverage_evidence.append(ev)
68
+
69
+ def diff_coverage_findings(self) -> List[DiffCoverageEvidence]:
70
+ """Measured diff-coverage runs where the changed lines were NOT fully exercised
71
+ — i.e. a green suite that did not actually run the new code."""
72
+ return [
73
+ ev for ev in self.diff_coverage_evidence
74
+ if ev.measured and ev.changed_lines and ev.covered_lines < ev.changed_lines
75
+ ]
76
+
65
77
  # --- Coverage Queries ---
66
78
 
67
79
  def requirements_without_tasks(self) -> List[Requirement]:
@@ -80,10 +92,16 @@ class ArtifactGraph:
80
92
  return [r for r in self.requirements.values() if not r.acceptance_criteria]
81
93
 
82
94
  def acceptance_criteria_without_evidence(self) -> List[Tuple[str, AcceptanceCriterion]]:
83
- """AC IDs that have no test evidence mapped to them."""
95
+ """AC IDs that have no *passing* test evidence mapped to them.
96
+
97
+ Only ``passed`` evidence counts: a failed or not-run check is not proof, so
98
+ it must not remove a criterion from the unproven list (which would feed a
99
+ falsely-green coverage summary to ``dev status`` and the MCP surface).
100
+ """
84
101
  evidenced_ac_ids: Set[str] = set()
85
102
  for ev in self.test_evidence:
86
- evidenced_ac_ids.add(ev.acceptance_criterion_id)
103
+ if getattr(ev, "status", "passed") == "passed":
104
+ evidenced_ac_ids.add(ev.acceptance_criterion_id)
87
105
 
88
106
  results: List[Tuple[str, AcceptanceCriterion]] = []
89
107
  for req in self.requirements.values():
@@ -126,6 +144,13 @@ class ArtifactGraph:
126
144
 
127
145
  def coverage_summary(self) -> Dict[str, Any]:
128
146
  """Produce a coverage summary for reporting."""
147
+ # Single pass over open findings; the "high"-filtered count reuses the same
148
+ # severity_order/rank predicate as open_findings("high") (rank >= 2).
149
+ all_open = self.open_findings()
150
+ severity_order = {"low": 0, "medium": 1, "high": 2, "critical": 3}
151
+ high_critical_open = sum(
152
+ 1 for f in all_open if severity_order.get(f.severity, 0) >= severity_order["high"]
153
+ )
129
154
  return {
130
155
  "total_requirements": len(self.requirements),
131
156
  "requirements_without_tasks": len(self.requirements_without_tasks()),
@@ -137,7 +162,9 @@ class ArtifactGraph:
137
162
  "ac_without_evidence": len(self.acceptance_criteria_without_evidence()),
138
163
  "total_gaps": len(self.gaps),
139
164
  "blocking_gaps": len(self.blocking_gaps()),
140
- "open_findings": len(self.open_findings()),
141
- "high_critical_open_findings": len(self.open_findings("high")),
165
+ "diff_coverage_runs": len(self.diff_coverage_evidence),
166
+ "unexercised_diff_findings": len(self.diff_coverage_findings()),
167
+ "open_findings": len(all_open),
168
+ "high_critical_open_findings": high_critical_open,
142
169
  "unconfirmed_high_assumptions": len(self.unconfirmed_high_impact_assumptions()),
143
170
  }
@@ -0,0 +1 @@
1
+ """Packaged visual assets for DevCouncil."""
@@ -0,0 +1,60 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="100%" height="100%">
2
+ <defs>
3
+ <linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" stop-color="#1a0000" />
5
+ <stop offset="100%" stop-color="#000000" />
6
+ </linearGradient>
7
+ <linearGradient id="redGlow" x1="0%" y1="0%" x2="100%" y2="100%">
8
+ <stop offset="0%" stop-color="#ff3333" />
9
+ <stop offset="100%" stop-color="#990000" />
10
+ </linearGradient>
11
+ <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
12
+ <feGaussianBlur stdDeviation="10" result="blur" />
13
+ <feComposite in="SourceGraphic" in2="blur" operator="over" />
14
+ </filter>
15
+ </defs>
16
+
17
+ <rect width="500" height="500" fill="transparent" />
18
+
19
+ <g transform="translate(250, 250)">
20
+ <!-- Outer wireframe hexagon representing the bounds of the orchestrator -->
21
+ <polygon points="0,-180 155.88,-90 155.88,90 0,180 -155.88,90 -155.88,-90"
22
+ fill="none" stroke="#330000" stroke-width="8" stroke-linejoin="round" />
23
+
24
+ <!-- Inner geometric structure - Top Face -->
25
+ <polygon points="0,-180 155.88,-90 0,0 -155.88,-90" fill="#0d0000" stroke="#ff0000" stroke-width="2" />
26
+
27
+ <!-- Inner geometric structure - Right Face -->
28
+ <polygon points="0,0 155.88,-90 155.88,90 0,180" fill="#000000" stroke="#cc0000" stroke-width="2" />
29
+
30
+ <!-- Inner geometric structure - Left Face -->
31
+ <polygon points="0,0 0,180 -155.88,90 -155.88,-90" fill="#1a0000" stroke="#ff3333" stroke-width="2" />
32
+
33
+ <!-- The glowing core node (3D Isometric) -->
34
+ <!-- Top core face -->
35
+ <polygon points="0,-80 69.28,-40 0,0 -69.28,-40" fill="#ff4d4d" />
36
+
37
+ <!-- Right core face -->
38
+ <polygon points="0,0 69.28,-40 69.28,40 0,80" fill="#b30000" />
39
+
40
+ <!-- Left core face -->
41
+ <polygon points="0,0 0,80 -69.28,40 -69.28,-40" fill="#800000" />
42
+
43
+ <!-- Power lines radiating from the core -->
44
+ <line x1="0" y1="-80" x2="0" y2="-180" stroke="#ff0000" stroke-width="4" filter="url(#glow)" />
45
+ <line x1="69.28" y1="40" x2="155.88" y2="90" stroke="#ff0000" stroke-width="4" filter="url(#glow)" />
46
+ <line x1="-69.28" y1="40" x2="-155.88" y2="90" stroke="#ff0000" stroke-width="4" filter="url(#glow)" />
47
+
48
+ <line x1="0" y1="80" x2="0" y2="180" stroke="#ff0000" stroke-width="2" stroke-dasharray="5,5" />
49
+ <line x1="-69.28" y1="-40" x2="-155.88" y2="-90" stroke="#ff0000" stroke-width="2" stroke-dasharray="5,5" />
50
+ <line x1="69.28" y1="-40" x2="155.88" y2="-90" stroke="#ff0000" stroke-width="2" stroke-dasharray="5,5" />
51
+
52
+ <!-- Terminal connection dots -->
53
+ <circle cx="0" cy="-180" r="8" fill="#ff3333" filter="url(#glow)" />
54
+ <circle cx="155.88" cy="90" r="8" fill="#ff3333" filter="url(#glow)" />
55
+ <circle cx="-155.88" cy="90" r="8" fill="#ff3333" filter="url(#glow)" />
56
+ <circle cx="0" cy="180" r="5" fill="#800000" />
57
+ <circle cx="-155.88" cy="-90" r="5" fill="#800000" />
58
+ <circle cx="155.88" cy="-90" r="5" fill="#800000" />
59
+ </g>
60
+ </svg>