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,239 @@
1
+ """Knowledge sources: discover and select OKF / design.md context for prompts.
2
+
3
+ A :class:`KnowledgeSource` is the prompt-facing view of an ingested knowledge file. It
4
+ mirrors :class:`devcouncil.skills.registry.Skill` — same frontmatter contract, same
5
+ trigger-based selection and relevance ranking — so OKF bundles and a project design system
6
+ flow into planning/council/task prompts through the existing budget-aware machinery.
7
+
8
+ On-disk layout (under the project root)::
9
+
10
+ .devcouncil/knowledge/
11
+ design/design.md # one design system, always selected
12
+ okf/*.md # ingested OKF documents, selected by trigger/keyword
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ from devcouncil.knowledge.frontmatter import split_frontmatter
23
+ from devcouncil.skills.registry import SkillTriggers, _keyword_in_text
24
+
25
+ KNOWLEDGE_DIR = ".devcouncil/knowledge"
26
+
27
+ Kind = Literal["okf", "design"]
28
+
29
+
30
+ class KnowledgeSource(BaseModel):
31
+ name: str
32
+ kind: Kind
33
+ description: str = ""
34
+ always: bool = False
35
+ triggers: SkillTriggers = Field(default_factory=SkillTriggers)
36
+ body: str = ""
37
+ priority: int = 50
38
+ source_path: Path | None = None
39
+
40
+ def _match_and_score(self, goal_lower: str) -> tuple[bool, int]:
41
+ """Whether this source applies to ``goal_lower`` and its relevance rank, in one
42
+ keyword scan. Unlike a Skill, ``matches`` is NOT equivalent to ``score > 0`` here:
43
+ OKF sources have a nonzero ``priority`` floor, so the two must be returned together."""
44
+ if self.always:
45
+ return True, 1_000_000 + self.priority
46
+ hits = sum(1 for kw in self.triggers.keywords if _keyword_in_text(kw, goal_lower))
47
+ return hits > 0, self.priority + 5 * hits
48
+
49
+ def matches(self, goal: str) -> bool:
50
+ """True if this source applies to the given goal text.
51
+
52
+ Design systems are always-on (a coding agent should always honor them); OKF
53
+ knowledge is matched on goal keywords like a domain skill.
54
+ """
55
+ return self._match_and_score(goal.lower())[0]
56
+
57
+ def relevance_score(self, goal: str) -> int:
58
+ return self._match_and_score(goal.lower())[1]
59
+
60
+ def render(self) -> str:
61
+ """A titled markdown block for inclusion in a prompt preamble."""
62
+ header = f"## {self.description or self.name}".rstrip()
63
+ return f"{header}\n\n{self.body.strip()}".strip()
64
+
65
+
66
+ # Parsed-source cache keyed on (resolved path, mtime_ns, kind, always, priority). Knowledge
67
+ # discovery runs once per task during planning (and repeatedly via MCP), so without this an
68
+ # N-task plan re-reads + re-parses every knowledge file N times. Keyed on mtime so an edited
69
+ # or freshly-ingested file is re-parsed; the args are in the key because they shape the
70
+ # resulting source. Cached sources are read-only (callers only match/score/render them).
71
+ _source_cache: dict[tuple[str, int, str, bool, int], KnowledgeSource] = {}
72
+ _SOURCE_CACHE_MAX = 512
73
+
74
+ # Directory-scan cache for discover_knowledge_sources(). The glob/rglob filesystem walk
75
+ # repeats 5-30x per planning session (once per task, plus MCP calls) over a directory tree
76
+ # that almost never changes mid-session, while per-file PARSING is already memoized by
77
+ # _source_cache. This caches the discovered source *list* keyed on the call's shaping args.
78
+ # Each entry also stores a signature: the sorted set of (resolved-path, mtime_ns) for every
79
+ # scanned file. On the next call we recompute that signature (a cheap stat per file vs. the
80
+ # directory walk + frontmatter parse) and invalidate if it differs, so a freshly written,
81
+ # edited, added or removed file is picked up. Keying on the path set (not just mtimes) means
82
+ # add/remove is detected even if the filesystem's mtime granularity is too coarse to notice
83
+ # an in-place rewrite within the same tick. Cached lists are read-only to callers.
84
+ _DiscoverKey = tuple[str, str, bool, int, int]
85
+ _DiscoverSig = tuple[tuple[str, int], ...]
86
+ _discover_cache: dict[_DiscoverKey, tuple[_DiscoverSig, list[KnowledgeSource]]] = {}
87
+ _DISCOVER_CACHE_MAX = 256
88
+
89
+
90
+ def clear_knowledge_caches() -> None:
91
+ """Drop the cached parsed knowledge sources (useful in long-running processes/tests)."""
92
+ _source_cache.clear()
93
+ _discover_cache.clear()
94
+
95
+
96
+ def _source_from_file(path: Path, kind: Kind, always: bool, priority: int) -> KnowledgeSource:
97
+ try:
98
+ key: tuple[str, int, str, bool, int] | None = (
99
+ str(path.resolve()), path.stat().st_mtime_ns, kind, always, priority,
100
+ )
101
+ except OSError:
102
+ key = None
103
+ if key is not None:
104
+ cached = _source_cache.get(key)
105
+ if cached is not None:
106
+ return cached
107
+ source = _parse_source_file(path, kind, always, priority)
108
+ if key is not None:
109
+ if len(_source_cache) >= _SOURCE_CACHE_MAX:
110
+ _source_cache.clear()
111
+ _source_cache[key] = source
112
+ return source
113
+
114
+
115
+ def _parse_source_file(path: Path, kind: Kind, always: bool, priority: int) -> KnowledgeSource:
116
+ meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
117
+ triggers = meta.get("triggers") or {}
118
+ # Derive keywords from explicit triggers and, for OKF, the document's tags — so an
119
+ # OKF doc tagged [sales, revenue] fires on goals mentioning those domains for free.
120
+ keywords = list(triggers.get("keywords") or [])
121
+ if kind == "okf":
122
+ tags = meta.get("tags") or []
123
+ if isinstance(tags, str):
124
+ tags = [tags]
125
+ keywords.extend(str(t) for t in tags)
126
+ description = str(meta.get("description") or meta.get("title") or meta.get("type") or path.stem)
127
+ return KnowledgeSource(
128
+ name=str(meta.get("name") or path.stem),
129
+ kind=kind,
130
+ description=description,
131
+ always=always,
132
+ triggers=SkillTriggers(keywords=keywords, globs=list(triggers.get("globs") or [])),
133
+ body=body.strip(),
134
+ priority=priority,
135
+ source_path=path,
136
+ )
137
+
138
+
139
+ def discover_knowledge_sources(
140
+ project_root: Path,
141
+ directory: str = KNOWLEDGE_DIR,
142
+ design_always: bool = True,
143
+ design_priority: int = 80,
144
+ okf_priority: int = 50,
145
+ ) -> list[KnowledgeSource]:
146
+ """Find ingested knowledge under ``<project_root>/<directory>/{design,okf}``."""
147
+ base = project_root / directory
148
+ # Collect the files to ingest (the directory walk we want to cache), as
149
+ # (path, kind, always, priority) in stable order, before parsing any of them.
150
+ scanned: list[tuple[Path, Kind, bool, int]] = []
151
+ design_dir = base / "design"
152
+ if design_dir.exists():
153
+ for path in sorted(design_dir.glob("*.md")):
154
+ scanned.append((path, "design", design_always, design_priority))
155
+ okf_dir = base / "okf"
156
+ if okf_dir.exists():
157
+ for path in sorted(okf_dir.rglob("*.md")):
158
+ # Skip OKF index files: they are navigation, not knowledge worth injecting.
159
+ if path.name.lower() == "index.md":
160
+ continue
161
+ scanned.append((path, "okf", False, okf_priority))
162
+
163
+ # Signature of the current scan: (resolved path, mtime_ns) for each file. A miss here
164
+ # (file added/removed/edited) invalidates the cached list for this key.
165
+ sig_parts: list[tuple[str, int]] = []
166
+ for path, _kind, _always, _priority in scanned:
167
+ try:
168
+ sig_parts.append((str(path.resolve()), path.stat().st_mtime_ns))
169
+ except OSError:
170
+ # Vanished between glob and stat: skip from the signature; parsing handles it.
171
+ continue
172
+ signature: _DiscoverSig = tuple(sig_parts)
173
+
174
+ key: _DiscoverKey = (
175
+ str(project_root.resolve()), directory, design_always, design_priority, okf_priority,
176
+ )
177
+ cached = _discover_cache.get(key)
178
+ if cached is not None and cached[0] == signature:
179
+ return cached[1]
180
+
181
+ sources = [
182
+ _source_from_file(path, kind, always, priority)
183
+ for path, kind, always, priority in scanned
184
+ ]
185
+ if len(_discover_cache) >= _DISCOVER_CACHE_MAX:
186
+ _discover_cache.clear()
187
+ _discover_cache[key] = (signature, sources)
188
+ return sources
189
+
190
+
191
+ def select_knowledge_sources(
192
+ goal: str = "",
193
+ project_root: Path | None = None,
194
+ directory: str = KNOWLEDGE_DIR,
195
+ design_always: bool = True,
196
+ ) -> list[KnowledgeSource]:
197
+ """Select and rank the knowledge sources that apply to ``goal``.
198
+
199
+ Always-on design sources sort first; OKF sources follow, ranked by relevance. Returns
200
+ an empty list when nothing is ingested.
201
+ """
202
+ if project_root is None:
203
+ return []
204
+ sources = discover_knowledge_sources(project_root, directory, design_always=design_always)
205
+ # One goal.lower() + one keyword scan per source (match and rank computed together).
206
+ goal_lower = goal.lower()
207
+ scored: list[tuple[KnowledgeSource, int]] = []
208
+ for source in sources:
209
+ ok, score = source._match_and_score(goal_lower)
210
+ if ok:
211
+ scored.append((source, score))
212
+ scored.sort(key=lambda item: (not item[0].always, -item[1], item[0].name))
213
+ return [source for source, _ in scored]
214
+
215
+
216
+ def render_knowledge_preamble(
217
+ sources: list[KnowledgeSource],
218
+ max_chars: int = 6000,
219
+ kind: Kind | None = None,
220
+ ) -> str:
221
+ """Concatenate source bodies (optionally filtered to one ``kind``) into a single
222
+ preamble, bounded to ``max_chars`` total. Sources are taken in the order given (so the
223
+ most relevant survive the budget); the first source is always included."""
224
+ separator = "\n\n---\n\n"
225
+ chosen = [s for s in sources if kind is None or s.kind == kind]
226
+ blocks: list[str] = []
227
+ total = 0
228
+ for source in chosen:
229
+ block = source.render()
230
+ if not block:
231
+ continue
232
+ # Count the separator that will join this block to the previous one, so the budget
233
+ # bounds the *rendered* preamble length, not just the sum of block bodies.
234
+ extra = len(separator) if blocks else 0
235
+ if blocks and total + extra + len(block) > max_chars:
236
+ break
237
+ blocks.append(block)
238
+ total += extra + len(block)
239
+ return separator.join(blocks).strip()
@@ -299,11 +299,19 @@ def filter_cards(
299
299
  return filtered, None, None
300
300
 
301
301
 
302
+ def load_card_by_id(project_root: Path, card_id: str) -> CritiqueCard | None:
303
+ """Read a single card directly by id, avoiding a scan of every card file."""
304
+ path = card_path(project_root, card_id)
305
+ if not path.exists():
306
+ return None
307
+ try:
308
+ return CritiqueCard.model_validate(json.loads(path.read_text(encoding="utf-8")))
309
+ except Exception:
310
+ return None
311
+
312
+
302
313
  def get_card(project_root: Path, card_id: str) -> CritiqueCard | None:
303
- for card in load_cards(project_root):
304
- if card.id == card_id:
305
- return card
306
- return None
314
+ return load_card_by_id(project_root, card_id)
307
315
 
308
316
 
309
317
  def update_card_status(project_root: Path, card_id: str, status: CardStatus) -> CritiqueCard | None:
@@ -317,9 +325,15 @@ def update_card_status(project_root: Path, card_id: str, status: CardStatus) ->
317
325
  return updated
318
326
 
319
327
 
320
- def unresolved_blocking_cards(project_root: Path, task_id: str | None = None) -> list[CritiqueCard]:
328
+ def unresolved_blocking_cards(
329
+ project_root: Path,
330
+ task_id: str | None = None,
331
+ *,
332
+ cards: list[CritiqueCard] | None = None,
333
+ ) -> list[CritiqueCard]:
334
+ source = cards if cards is not None else load_cards(project_root)
321
335
  return [
322
- card for card in load_cards(project_root)
336
+ card for card in source
323
337
  if card.status == "open" and card.verdict == "Critical Issues"
324
338
  and (task_id is None or card.task_id in {None, task_id})
325
339
  ]
@@ -8,8 +8,16 @@ from devcouncil.storage.db import get_db
8
8
  from devcouncil.storage.repositories import RequirementRepository, TaskRepository
9
9
 
10
10
 
11
- def build_live_repair_prompt(project_root: Path, card: CritiqueCard) -> str:
12
- """Build a ready-to-paste repair prompt for a live-review critique card."""
11
+ def build_live_repair_prompt(
12
+ project_root: Path,
13
+ card: CritiqueCard,
14
+ requirements: list | None = None,
15
+ ) -> str:
16
+ """Build a ready-to-paste repair prompt for a live-review critique card.
17
+
18
+ ``requirements`` may be pre-fetched (e.g. by the bulk builder) to avoid
19
+ re-querying every requirement once per card. ``None`` fetches them as before.
20
+ """
13
21
  prompt = [
14
22
  f"# Repair Live Review Card {card.id}",
15
23
  "",
@@ -34,7 +42,7 @@ def build_live_repair_prompt(project_root: Path, card: CritiqueCard) -> str:
34
42
  if card.message_for_agent:
35
43
  prompt.extend(["", "## Message For Agent", card.message_for_agent])
36
44
 
37
- task_prompt = _task_prompt(project_root, card.task_id)
45
+ task_prompt = _task_prompt(project_root, card.task_id, requirements=requirements)
38
46
  if task_prompt:
39
47
  prompt.extend(["", "## Original DevCouncil Task Contract", task_prompt])
40
48
 
@@ -59,17 +67,31 @@ def build_bulk_live_repair_prompt(project_root: Path, cards: list[CritiqueCard])
59
67
  "",
60
68
  f"DevCouncil found {len(cards)} blocking live-review card(s). Address each card below.",
61
69
  ]
70
+ requirements = _load_all_requirements(project_root)
62
71
  for index, card in enumerate(cards, start=1):
63
72
  sections.extend([
64
73
  "",
65
74
  f"---\n\n## Card {index}: {card.id}",
66
75
  "",
67
- build_live_repair_prompt(project_root, card).strip(),
76
+ build_live_repair_prompt(project_root, card, requirements=requirements).strip(),
68
77
  ])
69
78
  return "\n".join(sections).rstrip() + "\n"
70
79
 
71
80
 
72
- def _task_prompt(project_root: Path, task_id: str | None) -> str | None:
81
+ def _load_all_requirements(project_root: Path) -> list | None:
82
+ """Fetch all requirements once; returns None when no DB is available."""
83
+ db = get_db(project_root)
84
+ if not db:
85
+ return None
86
+ with db.get_session() as session:
87
+ return RequirementRepository(session).get_all()
88
+
89
+
90
+ def _task_prompt(
91
+ project_root: Path,
92
+ task_id: str | None,
93
+ requirements: list | None = None,
94
+ ) -> str | None:
73
95
  if not task_id:
74
96
  return None
75
97
  db = get_db(project_root)
@@ -79,5 +101,6 @@ def _task_prompt(project_root: Path, task_id: str | None) -> str | None:
79
101
  task = TaskRepository(session).get_by_id(task_id)
80
102
  if not task:
81
103
  return None
82
- requirements = RequirementRepository(session).get_all()
104
+ if requirements is None:
105
+ requirements = RequirementRepository(session).get_all()
83
106
  return PromptBuilder(project_root).build_task_prompt(task, requirements)
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  from pathlib import Path
4
5
 
5
6
  from devcouncil.live.cards import review_turn
6
7
  from devcouncil.live.models import AgentTurn, CritiqueCard
7
8
  from devcouncil.llm.router import ModelRouter
8
9
 
10
+ logger = logging.getLogger(__name__)
11
+
9
12
 
10
13
  class LiveReviewService:
11
14
  """Reviews coding-agent responses with deterministic or model-backed critique cards."""
@@ -23,6 +26,7 @@ class LiveReviewService:
23
26
  ) -> CritiqueCard:
24
27
  fallback = review_turn(turn, project_root, client=client)
25
28
  if not use_llm or self.router is None:
29
+ logger.debug("Live review (deterministic) for %s turn=%s: %s", client, turn.turn_id, fallback.verdict)
26
30
  return fallback
27
31
 
28
32
  prompt = f"""
@@ -46,21 +50,41 @@ Turn: {turn.turn_id}
46
50
  Assistant response:
47
51
  {turn.content}
48
52
  """
49
- try:
50
- reviewed = await self.router.complete_structured(
51
- role=self.role,
52
- messages=[{"role": "user", "content": prompt}],
53
- schema=CritiqueCard,
54
- )
55
- except ValueError:
56
- reviewed = await self.router.complete_structured(
57
- role="implementation_reviewer",
58
- messages=[{"role": "user", "content": prompt}],
59
- schema=CritiqueCard,
60
- )
61
- except Exception:
53
+ samples = self._samples(project_root)
54
+ cards: list[CritiqueCard] = []
55
+ for attempt in range(samples):
56
+ # Vary temperature so independent samples actually differ (and so the router
57
+ # cache returns distinct generations). Attempt 0 stays deterministic.
58
+ temperature = 0.0 if attempt == 0 else min(0.8, 0.3 + 0.2 * attempt)
59
+ try:
60
+ cards.append(await self.router.complete_structured(
61
+ role=self.role,
62
+ messages=[{"role": "user", "content": prompt}],
63
+ schema=CritiqueCard,
64
+ temperature=temperature,
65
+ ))
66
+ except ValueError:
67
+ try:
68
+ cards.append(await self.router.complete_structured(
69
+ role="implementation_reviewer",
70
+ messages=[{"role": "user", "content": prompt}],
71
+ schema=CritiqueCard,
72
+ temperature=temperature,
73
+ ))
74
+ except Exception:
75
+ continue
76
+ except Exception:
77
+ continue
78
+
79
+ if not cards:
80
+ logger.warning("Live review produced no cards for %s turn=%s; using deterministic fallback", client, turn.turn_id)
62
81
  return fallback
63
82
 
83
+ reviewed = self._vote(cards)
84
+ logger.info(
85
+ "Live review (LLM, %d sample(s)) for %s turn=%s: %s",
86
+ len(cards), client, turn.turn_id, reviewed.verdict,
87
+ )
64
88
  return reviewed.model_copy(update={
65
89
  "id": fallback.id,
66
90
  "session_id": turn.session_id,
@@ -68,3 +92,38 @@ Assistant response:
68
92
  "client": client,
69
93
  "source_path": fallback.source_path,
70
94
  })
95
+
96
+ def _samples(self, project_root: Path) -> int:
97
+ try:
98
+ from devcouncil.app.config import load_config
99
+ return max(1, load_config(project_root).verification.reviewer_checks.samples)
100
+ except Exception:
101
+ return 1
102
+
103
+ @staticmethod
104
+ def _vote(cards: list[CritiqueCard]) -> CritiqueCard:
105
+ """Majority-vote the verdict across independent reviews, then return a card whose
106
+ verdict matches the vote (so its concerns/evidence are consistent).
107
+
108
+ A single review is returned as-is. With several, the BLOCKING verdict
109
+ ("Critical Issues") is chosen only on a strict majority, and "Approved" likewise;
110
+ anything else de-escalates to the non-blocking "Concerns". This prevents a lone
111
+ mis-calibrated reviewer from blocking, without ever auto-approving a real concern."""
112
+ if len(cards) == 1:
113
+ return cards[0]
114
+ from collections import Counter
115
+
116
+ counts = Counter(card.verdict for card in cards)
117
+ threshold = len(cards) / 2
118
+ if counts.get("Critical Issues", 0) > threshold:
119
+ verdict = "Critical Issues"
120
+ elif counts.get("Approved", 0) > threshold:
121
+ verdict = "Approved"
122
+ else:
123
+ verdict = "Concerns"
124
+ # Return a representative card with the voted verdict so concerns/evidence align;
125
+ # fall back to the first card if none matches (then override just the verdict).
126
+ for card in cards:
127
+ if card.verdict == verdict:
128
+ return card
129
+ return cards[0].model_copy(update={"verdict": verdict})
@@ -12,8 +12,21 @@ def live_review_summary(project_root: Path, task_id: str | None = None) -> dict:
12
12
  signals = load_signals(project_root)
13
13
  active_id = active_task_id(project_root)
14
14
  scoped_task_id = task_id or active_id
15
- blockers = unresolved_blocking_cards(project_root, task_id=scoped_task_id)
15
+ blockers = unresolved_blocking_cards(project_root, task_id=scoped_task_id, cards=cards)
16
16
  pending_signal_items = [signal.model_dump() for signal in signals]
17
+ open_count = 0
18
+ resolved_count = 0
19
+ ignored_count = 0
20
+ critical_open_count = 0
21
+ for card in cards:
22
+ if card.status == "open":
23
+ open_count += 1
24
+ if card.verdict == "Critical Issues":
25
+ critical_open_count += 1
26
+ elif card.status == "resolved":
27
+ resolved_count += 1
28
+ elif card.status == "ignored":
29
+ ignored_count += 1
17
30
  return {
18
31
  "active_task_id": active_id,
19
32
  "scope_task_id": scoped_task_id,
@@ -21,13 +34,10 @@ def live_review_summary(project_root: Path, task_id: str | None = None) -> dict:
21
34
  "pending_signal_items": pending_signal_items[:10],
22
35
  "cards": {
23
36
  "total": len(cards),
24
- "open": len([card for card in cards if card.status == "open"]),
25
- "resolved": len([card for card in cards if card.status == "resolved"]),
26
- "ignored": len([card for card in cards if card.status == "ignored"]),
27
- "critical_open": len([
28
- card for card in cards
29
- if card.status == "open" and card.verdict == "Critical Issues"
30
- ]),
37
+ "open": open_count,
38
+ "resolved": resolved_count,
39
+ "ignored": ignored_count,
40
+ "critical_open": critical_open_count,
31
41
  },
32
42
  "blocking_cards": [card.model_dump() for card in blockers],
33
43
  "recent_cards": [card.model_dump() for card in cards[:5]],
@@ -31,9 +31,19 @@ def discover_sessions(project_root: Path, client: str = "claude") -> list[AgentS
31
31
  client=client,
32
32
  transcript_path=str(path),
33
33
  updated_at=str(stat.st_mtime),
34
- turns=sum(1 for _ in _safe_lines(path)),
34
+ # len() over the already-materialized splitlines list avoids a
35
+ # second Python-level pass and matches the previous line count.
36
+ turns=len(_safe_lines(path)),
35
37
  ))
36
- return sorted(sessions, key=lambda item: item.updated_at or "", reverse=True)
38
+ def _updated_key(item: AgentSession) -> float:
39
+ # updated_at is a stringified mtime; sort numerically so timestamps with
40
+ # different digit counts (string sort would misorder them) compare correctly.
41
+ try:
42
+ return float(item.updated_at or 0.0)
43
+ except (TypeError, ValueError):
44
+ return 0.0
45
+
46
+ return sorted(sessions, key=_updated_key, reverse=True)
37
47
 
38
48
 
39
49
  def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
@@ -50,22 +60,45 @@ def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
50
60
  return turns
51
61
 
52
62
 
53
- def latest_assistant_turn(path: Path, client: str = "generic") -> AgentTurn | None:
63
+ # mtime-keyed cache: reloading and reversing every turn just to find the last
64
+ # assistant message is wasteful when the transcript hasn't changed.
65
+ _LATEST_ASSISTANT_CACHE: dict[str, tuple[float, AgentTurn | None]] = {}
66
+
67
+
68
+ def _scan_latest_assistant_turn(path: Path, client: str) -> AgentTurn | None:
54
69
  for turn in reversed(load_turns(path, client=client)):
55
70
  if turn.role == "assistant":
56
71
  return turn
57
72
  return None
58
73
 
59
74
 
75
+ def latest_assistant_turn(path: Path, client: str = "generic") -> AgentTurn | None:
76
+ try:
77
+ mtime = path.stat().st_mtime
78
+ except OSError:
79
+ return _scan_latest_assistant_turn(path, client)
80
+ key = f"{path}\x00{client}"
81
+ cached = _LATEST_ASSISTANT_CACHE.get(key)
82
+ if cached is not None and cached[0] == mtime:
83
+ return cached[1]
84
+ result = _scan_latest_assistant_turn(path, client)
85
+ _LATEST_ASSISTANT_CACHE[key] = (mtime, result)
86
+ return result
87
+
88
+
60
89
  def _claude_transcript_candidates(project_root: Path) -> list[Path]:
61
90
  local_runtime = project_root / ".devcouncil" / "live" / "claude"
62
91
  candidates = list(local_runtime.glob("*.jsonl"))
63
92
  if CLAUDE_TRANSCRIPT_ROOT.exists():
64
93
  candidates.extend(CLAUDE_TRANSCRIPT_ROOT.rglob("*.jsonl"))
65
- return sorted(set(candidates), key=lambda path: path.stat().st_mtime if path.exists() else 0, reverse=True)
94
+ # Dedup, then sort by path for a deterministic, stat-free order. discover_sessions
95
+ # re-sorts the resulting sessions by mtime; because that sort is stable, giving it a
96
+ # deterministic input keeps the tie-break (equal-mtime sessions) stable across runs —
97
+ # whereas an unordered set would let ties reorder run to run.
98
+ return sorted(set(candidates))
66
99
 
67
100
 
68
- def _safe_lines(path: Path) -> Iterable[str]:
101
+ def _safe_lines(path: Path) -> list[str]:
69
102
  try:
70
103
  return path.read_text(encoding="utf-8", errors="replace").splitlines()
71
104
  except OSError:
@@ -1,9 +1,12 @@
1
1
  import json
2
2
  import hashlib
3
+ import logging
3
4
  from pathlib import Path
4
5
  from typing import Optional
5
6
  from devcouncil.llm.provider import LLMResponse
6
7
 
8
+ logger = logging.getLogger(__name__)
9
+
7
10
  class LLMCache:
8
11
  def __init__(self, project_root: Path):
9
12
  self.cache_dir = project_root / ".devcouncil" / "cache" / "llm"
@@ -23,20 +26,25 @@ class LLMCache:
23
26
  s = json.dumps(data, sort_keys=True)
24
27
  return hashlib.sha256(s.encode("utf-8")).hexdigest()
25
28
 
26
- def get(self, model: str, messages: list, temp: float, json_mode: bool, provider_fingerprint: str = "") -> Optional[LLMResponse]:
27
- key = self._get_key(model, messages, temp, json_mode, provider_fingerprint)
29
+ def get(self, model: str, messages: list, temp: float, json_mode: bool, provider_fingerprint: str = "", cache_key: Optional[str] = None) -> Optional[LLMResponse]:
30
+ # Hashing the JSON payload is non-trivial; let callers compute the key once
31
+ # (via ``_get_key``) and pass it to both get() and set() to avoid recomputing.
32
+ key = cache_key if cache_key is not None else self._get_key(model, messages, temp, json_mode, provider_fingerprint)
28
33
  cache_file = self.cache_dir / f"{key}.json"
29
34
  if cache_file.exists():
30
35
  try:
31
36
  with open(cache_file, "r") as f:
32
37
  data = json.load(f)
38
+ logger.debug("LLM cache HIT model=%s key=%s", model, key[:12])
33
39
  return LLMResponse(**data)
34
- except Exception:
35
- pass
40
+ except Exception as e:
41
+ logger.warning("LLM cache read failed for key=%s: %s", key[:12], e)
42
+ logger.debug("LLM cache MISS model=%s key=%s", model, key[:12])
36
43
  return None
37
44
 
38
- def set(self, model: str, messages: list, temp: float, json_mode: bool, response: LLMResponse, provider_fingerprint: str = ""):
39
- key = self._get_key(model, messages, temp, json_mode, provider_fingerprint)
45
+ def set(self, model: str, messages: list, temp: float, json_mode: bool, response: LLMResponse, provider_fingerprint: str = "", cache_key: Optional[str] = None):
46
+ key = cache_key if cache_key is not None else self._get_key(model, messages, temp, json_mode, provider_fingerprint)
40
47
  cache_file = self.cache_dir / f"{key}.json"
41
48
  with open(cache_file, "w") as f:
42
49
  json.dump(response.model_dump(), f)
50
+ logger.debug("LLM cache STORE model=%s key=%s", model, key[:12])