devcouncil 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- package/uv.lock +1 -1
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
12
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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":
|
|
25
|
-
"resolved":
|
|
26
|
-
"ignored":
|
|
27
|
-
"critical_open":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
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])
|