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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- 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 +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- 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 +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -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/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Central logging configuration for DevCouncil.
|
|
2
|
+
|
|
3
|
+
The codebase is sprinkled with ``logging.getLogger(__name__)`` calls across the
|
|
4
|
+
orchestrator, planner, executors, verifier, and LLM layers — but historically no
|
|
5
|
+
handler was ever installed, so every ``logger.info``/``logger.debug`` was silently
|
|
6
|
+
discarded and only uncaught WARNING+ records reached stderr via Python's "last
|
|
7
|
+
resort" handler. That made diagnosing the recurring run failures nearly
|
|
8
|
+
impossible: the breadcrumbs existed but went nowhere.
|
|
9
|
+
|
|
10
|
+
:func:`configure_logging` wires up two sinks, once per process:
|
|
11
|
+
|
|
12
|
+
* a **rotating file** at ``.devcouncil/logs/devcouncil.log`` that always captures
|
|
13
|
+
*everything* at DEBUG — this is the durable record you grep after a bad run;
|
|
14
|
+
* a **console** (stderr) handler whose level is dialed by ``-v``/``-q`` flags or
|
|
15
|
+
the ``DEVCOUNCIL_LOG_LEVEL`` env var, defaulting to WARNING so normal Rich CLI
|
|
16
|
+
output stays clean.
|
|
17
|
+
|
|
18
|
+
It is idempotent: calling it again only adjusts the console level (e.g. when a
|
|
19
|
+
command re-points at a different ``--project-root``), so importing modules can
|
|
20
|
+
call it freely without stacking duplicate handlers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from logging.handlers import RotatingFileHandler
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Iterator, Optional
|
|
31
|
+
|
|
32
|
+
# Sentinels so we can find (and reconfigure) our own handlers on repeat calls
|
|
33
|
+
# without disturbing handlers another library may have installed on the root.
|
|
34
|
+
_FILE_HANDLER_TAG = "devcouncil.file"
|
|
35
|
+
_CONSOLE_HANDLER_TAG = "devcouncil.console"
|
|
36
|
+
|
|
37
|
+
_LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(name)s] %(message)s"
|
|
38
|
+
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
39
|
+
|
|
40
|
+
# Default file location (relative to a project root) for the durable run log.
|
|
41
|
+
LOG_RELATIVE_PATH = Path(".devcouncil") / "logs" / "devcouncil.log"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _level_from_verbosity(verbosity: int, quiet: bool) -> int:
|
|
45
|
+
"""Map ``-v`` count / ``-q`` flag to a console log level.
|
|
46
|
+
|
|
47
|
+
quiet -> ERROR; default(0) -> WARNING; -v -> INFO; -vv (or more) -> DEBUG.
|
|
48
|
+
"""
|
|
49
|
+
if quiet:
|
|
50
|
+
return logging.ERROR
|
|
51
|
+
if verbosity <= 0:
|
|
52
|
+
return logging.WARNING
|
|
53
|
+
if verbosity == 1:
|
|
54
|
+
return logging.INFO
|
|
55
|
+
return logging.DEBUG
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_console_level(verbosity: int, quiet: bool, log_level: Optional[str]) -> int:
|
|
59
|
+
"""Console level precedence: explicit arg > env var > verbosity flags."""
|
|
60
|
+
explicit = log_level or os.environ.get("DEVCOUNCIL_LOG_LEVEL")
|
|
61
|
+
if explicit:
|
|
62
|
+
resolved = logging.getLevelName(explicit.strip().upper())
|
|
63
|
+
if isinstance(resolved, int):
|
|
64
|
+
return resolved
|
|
65
|
+
return _level_from_verbosity(verbosity, quiet)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _find_tagged_handler(logger: logging.Logger, tag: str) -> Optional[logging.Handler]:
|
|
69
|
+
for handler in logger.handlers:
|
|
70
|
+
if getattr(handler, "_devcouncil_tag", None) == tag:
|
|
71
|
+
return handler
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configure_logging(
|
|
76
|
+
project_root: Optional[Path] = None,
|
|
77
|
+
*,
|
|
78
|
+
verbosity: int = 0,
|
|
79
|
+
quiet: bool = False,
|
|
80
|
+
log_level: Optional[str] = None,
|
|
81
|
+
) -> Optional[Path]:
|
|
82
|
+
"""Install DevCouncil's file + console log handlers on the root logger.
|
|
83
|
+
|
|
84
|
+
Safe to call repeatedly. The file handler (DEBUG, rotating) is created once;
|
|
85
|
+
subsequent calls only update the console handler's level so a later command
|
|
86
|
+
invocation can raise/lower verbosity. Returns the resolved log file path, or
|
|
87
|
+
``None`` if the file sink could not be created (console logging still works).
|
|
88
|
+
"""
|
|
89
|
+
root = logging.getLogger()
|
|
90
|
+
# The root must pass DEBUG records through to handlers; each handler then
|
|
91
|
+
# applies its own threshold (file=DEBUG, console=user-selected).
|
|
92
|
+
root.setLevel(logging.DEBUG)
|
|
93
|
+
|
|
94
|
+
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
|
|
95
|
+
console_level = _resolve_console_level(verbosity, quiet, log_level)
|
|
96
|
+
|
|
97
|
+
# --- Console handler (stderr) -------------------------------------------
|
|
98
|
+
console = _find_tagged_handler(root, _CONSOLE_HANDLER_TAG)
|
|
99
|
+
if console is None:
|
|
100
|
+
console = logging.StreamHandler() # defaults to stderr, keeps stdout clean
|
|
101
|
+
console._devcouncil_tag = _CONSOLE_HANDLER_TAG # type: ignore[attr-defined]
|
|
102
|
+
console.setFormatter(formatter)
|
|
103
|
+
root.addHandler(console)
|
|
104
|
+
console.setLevel(console_level)
|
|
105
|
+
|
|
106
|
+
# --- Rotating file handler (always DEBUG) -------------------------------
|
|
107
|
+
log_path: Optional[Path] = None
|
|
108
|
+
if _find_tagged_handler(root, _FILE_HANDLER_TAG) is None:
|
|
109
|
+
base = Path(project_root) if project_root is not None else Path.cwd()
|
|
110
|
+
log_path = base / LOG_RELATIVE_PATH
|
|
111
|
+
try:
|
|
112
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
file_handler = RotatingFileHandler(
|
|
114
|
+
log_path,
|
|
115
|
+
maxBytes=5 * 1024 * 1024, # 5 MB per file
|
|
116
|
+
backupCount=5, # keep ~25 MB of history
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
file_handler._devcouncil_tag = _FILE_HANDLER_TAG # type: ignore[attr-defined]
|
|
120
|
+
file_handler.setLevel(logging.DEBUG)
|
|
121
|
+
file_handler.setFormatter(formatter)
|
|
122
|
+
root.addHandler(file_handler)
|
|
123
|
+
except OSError:
|
|
124
|
+
# Read-only FS or unwritable path: degrade to console-only rather
|
|
125
|
+
# than crashing the command the user actually asked for.
|
|
126
|
+
log_path = None
|
|
127
|
+
else:
|
|
128
|
+
base = Path(project_root) if project_root is not None else Path.cwd()
|
|
129
|
+
log_path = base / LOG_RELATIVE_PATH
|
|
130
|
+
|
|
131
|
+
# Quieten chatty third-party loggers on the console; the file still gets them.
|
|
132
|
+
for noisy in ("httpx", "httpcore", "urllib3", "asyncio"):
|
|
133
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
134
|
+
|
|
135
|
+
_install_excepthook()
|
|
136
|
+
|
|
137
|
+
logging.getLogger(__name__).debug(
|
|
138
|
+
"Logging configured (console=%s, file=%s)",
|
|
139
|
+
logging.getLevelName(console_level),
|
|
140
|
+
log_path,
|
|
141
|
+
)
|
|
142
|
+
return log_path
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _install_excepthook() -> None:
|
|
146
|
+
"""Ensure an uncaught exception is written to the log (with traceback) before exit.
|
|
147
|
+
|
|
148
|
+
A crash otherwise only prints a traceback to the terminal — gone once the scrollback
|
|
149
|
+
is. Routing it through logging means the full stack also lands in the durable DEBUG
|
|
150
|
+
file (and any active per-run log), which is exactly what you need to diagnose the
|
|
151
|
+
recurring failures. ``KeyboardInterrupt`` is left to the default handler so Ctrl-C
|
|
152
|
+
stays clean. Installed once; idempotent across repeat ``configure_logging`` calls.
|
|
153
|
+
"""
|
|
154
|
+
import sys
|
|
155
|
+
|
|
156
|
+
if getattr(sys.excepthook, "_devcouncil_hook", False):
|
|
157
|
+
return
|
|
158
|
+
previous = sys.excepthook
|
|
159
|
+
|
|
160
|
+
def _hook(exc_type, exc_value, exc_tb):
|
|
161
|
+
if not issubclass(exc_type, KeyboardInterrupt):
|
|
162
|
+
logging.getLogger("devcouncil.crash").critical(
|
|
163
|
+
"Uncaught exception", exc_info=(exc_type, exc_value, exc_tb)
|
|
164
|
+
)
|
|
165
|
+
previous(exc_type, exc_value, exc_tb)
|
|
166
|
+
|
|
167
|
+
_hook._devcouncil_hook = True # type: ignore[attr-defined]
|
|
168
|
+
sys.excepthook = _hook
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def set_log_dir(project_root: Path) -> Optional[Path]:
|
|
172
|
+
"""Re-point the shared DEBUG file handler at ``project_root/.devcouncil/logs``.
|
|
173
|
+
|
|
174
|
+
The CLI callback configures logging before any command knows its ``--project-root``,
|
|
175
|
+
so the file handler initially lands under the current working directory. When a
|
|
176
|
+
command operates on a *different* root (``dev go --project-root /other/repo``), its
|
|
177
|
+
log should live with that project, not in cwd. Each command calls this once it has
|
|
178
|
+
resolved its root; if the handler already points there (the common ``.`` case) this
|
|
179
|
+
is a cheap no-op. Returns the (re)resolved log path, or ``None`` if it could not be
|
|
180
|
+
created (logging then stays on the previous handler / console).
|
|
181
|
+
"""
|
|
182
|
+
target = Path(project_root) / LOG_RELATIVE_PATH
|
|
183
|
+
root = logging.getLogger()
|
|
184
|
+
existing = _find_tagged_handler(root, _FILE_HANDLER_TAG)
|
|
185
|
+
if existing is not None:
|
|
186
|
+
current = getattr(existing, "baseFilename", None)
|
|
187
|
+
if current and Path(current) == target.resolve():
|
|
188
|
+
return target # already logging to this project's file — nothing to do
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
new_handler = RotatingFileHandler(
|
|
193
|
+
target, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
|
194
|
+
)
|
|
195
|
+
new_handler._devcouncil_tag = _FILE_HANDLER_TAG # type: ignore[attr-defined]
|
|
196
|
+
new_handler.setLevel(logging.DEBUG)
|
|
197
|
+
new_handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
|
198
|
+
except OSError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if existing is not None:
|
|
202
|
+
root.removeHandler(existing)
|
|
203
|
+
existing.close()
|
|
204
|
+
root.addHandler(new_handler)
|
|
205
|
+
logging.getLogger(__name__).debug("Log file re-pointed to %s", target)
|
|
206
|
+
return target
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@contextmanager
|
|
210
|
+
def run_log(log_file: Path) -> Iterator[Optional[Path]]:
|
|
211
|
+
"""Capture everything logged during a single invocation into its own DEBUG file.
|
|
212
|
+
|
|
213
|
+
The shared rotating ``devcouncil.log`` interleaves every command and rotates by
|
|
214
|
+
size, so isolating one run's complete trail there means grepping across rotations
|
|
215
|
+
and unrelated activity. This attaches a second, run-scoped DEBUG file handler for
|
|
216
|
+
the duration of the ``with`` block (e.g. ``.devcouncil/runs/<id>/run.log``) and
|
|
217
|
+
detaches it on exit — giving a clean, self-contained log for exactly that run on
|
|
218
|
+
top of the always-on shared log.
|
|
219
|
+
|
|
220
|
+
Best-effort: if the file can't be opened (read-only FS, bad path) the block still
|
|
221
|
+
runs with only the shared log. Yields the resolved path, or ``None`` on failure.
|
|
222
|
+
"""
|
|
223
|
+
root = logging.getLogger()
|
|
224
|
+
handler: Optional[logging.Handler] = None
|
|
225
|
+
try:
|
|
226
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
228
|
+
handler._devcouncil_tag = "devcouncil.run" # type: ignore[attr-defined]
|
|
229
|
+
handler.setLevel(logging.DEBUG)
|
|
230
|
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
|
231
|
+
root.addHandler(handler)
|
|
232
|
+
except OSError:
|
|
233
|
+
if handler is not None:
|
|
234
|
+
root.removeHandler(handler)
|
|
235
|
+
handler = None
|
|
236
|
+
log_file = None # type: ignore[assignment]
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
yield log_file
|
|
240
|
+
finally:
|
|
241
|
+
if handler is not None:
|
|
242
|
+
handler.flush()
|
|
243
|
+
handler.close()
|
|
244
|
+
root.removeHandler(handler)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
anthropic/claude-3-opus:
|
|
2
|
+
prompt_per_1k: 0.015
|
|
3
|
+
completion_per_1k: 0.075
|
|
4
|
+
anthropic/claude-3.5-sonnet:
|
|
5
|
+
prompt_per_1k: 0.003
|
|
6
|
+
completion_per_1k: 0.015
|
|
7
|
+
anthropic/claude-sonnet-4:
|
|
8
|
+
prompt_per_1k: 0.003
|
|
9
|
+
completion_per_1k: 0.015
|
|
10
|
+
google/gemini-pro-1.5:
|
|
11
|
+
prompt_per_1k: 0.00125
|
|
12
|
+
completion_per_1k: 0.005
|
|
13
|
+
google/gemini-2.5-pro:
|
|
14
|
+
prompt_per_1k: 0.00125
|
|
15
|
+
completion_per_1k: 0.01
|
|
16
|
+
openai/gpt-4o:
|
|
17
|
+
prompt_per_1k: 0.005
|
|
18
|
+
completion_per_1k: 0.015
|
|
19
|
+
openai/o3-mini:
|
|
20
|
+
prompt_per_1k: 0.0011
|
|
21
|
+
completion_per_1k: 0.0044
|
|
22
|
+
anthropic/claude-sonnet-4.6:
|
|
23
|
+
prompt_per_1k: 0.003
|
|
24
|
+
completion_per_1k: 0.015
|
|
25
|
+
anthropic/claude-opus-4.8:
|
|
26
|
+
prompt_per_1k: 0.005
|
|
27
|
+
completion_per_1k: 0.025
|
|
28
|
+
openai/gpt-5.5:
|
|
29
|
+
prompt_per_1k: 0.005
|
|
30
|
+
completion_per_1k: 0.03
|
|
31
|
+
google/gemini-2.5-flash:
|
|
32
|
+
prompt_per_1k: 0.0003
|
|
33
|
+
completion_per_1k: 0.0025
|
|
34
|
+
# Local Ollama models run on-device and are free. Bare tags (no ``ollama/``
|
|
35
|
+
# prefix) are listed here so the cost reporter shows $0; the cost.py prefix
|
|
36
|
+
# guard covers any other ``ollama/`` or ``ollama:`` model id.
|
|
37
|
+
qwen2.5-coder:7b:
|
|
38
|
+
prompt_per_1k: 0.0
|
|
39
|
+
completion_per_1k: 0.0
|
|
40
|
+
qwen2.5-coder:14b:
|
|
41
|
+
prompt_per_1k: 0.0
|
|
42
|
+
completion_per_1k: 0.0
|
|
43
|
+
qwen2.5-coder:32b:
|
|
44
|
+
prompt_per_1k: 0.0
|
|
45
|
+
completion_per_1k: 0.0
|
|
46
|
+
llama3.1:
|
|
47
|
+
prompt_per_1k: 0.0
|
|
48
|
+
completion_per_1k: 0.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from importlib import resources
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
MODEL_PRICING_RESOURCE = "model_pricing.yaml"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=1)
|
|
11
|
+
def load_model_pricing() -> Dict[str, Dict[str, float]]:
|
|
12
|
+
data = resources.files(__package__).joinpath(MODEL_PRICING_RESOURCE).read_text(encoding="utf-8")
|
|
13
|
+
loaded = yaml.safe_load(data) or {}
|
|
14
|
+
return {
|
|
15
|
+
str(model): {
|
|
16
|
+
"prompt_per_1k": float(pricing.get("prompt_per_1k", 0.0)),
|
|
17
|
+
"completion_per_1k": float(pricing.get("completion_per_1k", 0.0)),
|
|
18
|
+
}
|
|
19
|
+
for model, pricing in loaded.items()
|
|
20
|
+
if isinstance(pricing, dict)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pricing_for_model(model: str, default: Dict[str, float] | None = None) -> Dict[str, float]:
|
|
25
|
+
pricing = load_model_pricing().get(model)
|
|
26
|
+
if pricing is not None:
|
|
27
|
+
return pricing
|
|
28
|
+
return default or {"prompt_per_1k": 0.0, "completion_per_1k": 0.0}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Stage and step instrumentation for DevCouncil pipelines.
|
|
2
|
+
|
|
3
|
+
A DevCouncil run moves through coarse *stages* (plan, execute, verify, repair,
|
|
4
|
+
reconcile, report) each made of finer *steps*. When a run misbehaves, the first
|
|
5
|
+
question is always "how far did it get, and what was the last thing it tried?".
|
|
6
|
+
This module gives one consistent way to answer that:
|
|
7
|
+
|
|
8
|
+
* :func:`log_stage` — a context manager that logs ``▶ <stage>`` on entry and
|
|
9
|
+
``✔ <stage> (1.23s)`` / ``✖ <stage> failed (1.23s): ...`` on exit, with wall
|
|
10
|
+
time. It also mirrors the boundary to the structured JSONL trace
|
|
11
|
+
(:class:`~devcouncil.telemetry.traces.TraceLogger`) when a ``project_root`` is
|
|
12
|
+
given, so the human log and the machine trace stay in lock-step.
|
|
13
|
+
* :func:`log_step` — a one-liner for a single step inside a stage.
|
|
14
|
+
|
|
15
|
+
Both are best-effort: instrumentation must never be the reason a run fails, so
|
|
16
|
+
trace-write errors are swallowed (the Python log line still goes out).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Iterator, Optional
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("devcouncil.stage")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_trace(
|
|
31
|
+
project_root: Optional[Path],
|
|
32
|
+
event_type: str,
|
|
33
|
+
details: dict[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
run_id: Optional[str],
|
|
36
|
+
task_id: Optional[str],
|
|
37
|
+
summary: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Mirror a stage/step boundary to the JSONL trace, swallowing any error."""
|
|
40
|
+
if project_root is None:
|
|
41
|
+
return
|
|
42
|
+
try:
|
|
43
|
+
from devcouncil.telemetry.traces import TraceLogger
|
|
44
|
+
|
|
45
|
+
TraceLogger(Path(project_root)).log_event(
|
|
46
|
+
event_type, details, run_id=run_id, task_id=task_id, summary=summary
|
|
47
|
+
)
|
|
48
|
+
except Exception: # pragma: no cover - tracing is strictly best-effort
|
|
49
|
+
logger.debug("Failed to mirror %s to trace", event_type, exc_info=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _context_suffix(context: dict[str, Any]) -> str:
|
|
53
|
+
if not context:
|
|
54
|
+
return ""
|
|
55
|
+
return " " + " ".join(f"{k}={v}" for k, v in context.items())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def log_stage(
|
|
60
|
+
name: str,
|
|
61
|
+
*,
|
|
62
|
+
project_root: Optional[Path] = None,
|
|
63
|
+
run_id: Optional[str] = None,
|
|
64
|
+
task_id: Optional[str] = None,
|
|
65
|
+
**context: Any,
|
|
66
|
+
) -> Iterator[None]:
|
|
67
|
+
"""Log entry/exit (with timing) around a coarse pipeline stage.
|
|
68
|
+
|
|
69
|
+
Usage::
|
|
70
|
+
|
|
71
|
+
with log_stage("plan", project_root=root, run_id=run_id, goal=goal):
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
Logs at INFO on success, ERROR on exception, and always reports elapsed time.
|
|
75
|
+
Re-raises whatever the body raised so control flow is unchanged.
|
|
76
|
+
"""
|
|
77
|
+
suffix = _context_suffix(context)
|
|
78
|
+
logger.info("▶ %s%s", name, suffix)
|
|
79
|
+
_safe_trace(
|
|
80
|
+
project_root,
|
|
81
|
+
"stage_started",
|
|
82
|
+
{"stage": name, **context},
|
|
83
|
+
run_id=run_id,
|
|
84
|
+
task_id=task_id,
|
|
85
|
+
summary=f"Stage started: {name}",
|
|
86
|
+
)
|
|
87
|
+
start = time.monotonic()
|
|
88
|
+
try:
|
|
89
|
+
yield
|
|
90
|
+
except BaseException as exc:
|
|
91
|
+
elapsed = time.monotonic() - start
|
|
92
|
+
logger.error("✖ %s failed (%.2fs): %s", name, elapsed, exc)
|
|
93
|
+
_safe_trace(
|
|
94
|
+
project_root,
|
|
95
|
+
"stage_failed",
|
|
96
|
+
{"stage": name, "error": str(exc), "elapsed_s": round(elapsed, 3), **context},
|
|
97
|
+
run_id=run_id,
|
|
98
|
+
task_id=task_id,
|
|
99
|
+
summary=f"Stage failed: {name}",
|
|
100
|
+
)
|
|
101
|
+
raise
|
|
102
|
+
else:
|
|
103
|
+
elapsed = time.monotonic() - start
|
|
104
|
+
logger.info("✔ %s (%.2fs)", name, elapsed)
|
|
105
|
+
_safe_trace(
|
|
106
|
+
project_root,
|
|
107
|
+
"stage_completed",
|
|
108
|
+
{"stage": name, "elapsed_s": round(elapsed, 3), **context},
|
|
109
|
+
run_id=run_id,
|
|
110
|
+
task_id=task_id,
|
|
111
|
+
summary=f"Stage completed: {name}",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def log_step(
|
|
116
|
+
message: str,
|
|
117
|
+
*,
|
|
118
|
+
project_root: Optional[Path] = None,
|
|
119
|
+
run_id: Optional[str] = None,
|
|
120
|
+
task_id: Optional[str] = None,
|
|
121
|
+
level: int = logging.INFO,
|
|
122
|
+
trace: bool = False,
|
|
123
|
+
**context: Any,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Log a single step within a stage.
|
|
126
|
+
|
|
127
|
+
By default this only writes the Python log (cheap, captured in full by the
|
|
128
|
+
file handler). Set ``trace=True`` for milestone steps worth recording in the
|
|
129
|
+
structured JSONL trace as well.
|
|
130
|
+
"""
|
|
131
|
+
suffix = _context_suffix(context)
|
|
132
|
+
logger.log(level, "• %s%s", message, suffix)
|
|
133
|
+
if trace:
|
|
134
|
+
_safe_trace(
|
|
135
|
+
project_root,
|
|
136
|
+
"step",
|
|
137
|
+
{"message": message, **context},
|
|
138
|
+
run_id=run_id,
|
|
139
|
+
task_id=task_id,
|
|
140
|
+
summary=message,
|
|
141
|
+
)
|
|
@@ -3,7 +3,7 @@ import logging
|
|
|
3
3
|
import uuid
|
|
4
4
|
from datetime import datetime, timezone
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Dict, Iterable, Optional
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel, ConfigDict, Field
|
|
9
9
|
|
|
@@ -31,14 +31,17 @@ class TraceEvent(BaseModel):
|
|
|
31
31
|
def from_legacy(cls, raw: Dict[str, Any]) -> "TraceEvent":
|
|
32
32
|
if raw.get("schema") == TRACE_SCHEMA_VERSION:
|
|
33
33
|
return cls.model_validate(raw)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
raw_details = raw.get("details")
|
|
35
|
+
details: Dict[str, Any] = raw_details if isinstance(raw_details, dict) else {}
|
|
36
|
+
raw_task_id = details.get("task_id")
|
|
37
|
+
raw_summary = details.get("summary")
|
|
38
|
+
raw_run_id = raw.get("run_id")
|
|
37
39
|
return cls(
|
|
40
|
+
schema=TRACE_SCHEMA_VERSION,
|
|
38
41
|
type=str(raw.get("type", "legacy_event")),
|
|
39
|
-
run_id=
|
|
40
|
-
task_id=
|
|
41
|
-
summary=
|
|
42
|
+
run_id=raw_run_id if isinstance(raw_run_id, str) else None,
|
|
43
|
+
task_id=raw_task_id if isinstance(raw_task_id, str) else None,
|
|
44
|
+
summary=raw_summary if isinstance(raw_summary, str) else "",
|
|
42
45
|
details=details,
|
|
43
46
|
)
|
|
44
47
|
|
|
@@ -61,6 +64,7 @@ class TraceLogger:
|
|
|
61
64
|
) -> TraceEvent:
|
|
62
65
|
"""Append an orchestration event trace."""
|
|
63
66
|
trace = TraceEvent(
|
|
67
|
+
schema=TRACE_SCHEMA_VERSION,
|
|
64
68
|
type=event_type,
|
|
65
69
|
details=details,
|
|
66
70
|
run_id=run_id,
|
|
@@ -89,3 +93,54 @@ def read_trace_events(project_root: Path) -> Iterable[TraceEvent]:
|
|
|
89
93
|
except Exception as exc:
|
|
90
94
|
logger.debug("Skipping invalid trace line: %s", exc)
|
|
91
95
|
return events
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_trace_events_since(
|
|
99
|
+
project_root: Path, cursor: Optional[int] = None
|
|
100
|
+
) -> Tuple[List[TraceEvent], int]:
|
|
101
|
+
"""Incrementally read trace events appended after ``cursor``.
|
|
102
|
+
|
|
103
|
+
The cursor is a byte offset into the trace file, which is robust to appends
|
|
104
|
+
(the file is append-only) and makes repeated polling O(new bytes) rather than
|
|
105
|
+
O(all events). Returns ``(events, next_cursor)`` where ``next_cursor`` should
|
|
106
|
+
be passed back on the next call to fetch only newer events.
|
|
107
|
+
|
|
108
|
+
Never raises: on any error it returns an empty batch and a safe cursor. A
|
|
109
|
+
``cursor`` past the current end-of-file (e.g. the log was rotated/truncated)
|
|
110
|
+
is treated as a reset so the caller still makes progress.
|
|
111
|
+
"""
|
|
112
|
+
trace_file = project_root / ".devcouncil" / "logs" / "traces.jsonl"
|
|
113
|
+
start = cursor if isinstance(cursor, int) and cursor >= 0 else 0
|
|
114
|
+
if not trace_file.exists():
|
|
115
|
+
return [], start
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
size = trace_file.stat().st_size
|
|
119
|
+
# File shrank (rotation/truncation) — restart from the beginning.
|
|
120
|
+
if start > size:
|
|
121
|
+
start = 0
|
|
122
|
+
|
|
123
|
+
with open(trace_file, "rb") as f:
|
|
124
|
+
f.seek(start)
|
|
125
|
+
chunk = f.read()
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
logger.debug("Failed incremental trace read: %s", exc)
|
|
128
|
+
return [], start
|
|
129
|
+
|
|
130
|
+
# Only consume up to the last complete line so a half-written final line is
|
|
131
|
+
# re-read (not skipped) on the next poll once it is fully flushed.
|
|
132
|
+
last_newline = chunk.rfind(b"\n")
|
|
133
|
+
if last_newline == -1:
|
|
134
|
+
return [], start
|
|
135
|
+
consumed = chunk[: last_newline + 1]
|
|
136
|
+
next_cursor = start + len(consumed)
|
|
137
|
+
|
|
138
|
+
events: List[TraceEvent] = []
|
|
139
|
+
for raw_line in consumed.decode("utf-8", errors="replace").splitlines():
|
|
140
|
+
if not raw_line.strip():
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
events.append(TraceEvent.from_legacy(json.loads(raw_line)))
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
logger.debug("Skipping invalid trace line: %s", exc)
|
|
146
|
+
return events, next_cursor
|
|
@@ -2,17 +2,16 @@ import json
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from typing import Dict, Any
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
"anthropic/claude-3-opus": {"prompt": 0.015, "completion": 0.075},
|
|
7
|
-
"anthropic/claude-3.5-sonnet": {"prompt": 0.003, "completion": 0.015},
|
|
8
|
-
"openai/gpt-4o": {"prompt": 0.005, "completion": 0.015},
|
|
9
|
-
"google/gemini-pro-1.5": {"prompt": 0.00125, "completion": 0.00375},
|
|
10
|
-
}
|
|
5
|
+
from devcouncil.telemetry.pricing import pricing_for_model
|
|
11
6
|
|
|
12
7
|
class TelemetryTracker:
|
|
13
8
|
def __init__(self, project_root: Path):
|
|
14
9
|
self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
|
|
15
|
-
|
|
10
|
+
# log_usage() reloads the ledger immediately before saving (for concurrent-write
|
|
11
|
+
# safety), so any value read here would always be overwritten before use. Start
|
|
12
|
+
# from the same default shape _load() returns for a missing file instead of
|
|
13
|
+
# doing a dead disk read at construction.
|
|
14
|
+
self.stats: Dict[str, Any] = {"total_cost": 0.0, "total_prompt_tokens": 0, "total_completion_tokens": 0, "models": {}}
|
|
16
15
|
|
|
17
16
|
def _load(self) -> Dict[str, Any]:
|
|
18
17
|
if self.log_file.exists():
|
|
@@ -28,12 +27,27 @@ class TelemetryTracker:
|
|
|
28
27
|
with open(self.log_file, "w") as f:
|
|
29
28
|
json.dump(self.stats, f, indent=2)
|
|
30
29
|
|
|
31
|
-
def log_usage(self, model: str, usage: Dict[str, int]):
|
|
30
|
+
def log_usage(self, model: str, usage: Dict[str, int], *, local: bool = False):
|
|
31
|
+
# Re-read the ledger immediately before mutating so the whole load->mutate->save
|
|
32
|
+
# runs as one synchronous (await-free) step. The router constructs a fresh tracker
|
|
33
|
+
# per call and only calls log_usage *after* the LLM await, so snapshotting the
|
|
34
|
+
# baseline at construction would let concurrent calls (e.g. plan.py's gather, or a
|
|
35
|
+
# parallelized SkillOpt _evaluate) each load the same baseline and clobber each
|
|
36
|
+
# other's entries on save. Reloading here makes the last writer additive, not lossy.
|
|
37
|
+
self.stats = self._load()
|
|
32
38
|
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
33
39
|
completion_tokens = usage.get("completion_tokens", 0)
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
if local:
|
|
42
|
+
# On-device providers (Ollama) incur no per-token cost. Zero by provider, not
|
|
43
|
+
# by model-id matching — consistent with telemetry/cost.py — so a local tag
|
|
44
|
+
# that happens to collide with a priced entry is never billed.
|
|
45
|
+
cost = 0.0
|
|
46
|
+
else:
|
|
47
|
+
rates = pricing_for_model(model)
|
|
48
|
+
cost = (prompt_tokens / 1000.0) * rates["prompt_per_1k"] + (
|
|
49
|
+
completion_tokens / 1000.0
|
|
50
|
+
) * rates["completion_per_1k"]
|
|
37
51
|
|
|
38
52
|
self.stats["total_cost"] += cost
|
|
39
53
|
self.stats["total_prompt_tokens"] += prompt_tokens
|