devcouncil 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -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
- details = raw.get("details") if isinstance(raw.get("details"), dict) else {}
35
- task_id = details.get("task_id") if isinstance(details.get("task_id"), str) else None
36
- summary = details.get("summary") if isinstance(details.get("summary"), str) else ""
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=raw.get("run_id"),
40
- task_id=task_id,
41
- summary=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
- COST_PER_1K_TOKENS = {
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
- self.stats = self._load()
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
- rates = COST_PER_1K_TOKENS.get(model, {"prompt": 0.0, "completion": 0.0})
36
- cost = (prompt_tokens / 1000.0) * rates["prompt"] + (completion_tokens / 1000.0) * rates["completion"]
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