devcouncil 0.2.0 → 0.3.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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  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 +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
package/README.md CHANGED
@@ -25,6 +25,7 @@ DevCouncil does not replace coding agents. It sits beside tools like Codex CLI,
25
25
  - [Executor adapters](docs/executor-adapters.md): manual, coding CLI, native-preview, Mini-SWE, and OpenHands execution paths.
26
26
  - [Live review](docs/live-review.md): `dev watch` session review, cards, signals, and blocking behavior.
27
27
  - [Model routing](docs/model-routing.md): provider selection, role models, OpenRouter, Vertex AI, Doubleword, and Ollama (local) setup.
28
+ - [Knowledge formats](docs/knowledge-formats.md): Open Knowledge Format (OKF) export/ingest/browse (`dev okf html` renders a bundle as a self-contained static HTML site) and design.md design-system lint/export plus `dev design check` (a CI-friendly gate that fails on hardcoded color/spacing/typography literals bypassing the tokens), injected as planning and coding context, plus the bidirectional OKF <-> engineering-skills bridge (`dev okf export --skills` / `dev okf ingest`).
28
29
  - [Security model](docs/security.md): redaction, permissions, allowlists, and local state.
29
30
  - [Project status](docs/project-status.md): current maturity by subsystem.
30
31
  - [Roadmap](docs/roadmap.md): planned work.
@@ -226,11 +227,21 @@ DevCouncil stores local workflow state in the target repository:
226
227
  - `.devcouncil/repo_map.json`: generated repository map and subsystem navigation index.
227
228
  - `.devcouncil/state.sqlite`: SQLite state for requirements, assumptions, tasks, evidence, gaps, critique findings, and project phase history.
228
229
  - `.devcouncil/checkpoints/`: task snapshots used by verification and rollback.
229
- - `.devcouncil/logs/`: redacted stdout/stderr from verification commands.
230
+ - `.devcouncil/logs/`: the durable run log (`devcouncil.log`, rotating, DEBUG-level) plus redacted stdout/stderr from verification commands.
231
+ - `.devcouncil/runs/<run-id>/run.log`: the full per-run log isolated to a single executor run.
230
232
  - `.devcouncil/runs/<run-id>/agent-run.json`: prompt, executor, profile, exit status, and run metadata for automated agent executions.
231
233
  - `.devcouncil/reports/latest.json`: optional machine-readable report generated by `dev e2e --agent`.
232
234
  - `.devcouncil/integrations/` and `.agents/`: generated integration files such as Warp/Oz MCP JSON and Antigravity MCP config.
233
235
 
236
+ ### Logging & diagnostics
237
+
238
+ Every command logs each stage and step. The full DEBUG trail always lands in `.devcouncil/logs/devcouncil.log` (rotating), each executor run also gets an isolated `.devcouncil/runs/<run-id>/run.log`, and uncaught crashes are captured there with a full traceback. The console stays quiet by default — raise it per command:
239
+
240
+ - `dev <command> -v` (INFO) or `-vv` (DEBUG); `-q` for errors only; `--log-level DEBUG`. The `DEVCOUNCIL_LOG_LEVEL` env var sets a default.
241
+ - `dev logs tail [-n N] [-f] [--grep TEXT]` — read/follow/filter the shared log.
242
+ - `dev logs tail --run <run-id>` — read one run's log; `dev logs runs` lists them; `dev logs path` prints the location.
243
+ - `dev doctor` reports the log location and size.
244
+
234
245
  ### Maturity
235
246
 
236
247
  The stable daily workflow is planning, manual sidecar execution, verification, repair, rollback, and reporting. Coding CLI executors, MCP, live review, dashboard, PR comments, LSP/AST tools, and GitHub check surfaces are preview features. Native autonomous execution is experimental and still requires DevCouncil verification before work is considered complete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devcouncil",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Gated orchestrator for AI-assisted software development",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/bharathvbcr/DevCouncil#readme",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devcouncil"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "Gated orchestrator for AI-assisted software development"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -5,18 +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
9
10
  import shutil
10
11
  import subprocess
12
+ import time
11
13
  from pathlib import Path
12
- from typing import Dict, List
14
+ from typing import Dict, List, Optional, Tuple
13
15
 
14
- from pydantic import BaseModel, Field
16
+ from pydantic import BaseModel, Field, field_validator
17
+
18
+ logger = logging.getLogger(__name__)
15
19
 
16
20
 
17
21
  class ModelRoleConfig(BaseModel):
18
22
  model: str
19
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)
20
39
 
21
40
 
22
41
  class ModelsConfig(BaseModel):
@@ -59,9 +78,54 @@ class DiffCoverageConfig(BaseModel):
59
78
  min_ratio: float = 0.0
60
79
 
61
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
+
62
124
  class VerificationConfig(BaseModel):
63
125
  sandbox: VerificationSandboxConfig = Field(default_factory=VerificationSandboxConfig)
64
126
  diff_coverage: DiffCoverageConfig = Field(default_factory=DiffCoverageConfig)
127
+ acceptance_checks: AcceptanceCheckConfig = Field(default_factory=AcceptanceCheckConfig)
128
+ reviewer_checks: ReviewerCheckConfig = Field(default_factory=ReviewerCheckConfig)
65
129
 
66
130
 
67
131
  class GatesConfig(BaseModel):
@@ -88,6 +152,13 @@ class ExecutionConfig(BaseModel):
88
152
  verify_on_post_task: bool = False
89
153
  cursor_resume_mode: str = "off"
90
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
91
162
 
92
163
 
93
164
  class PrivacyConfig(BaseModel):
@@ -200,6 +271,23 @@ class IntegrationsConfig(BaseModel):
200
271
  cli_agents: CliAgentsIntegrationConfig = Field(default_factory=CliAgentsIntegrationConfig)
201
272
 
202
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
289
+
290
+
203
291
  class ProviderConfig(BaseModel):
204
292
  sort: str = "price"
205
293
  allow_fallbacks: bool = True
@@ -219,29 +307,59 @@ class DevCouncilConfig(BaseModel):
219
307
  verification: VerificationConfig = Field(default_factory=VerificationConfig)
220
308
  privacy: PrivacyConfig = Field(default_factory=PrivacyConfig)
221
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]] = {}
222
318
 
223
319
 
224
320
  def load_config(project_root: Path = Path(".")) -> DevCouncilConfig:
225
321
  """Load and validate .devcouncil/config.yaml.
226
-
322
+
227
323
  Returns DevCouncilConfig with defaults for any missing fields.
228
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.
229
329
  """
230
330
  import yaml # type: ignore[import-untyped]
231
331
 
232
332
  config_path = project_root / ".devcouncil" / "config.yaml"
233
- if not config_path.exists():
333
+ try:
334
+ stat = config_path.stat()
335
+ except FileNotFoundError:
234
336
  raise FileNotFoundError(f"Config not found at {config_path}. Run 'dev init' first.")
235
337
 
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)
236
350
  with open(config_path, encoding="utf-8") as f:
237
351
  try:
238
352
  raw = yaml.safe_load(f) or {}
239
353
  except yaml.YAMLError as exc:
354
+ logger.error("Invalid YAML in %s: %s", config_path, exc)
240
355
  raise ValueError(
241
356
  f"Invalid YAML in {config_path}: {exc}. Fix the syntax or re-run 'dev init'."
242
357
  ) from exc
243
358
 
244
- return DevCouncilConfig.model_validate(raw)
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
245
363
 
246
364
 
247
365
  def provider_api_key_env_var(provider: str = "openrouter") -> str:
@@ -261,11 +379,27 @@ def _normalized_provider_name(provider: str) -> str:
261
379
  return provider.strip().lower().replace("-", "").replace("_", "")
262
380
 
263
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]]] = {}
385
+
386
+
264
387
  def load_local_secrets(project_root: Path = Path(".")) -> Dict[str, str]:
265
388
  secrets_path = project_root / ".devcouncil" / "secrets.env"
266
- if not secrets_path.exists():
389
+ try:
390
+ stat = secrets_path.stat()
391
+ except FileNotFoundError:
267
392
  return {}
268
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
+
269
403
  secrets: Dict[str, str] = {}
270
404
  for line in secrets_path.read_text(encoding="utf-8").splitlines():
271
405
  stripped = line.strip()
@@ -273,10 +407,18 @@ def load_local_secrets(project_root: Path = Path(".")) -> Dict[str, str]:
273
407
  continue
274
408
  key, value = stripped.split("=", 1)
275
409
  secrets[key.strip()] = value.strip().strip('"').strip("'")
410
+ _SECRETS_CACHE[cache_key] = (signature, dict(secrets))
276
411
  return secrets
277
412
 
278
413
 
279
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
+ """
280
422
  executable = shutil.which("gcloud")
281
423
  if not executable:
282
424
  return None
@@ -294,6 +436,37 @@ def get_gcloud_access_token() -> str | None:
294
436
  return token or None
295
437
 
296
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
+
297
470
  def get_api_key(provider: str = "openrouter", project_root: Path = Path(".")) -> str:
298
471
  """Retrieve the API key for the configured provider from environment.
299
472
 
@@ -302,12 +475,13 @@ def get_api_key(provider: str = "openrouter", project_root: Path = Path(".")) ->
302
475
  env_var = provider_api_key_env_var(provider)
303
476
  key = os.environ.get(env_var) or load_local_secrets(project_root).get(env_var)
304
477
  if not key and _normalized_provider_name(provider) == "vertexai":
305
- key = get_gcloud_access_token()
478
+ key = get_cached_gcloud_access_token()
306
479
  if not key and _normalized_provider_name(provider) == "ollama":
307
480
  # Ollama is a local server and needs no API key; an explicitly-set
308
481
  # OLLAMA_API_KEY still flows through above if present.
309
482
  return ""
310
483
  if not key:
484
+ logger.warning("API key not found for provider %s (env var %s)", provider, env_var)
311
485
  extra = (
312
486
  " You can also authenticate with 'gcloud auth login' for vertexai."
313
487
  if _normalized_provider_name(provider) == "vertexai"
@@ -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)
@@ -144,6 +144,13 @@ class ArtifactGraph:
144
144
 
145
145
  def coverage_summary(self) -> Dict[str, Any]:
146
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
+ )
147
154
  return {
148
155
  "total_requirements": len(self.requirements),
149
156
  "requirements_without_tasks": len(self.requirements_without_tasks()),
@@ -157,7 +164,7 @@ class ArtifactGraph:
157
164
  "blocking_gaps": len(self.blocking_gaps()),
158
165
  "diff_coverage_runs": len(self.diff_coverage_evidence),
159
166
  "unexercised_diff_findings": len(self.diff_coverage_findings()),
160
- "open_findings": len(self.open_findings()),
161
- "high_critical_open_findings": len(self.open_findings("high")),
167
+ "open_findings": len(all_open),
168
+ "high_critical_open_findings": high_critical_open,
162
169
  "unconfirmed_high_assumptions": len(self.unconfirmed_high_impact_assumptions()),
163
170
  }
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import asyncio
13
13
  import json
14
+ import logging
14
15
  import subprocess
15
16
  from pathlib import Path
16
17
 
@@ -30,6 +31,7 @@ from devcouncil.verification.implementation_reviewer import ImplementationReview
30
31
  from devcouncil.verification.verifier import Verifier
31
32
 
32
33
  console = Console()
34
+ logger = logging.getLogger(__name__)
33
35
 
34
36
 
35
37
  def _diff(root: Path, base: str | None) -> str:
@@ -55,6 +57,8 @@ def check(
55
57
  ):
56
58
  """Audit the current changes — scope, risks, missing edge cases, secrets — no planning required."""
57
59
  root = project_root.expanduser().resolve()
60
+ from devcouncil.telemetry.logging_setup import set_log_dir
61
+ set_log_dir(root)
58
62
  initialize_project(root, quiet=True)
59
63
 
60
64
  # A --goal of "#142" or a GitHub issue/PR URL is a reference, not a spec —
@@ -85,14 +89,18 @@ def check(
85
89
 
86
90
  verifier = Verifier(root)
87
91
 
92
+ logger.info("dev check (LLM audit): base=%s goal=%s", base or "working-tree", "set" if goal else "none")
88
93
  diff = _diff(root, base)
89
94
  if not diff.strip():
95
+ logger.info("dev check: clean working tree; nothing to audit")
90
96
  msg = "No changes to check (clean working tree)."
91
97
  typer.echo(json.dumps({"ok": True, "message": msg}) if json_format else msg)
92
98
  return
93
99
 
94
100
  changed_files = verifier.get_changed_files()
95
101
  secret_gaps = verifier.secret_scanner.scan_diff(diff, "check")
102
+ if secret_gaps:
103
+ logger.warning("dev check: %d possible secret(s) in diff across %d changed file(s)", len(secret_gaps), len(changed_files))
96
104
 
97
105
  # Blast radius: what do the changed files ripple into? Surfaced from the
98
106
  # structural graph when the code-review-graph integration is enabled, so a
@@ -105,7 +113,7 @@ def check(
105
113
  config = load_config(root)
106
114
  validate_model_provider(config.models.provider)
107
115
  api_key = get_api_key(config.models.provider, root)
108
- provider = create_provider(config.models.provider, api_key, project_root=root)
116
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
109
117
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
110
118
  router = ModelRouter(provider, role_config, project_root=root)
111
119
  synthetic = Task(
@@ -120,9 +128,12 @@ def check(
120
128
  review = asyncio.run(ImplementationReviewer(router).review_changes(synthetic, [], diff))
121
129
  findings = review.findings
122
130
  except (ProviderRequestError, StructuredOutputError) as exc:
131
+ logger.warning("dev check: LLM review unavailable: %s", exc)
123
132
  review_note = f"LLM review unavailable: {exc}"
124
133
  except Exception as exc: # pragma: no cover - best effort
134
+ logger.warning("dev check: LLM review unavailable: %s", exc)
125
135
  review_note = f"LLM review unavailable: {exc}"
136
+ logger.info("dev check audit complete: %d secret finding(s), %d review finding(s)", len(secret_gaps), len(findings))
126
137
 
127
138
  if json_format:
128
139
  typer.echo(json.dumps({