devcouncil 0.2.0 → 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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
package/pyproject.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
64
|
-
|
|
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(
|
|
161
|
-
"high_critical_open_findings":
|
|
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({
|