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.
Files changed (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -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,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
+ )
@@ -7,7 +7,11 @@ from devcouncil.telemetry.pricing import pricing_for_model
7
7
  class TelemetryTracker:
8
8
  def __init__(self, project_root: Path):
9
9
  self.log_file = project_root / ".devcouncil" / "logs" / "telemetry.json"
10
- 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": {}}
11
15
 
12
16
  def _load(self) -> Dict[str, Any]:
13
17
  if self.log_file.exists():
@@ -24,6 +28,13 @@ class TelemetryTracker:
24
28
  json.dump(self.stats, f, indent=2)
25
29
 
26
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()
27
38
  prompt_tokens = usage.get("prompt_tokens", 0)
28
39
  completion_tokens = usage.get("completion_tokens", 0)
29
40
 
@@ -6,14 +6,18 @@ import time
6
6
  from importlib import resources
7
7
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
8
  from pathlib import Path
9
+ from typing import TYPE_CHECKING
9
10
  from urllib.parse import urlparse
10
11
 
11
12
  from devcouncil.app.project_status import compute_phase
12
13
  from devcouncil.integrations.actions import apply_integration_target
13
14
  from devcouncil.integrations.check import build_integration_check_report, integration_status_summary
14
15
  from devcouncil.storage.db import get_db
15
- from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository, TaskRepository
16
- from devcouncil.telemetry.traces import read_trace_events
16
+ from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
17
+ from devcouncil.telemetry.traces import TraceEvent, read_trace_events_since
18
+
19
+ if TYPE_CHECKING:
20
+ from devcouncil.artifacts.graph import ArtifactGraph
17
21
 
18
22
  LOGO_ASSET = "devcouncil_logo_premium.png"
19
23
  LEGACY_LOGO_ASSET = "devcouncil-logo.svg"
@@ -41,9 +45,25 @@ def _load_run_manifest(manifest_path: Path) -> dict | None:
41
45
  return dict(manifest)
42
46
 
43
47
 
48
+ # The glob+stat over every run manifest is wasteful on each 2-second poll; a
49
+ # short TTL cache of the assembled result keeps the loop cheap.
50
+ _RECENT_RUNS_TTL_SECONDS = 2.0
51
+ _RECENT_RUNS_CACHE: dict[str, tuple[float, int, list[dict]]] = {}
52
+
53
+
54
+ def _invalidate_recent_runs(project_root: Path) -> None:
55
+ _RECENT_RUNS_CACHE.pop(str(project_root), None)
56
+
57
+
44
58
  def recent_run_artifacts(project_root: Path, *, limit: int = 10) -> list[dict]:
59
+ key = str(project_root)
60
+ now = time.monotonic()
61
+ cached = _RECENT_RUNS_CACHE.get(key)
62
+ if cached is not None and cached[1] == limit and now - cached[0] < _RECENT_RUNS_TTL_SECONDS:
63
+ return cached[2]
45
64
  runs_dir = project_root / ".devcouncil" / "runs"
46
65
  if not runs_dir.exists():
66
+ _RECENT_RUNS_CACHE[key] = (now, limit, [])
47
67
  return []
48
68
  manifests: list[dict] = []
49
69
  for manifest_path in sorted(
@@ -58,6 +78,7 @@ def recent_run_artifacts(project_root: Path, *, limit: int = 10) -> list[dict]:
58
78
  manifests.append(manifest)
59
79
  if len(manifests) >= limit:
60
80
  break
81
+ _RECENT_RUNS_CACHE[key] = (now, limit, manifests)
61
82
  return manifests
62
83
 
63
84
 
@@ -82,6 +103,49 @@ def _invalidate_integration_summary(project_root: Path) -> None:
82
103
  _INTEGRATION_SUMMARY_CACHE.pop(str(project_root), None)
83
104
 
84
105
 
106
+ # load_graph() runs six full-table scans, but the dashboard only needs the
107
+ # coverage summary and task list. Cache the materialized graph for a short TTL so
108
+ # the 2-second poll loop reuses it instead of rescanning every table each time.
109
+ _ARTIFACT_GRAPH_TTL_SECONDS = 2.0
110
+ _ARTIFACT_GRAPH_CACHE: dict[str, tuple[float, "ArtifactGraph"]] = {}
111
+
112
+
113
+ def _artifact_graph_cached(project_root: Path, session) -> "ArtifactGraph":
114
+ key = str(project_root)
115
+ now = time.monotonic()
116
+ cached = _ARTIFACT_GRAPH_CACHE.get(key)
117
+ if cached is not None and now - cached[0] < _ARTIFACT_GRAPH_TTL_SECONDS:
118
+ return cached[1]
119
+ graph = ArtifactGraphRepository(session).load_graph()
120
+ _ARTIFACT_GRAPH_CACHE[key] = (now, graph)
121
+ return graph
122
+
123
+
124
+ def _invalidate_artifact_graph(project_root: Path) -> None:
125
+ _ARTIFACT_GRAPH_CACHE.pop(str(project_root), None)
126
+
127
+
128
+ # Re-reading and re-parsing the whole trace file on each poll is O(all events).
129
+ # Keep a per-root byte cursor plus the last 50 parsed events; each refresh reads
130
+ # only the bytes appended since the cursor. Semantics match the previous
131
+ # ``list(read_trace_events(...))[-50:]`` (last 50 events, in order).
132
+ _TRACE_EVENTS_LIMIT = 50
133
+ _TRACE_EVENTS_CACHE: dict[str, tuple[int, list[TraceEvent]]] = {}
134
+
135
+
136
+ def _recent_trace_events_cached(project_root: Path) -> list[TraceEvent]:
137
+ key = str(project_root)
138
+ cursor, buffer = _TRACE_EVENTS_CACHE.get(key, (0, []))
139
+ new_events, next_cursor = read_trace_events_since(project_root, cursor)
140
+ if next_cursor < cursor:
141
+ # File was truncated/rotated: discard the stale buffer and start over.
142
+ buffer = new_events[-_TRACE_EVENTS_LIMIT:]
143
+ elif new_events:
144
+ buffer = (buffer + new_events)[-_TRACE_EVENTS_LIMIT:]
145
+ _TRACE_EVENTS_CACHE[key] = (next_cursor, buffer)
146
+ return buffer
147
+
148
+
85
149
  def logo_svg() -> str:
86
150
  return resources.files("devcouncil.assets").joinpath(LEGACY_LOGO_ASSET).read_text(encoding="utf-8")
87
151
 
@@ -103,16 +167,16 @@ def dashboard_payload(project_root: Path) -> dict:
103
167
  "recent_runs": recent_run_artifacts(project_root),
104
168
  }
105
169
  with db.get_session() as session:
106
- graph = ArtifactGraphRepository(session).load_graph()
170
+ graph = _artifact_graph_cached(project_root, session)
107
171
  state = StateRepository(session).get_state()
108
172
  phase = compute_phase(graph, state.current_phase if state else None)
109
- tasks = [task.model_dump() for task in TaskRepository(session).get_all()]
173
+ tasks = [task.model_dump() for task in graph.tasks.values()]
110
174
  return {
111
175
  "initialized": True,
112
176
  "phase": phase,
113
177
  "coverage": graph.coverage_summary(),
114
178
  "tasks": tasks,
115
- "events": [event.model_dump(by_alias=True) for event in list(read_trace_events(project_root))[-50:]],
179
+ "events": [event.model_dump(by_alias=True) for event in _recent_trace_events_cached(project_root)],
116
180
  "integrations": _integration_summary_cached(project_root),
117
181
  "recent_runs": recent_run_artifacts(project_root),
118
182
  }