devcouncil 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -2,9 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import re
6
+ from dataclasses import dataclass
5
7
  from pathlib import Path
6
8
 
7
- from devcouncil.live.models import AgentTurn, CardStatus, CritiqueCard
9
+ from devcouncil.live.models import AgentTurn, CardStatus, CritiqueCard, Verdict
8
10
 
9
11
  RISK_TERMS = (
10
12
  "skip tests",
@@ -31,21 +33,171 @@ EVIDENCE_TERMS = (
31
33
  "verified",
32
34
  )
33
35
 
36
+ # Word-boundary matchers so "done" matches "I'm done" but not "abandoned"/"undone".
37
+ _COMPLETION_RE = re.compile(
38
+ r"\b(done|complete|completed|finished|implemented|fixed|ready|all set|"
39
+ r"ship it|good to go|works now|it works)\b"
40
+ )
41
+ # An agent asserting its verification actually passed (the claim we cross-check).
42
+ _PASS_CLAIM_RE = re.compile(
43
+ r"(tests?\s+(?:are\s+|now\s+)?pass(?:ing|ed|es)?"
44
+ r"|all\s+(?:tests?|checks?|cases?)\s+pass"
45
+ r"|passing\s+tests?"
46
+ r"|\bverified\b|verification\s+(?:pass|succeed)"
47
+ r"|tests?\s+green|green\s+tests?"
48
+ r"|(?:ran|run)\s+[^.\n]{0,40}?\bpass)"
49
+ )
50
+ # Negations that flip a nearby claim ("not done", "tests do not pass", "still failing").
51
+ _NEGATION_RE = re.compile(
52
+ r"\b(not|isn'?t|aren'?t|won'?t|can'?t|cannot|haven'?t|hasn'?t|don'?t|"
53
+ r"doesn'?t|didn'?t|no longer|never|yet to|still need|still failing|"
54
+ r"not yet|unable|fail(?:s|ing|ed)?)\b"
55
+ )
56
+ _NEGATION_WINDOW = 30
57
+
58
+
59
+ def _claim_present(pattern: re.Pattern[str], lower: str) -> bool:
60
+ """True if `pattern` matches and is not negated by a word shortly before it."""
61
+ for match in pattern.finditer(lower):
62
+ prefix = lower[max(0, match.start() - _NEGATION_WINDOW):match.start()]
63
+ if _NEGATION_RE.search(prefix):
64
+ continue
65
+ return True
66
+ return False
67
+
68
+
69
+ @dataclass
70
+ class _TaskGrounding:
71
+ """A snapshot of a task's real verification state from the artifact graph."""
72
+
73
+ task_id: str
74
+ status: str
75
+ blocking_gaps: int
76
+ failing_commands: int
77
+ acs_total: int
78
+ acs_passing: int
79
+
80
+ @property
81
+ def acs_unproven(self) -> int:
82
+ return max(0, self.acs_total - self.acs_passing)
83
+
84
+ @property
85
+ def is_satisfied(self) -> bool:
86
+ return (
87
+ self.status in ("verified", "done")
88
+ and self.blocking_gaps == 0
89
+ and (self.acs_total == 0 or self.acs_passing >= self.acs_total)
90
+ )
91
+
92
+
93
+ def _load_task_grounding(project_root: Path, task_id: str | None) -> _TaskGrounding | None:
94
+ """Load the scoped task's real verification state so claims can be checked
95
+ against evidence instead of trusted on the agent's word. Best-effort: any
96
+ failure (no DB, unknown task) returns None and the caller falls back to the
97
+ pure-heuristic review."""
98
+ if not task_id:
99
+ return None
100
+ try:
101
+ from devcouncil.storage.db import get_db
102
+ from devcouncil.storage.repositories import ArtifactGraphRepository
103
+
104
+ db = get_db(project_root)
105
+ if not db:
106
+ return None
107
+ with db.get_session() as session:
108
+ graph = ArtifactGraphRepository(session).load_graph()
109
+ except Exception:
110
+ return None
111
+
112
+ task = graph.tasks.get(task_id)
113
+ if task is None:
114
+ return None
115
+
116
+ blocking = [g for g in graph.gaps.values() if g.task_id == task_id and g.blocking]
117
+ failing = [g for g in blocking if g.gap_type == "test_failed"]
118
+ ac_ids = set(task.acceptance_criterion_ids)
119
+ passing_ac = {
120
+ ev.acceptance_criterion_id
121
+ for ev in graph.test_evidence
122
+ if ev.acceptance_criterion_id in ac_ids and getattr(ev, "status", "") == "passed"
123
+ }
124
+ return _TaskGrounding(
125
+ task_id=task_id,
126
+ status=task.status,
127
+ blocking_gaps=len(blocking),
128
+ failing_commands=len(failing),
129
+ acs_total=len(ac_ids),
130
+ acs_passing=len(passing_ac),
131
+ )
132
+
34
133
 
35
- def review_turn(turn: AgentTurn, project_root: Path, client: str | None = None) -> CritiqueCard:
36
- """Generate a deterministic critique card for an agent response."""
134
+ def review_turn(
135
+ turn: AgentTurn,
136
+ project_root: Path,
137
+ client: str | None = None,
138
+ task_id: str | None = None,
139
+ ) -> CritiqueCard:
140
+ """Generate a deterministic critique card for an agent response.
141
+
142
+ When ``task_id`` resolves to a known task, completion/verification claims are
143
+ checked against the task's real artifact state (status, blocking gaps, passing
144
+ acceptance-criterion evidence) instead of being trusted by keyword alone. With
145
+ no task state available it falls back to the lightweight keyword heuristic.
146
+ """
37
147
  content = turn.content.strip()
38
148
  lower = content.lower()
39
149
  concerns: list[str] = []
40
150
  alternatives: list[str] = []
41
151
  evidence_requests: list[str] = []
152
+ verdict: Verdict = "Approved"
153
+
154
+ grounding = _load_task_grounding(project_root, task_id)
42
155
 
43
156
  risky_terms = [term for term in RISK_TERMS if term in lower]
44
157
  if risky_terms:
45
158
  concerns.append(f"Response contains risky implementation language: {', '.join(risky_terms[:4])}.")
46
159
  alternatives.append("Replace risky shortcuts with a scoped implementation and explicit rollback or verification path.")
47
160
 
48
- if _looks_like_completion_claim(lower) and not any(term in lower for term in EVIDENCE_TERMS):
161
+ claims_completion = _claim_present(_COMPLETION_RE, lower)
162
+ claims_passing = _claim_present(_PASS_CLAIM_RE, lower)
163
+
164
+ if grounding is not None:
165
+ # Evidence-grounded review: cross-check the agent's claims against reality.
166
+ if claims_passing and grounding.failing_commands > 0:
167
+ concerns.append(
168
+ f"Agent claims verification passes, but DevCouncil recorded "
169
+ f"{grounding.failing_commands} failing verification command(s) for "
170
+ f"task {grounding.task_id}."
171
+ )
172
+ evidence_requests.append(
173
+ f"Re-run 'dev verify {grounding.task_id}' and fix the failing command(s) "
174
+ "before claiming success."
175
+ )
176
+ verdict = "Critical Issues"
177
+ elif (claims_completion or claims_passing) and not grounding.is_satisfied:
178
+ details = [f"task {grounding.task_id} is '{grounding.status}'"]
179
+ if grounding.blocking_gaps:
180
+ details.append(f"{grounding.blocking_gaps} blocking gap(s)")
181
+ if grounding.acs_unproven:
182
+ details.append(
183
+ f"{grounding.acs_unproven}/{grounding.acs_total} acceptance "
184
+ "criteria still lack passing evidence"
185
+ )
186
+ concerns.append(
187
+ "Completion claim is not yet backed by DevCouncil evidence: "
188
+ + ", ".join(details) + "."
189
+ )
190
+ evidence_requests.append(
191
+ f"Run 'dev verify {grounding.task_id}' and resolve the gaps so the "
192
+ "claim is supported by passing evidence."
193
+ )
194
+ elif claims_completion and grounding.is_satisfied:
195
+ alternatives.append(
196
+ f"Completion is corroborated by passing evidence for task {grounding.task_id}; "
197
+ "proceed."
198
+ )
199
+ elif claims_completion and not any(term in lower for term in EVIDENCE_TERMS):
200
+ # No task state to ground against: best-effort keyword heuristic.
49
201
  concerns.append("The response appears to claim completion without naming verification evidence.")
50
202
  evidence_requests.append("State the exact commands, checks, or reviewed artifacts that prove the change.")
51
203
 
@@ -56,8 +208,7 @@ def review_turn(turn: AgentTurn, project_root: Path, client: str | None = None)
56
208
  if "todo" in lower or "follow-up" in lower or "later" in lower:
57
209
  evidence_requests.append("List any remaining TODOs as DevCouncil gaps or repair tasks instead of burying them in chat.")
58
210
 
59
- verdict = "Approved"
60
- if concerns:
211
+ if concerns and verdict == "Approved":
61
212
  verdict = "Concerns"
62
213
  if any(term in lower for term in ("--no-verify", "reset --hard", "force push", "ignore failing")):
63
214
  verdict = "Critical Issues"
@@ -69,9 +220,11 @@ def review_turn(turn: AgentTurn, project_root: Path, client: str | None = None)
69
220
  message_for_agent = _message_for_agent(verdict, concerns, evidence_requests)
70
221
  card_id = _card_id(turn)
71
222
  return CritiqueCard(
223
+ schema="devcouncil.critique_card.v1",
72
224
  id=card_id,
73
225
  session_id=turn.session_id,
74
226
  turn_id=turn.turn_id,
227
+ task_id=task_id,
75
228
  client=client or turn.source,
76
229
  verdict=verdict,
77
230
  summary=summary,
@@ -146,11 +299,19 @@ def filter_cards(
146
299
  return filtered, None, None
147
300
 
148
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
+
149
313
  def get_card(project_root: Path, card_id: str) -> CritiqueCard | None:
150
- for card in load_cards(project_root):
151
- if card.id == card_id:
152
- return card
153
- return None
314
+ return load_card_by_id(project_root, card_id)
154
315
 
155
316
 
156
317
  def update_card_status(project_root: Path, card_id: str, status: CardStatus) -> CritiqueCard | None:
@@ -164,30 +325,25 @@ def update_card_status(project_root: Path, card_id: str, status: CardStatus) ->
164
325
  return updated
165
326
 
166
327
 
167
- 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)
168
335
  return [
169
- card for card in load_cards(project_root)
336
+ card for card in source
170
337
  if card.status == "open" and card.verdict == "Critical Issues"
171
338
  and (task_id is None or card.task_id in {None, task_id})
172
339
  ]
173
340
 
174
341
 
175
342
  def _card_id(turn: AgentTurn) -> str:
176
- digest = hashlib.sha1(f"{turn.session_id}:{turn.turn_id}:{turn.content}".encode("utf-8")).hexdigest()
343
+ digest = hashlib.sha256(f"{turn.session_id}:{turn.turn_id}:{turn.content}".encode("utf-8")).hexdigest()
177
344
  return f"CARD-{digest[:12]}"
178
345
 
179
346
 
180
- def _looks_like_completion_claim(lower: str) -> bool:
181
- return any(phrase in lower for phrase in (
182
- "done",
183
- "completed",
184
- "implemented",
185
- "fixed",
186
- "ready",
187
- "all set",
188
- ))
189
-
190
-
191
347
  def _mentions_broad_change(lower: str) -> bool:
192
348
  return any(phrase in lower for phrase in (
193
349
  "refactor the entire",
@@ -198,7 +354,7 @@ def _mentions_broad_change(lower: str) -> bool:
198
354
  ))
199
355
 
200
356
 
201
- def _message_for_agent(verdict: str, concerns: list[str], evidence_requests: list[str]) -> str:
357
+ def _message_for_agent(verdict: Verdict, concerns: list[str], evidence_requests: list[str]) -> str:
202
358
  if verdict == "Approved":
203
359
  return "Continue, but keep the next response grounded in changed files and verification evidence."
204
360
  pieces = ["Pause and address this review before proceeding."]
@@ -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})
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  from datetime import datetime, timezone
5
6
  from pathlib import Path
@@ -42,9 +43,8 @@ def write_signal(project_root: Path, client: str, payload: dict[str, Any]) -> Pa
42
43
  review_command=_review_command(client.lower(), transcript_path, task_id),
43
44
  )
44
45
  key = transcript_path or session_id or json.dumps(payload, sort_keys=True, default=str)
45
- import hashlib
46
46
 
47
- digest = hashlib.sha1(key.encode("utf-8", errors="replace")).hexdigest()[:12]
47
+ digest = hashlib.sha256(key.encode("utf-8", errors="replace")).hexdigest()[:12]
48
48
  path = directory / f"{client.lower()}-{digest}.json"
49
49
  signal.path = str(path)
50
50
  path.write_text(signal.model_dump_json(indent=2) + "\n", encoding="utf-8")
@@ -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]],
@@ -2,10 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from pathlib import Path
5
- from typing import Any, Iterable
5
+ from typing import Any, Iterable, Literal
6
6
 
7
7
  from devcouncil.live.models import AgentSession, AgentTurn, session_id_from_path
8
8
 
9
+ RoleName = Literal["user", "assistant", "system", "tool", "unknown"]
10
+ KNOWN_ROLES: set[RoleName] = {"user", "assistant", "system", "tool"}
11
+
9
12
 
10
13
  CLAUDE_TRANSCRIPT_ROOT = Path.home() / ".claude" / "projects"
11
14
 
@@ -28,9 +31,19 @@ def discover_sessions(project_root: Path, client: str = "claude") -> list[AgentS
28
31
  client=client,
29
32
  transcript_path=str(path),
30
33
  updated_at=str(stat.st_mtime),
31
- 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)),
32
37
  ))
33
- 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)
34
47
 
35
48
 
36
49
  def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
@@ -47,22 +60,45 @@ def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
47
60
  return turns
48
61
 
49
62
 
50
- 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:
51
69
  for turn in reversed(load_turns(path, client=client)):
52
70
  if turn.role == "assistant":
53
71
  return turn
54
72
  return None
55
73
 
56
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
+
57
89
  def _claude_transcript_candidates(project_root: Path) -> list[Path]:
58
90
  local_runtime = project_root / ".devcouncil" / "live" / "claude"
59
91
  candidates = list(local_runtime.glob("*.jsonl"))
60
92
  if CLAUDE_TRANSCRIPT_ROOT.exists():
61
93
  candidates.extend(CLAUDE_TRANSCRIPT_ROOT.rglob("*.jsonl"))
62
- 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))
63
99
 
64
100
 
65
- def _safe_lines(path: Path) -> Iterable[str]:
101
+ def _safe_lines(path: Path) -> list[str]:
66
102
  try:
67
103
  return path.read_text(encoding="utf-8", errors="replace").splitlines()
68
104
  except OSError:
@@ -98,18 +134,18 @@ def _turn_from_record(raw: dict[str, Any], session_id: str, turn_index: int, cli
98
134
  )
99
135
 
100
136
 
101
- def _role(raw: dict[str, Any]) -> str:
137
+ def _role(raw: dict[str, Any]) -> RoleName:
102
138
  role = raw.get("role")
103
139
  if isinstance(role, str):
104
- return role if role in {"user", "assistant", "system", "tool"} else "unknown"
140
+ return role if role in KNOWN_ROLES else "unknown"
105
141
  message = raw.get("message")
106
142
  if isinstance(message, dict):
107
143
  nested = message.get("role")
108
144
  if isinstance(nested, str):
109
- return nested if nested in {"user", "assistant", "system", "tool"} else "unknown"
145
+ return nested if nested in KNOWN_ROLES else "unknown"
110
146
  record_type = raw.get("type")
111
- if record_type in {"user", "assistant", "system"}:
112
- return str(record_type)
147
+ if record_type in KNOWN_ROLES:
148
+ return record_type
113
149
  return "unknown"
114
150
 
115
151