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
@@ -1,9 +1,12 @@
1
+ import logging
1
2
  from typing import List, Dict
2
3
  from pydantic import BaseModel
3
4
  from devcouncil.domain.requirement import Requirement
4
5
  from devcouncil.domain.task import Task
5
6
  from devcouncil.llm.router import ModelRouter
6
7
 
8
+ logger = logging.getLogger(__name__)
9
+
7
10
  class ArbiterDecision(BaseModel):
8
11
  accepted_finding_ids: List[str]
9
12
  rejected_finding_ids: List[Dict[str, str]] # id, reason
@@ -49,9 +52,14 @@ You are the arbiter engineering manager. Your goal is to produce the final, defi
49
52
  messages = [
50
53
  {"role": "user", "content": prompt}
51
54
  ]
52
-
53
- return await self.router.complete_structured(
55
+
56
+ result = await self.router.complete_structured(
54
57
  role="arbiter",
55
58
  messages=messages,
56
59
  schema=ArbiterDecision
57
60
  )
61
+ logger.info(
62
+ "Arbiter decision: %d final requirement(s), %d final task(s), %d finding(s) accepted",
63
+ len(result.final_requirements), len(result.final_tasks), len(result.accepted_finding_ids),
64
+ )
65
+ return result
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  import uuid
7
8
  from datetime import datetime, timezone
8
9
  from pathlib import Path
@@ -17,6 +18,8 @@ from devcouncil.storage.native import CorrectionManifestRepository
17
18
  from devcouncil.storage.repositories import EvidenceRepository, GapRepository, TaskRepository
18
19
  from devcouncil.utils.redaction import redact_text
19
20
 
21
+ logger = logging.getLogger(__name__)
22
+
20
23
  # Bounds for the prior-attempt context folded into the manifest. These reach the
21
24
  # next executor's prompt verbatim, so they must stay small enough not to crowd out
22
25
  # the task spec / blow the context window while still carrying the signal the agent
@@ -56,6 +59,29 @@ class CorrectionManifest(BaseModel):
56
59
  # Severity ordering: most severe first.
57
60
  _SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
58
61
 
62
+ # Verification methods the EXECUTOR can satisfy by writing/fixing code+tests. A criterion
63
+ # left unproven for one of these reasons (a check that could not run, an inconclusive
64
+ # auto-check) is remediable — worth another repair pass — whereas manual/llm_review
65
+ # criteria cannot be closed by re-running the agent and must not drive the loop.
66
+ _AUTOMATABLE_METHODS = {"unit_test", "integration_test", "static_check"}
67
+
68
+
69
+ def remediable_incomplete_gaps(all_gaps: list[Gap]) -> list[Gap]:
70
+ """Non-blocking ``acceptance_criteria_unproven`` gaps the executor could still close.
71
+
72
+ These are the "incomplete" signals (an acceptance criterion with no passing evidence,
73
+ but nothing actively failing) whose verification method is automatable — so another
74
+ repair pass that adds/repairs a proving test can move the task to done. Manual/llm
75
+ criteria are excluded (re-running the agent cannot prove them)."""
76
+ return [
77
+ g for g in all_gaps
78
+ if not g.blocking
79
+ and g.gap_type == "acceptance_criteria_unproven"
80
+ # Unknown/None method is excluded (not assumed automatable): only drive the loop
81
+ # when we positively know the criterion is one the executor can prove.
82
+ and g.expected_verification_method in _AUTOMATABLE_METHODS
83
+ ]
84
+
59
85
  # Gap-type priority within a severity band. Lower sorts first. Executable-evidence
60
86
  # failures (a failing test / unproven acceptance criterion) are the real defect signal
61
87
  # and must outrank scope (orphan/dependency) and advisory (review/secret) gaps so the
@@ -184,8 +210,13 @@ def build_correction_manifest(
184
210
  *,
185
211
  repair_service=None,
186
212
  prior_attempts: int = 0,
213
+ config=None,
187
214
  ) -> CorrectionManifest:
188
- config = load_config(project_root)
215
+ # ``config`` may be threaded in by a caller that already loaded it (e.g. the repair
216
+ # loop, which would otherwise reload config from disk on every attempt). Fall back
217
+ # to loading it when not supplied — same result, deterministic for a given root.
218
+ if config is None:
219
+ config = load_config(project_root)
189
220
  failed: list[str] = []
190
221
  failed_results: list = []
191
222
  db = get_db(project_root)
@@ -254,7 +285,9 @@ def _union(base: list[str], extra: list[str]) -> list[str]:
254
285
  return merged
255
286
 
256
287
 
257
- def write_correction_manifest(project_root: Path, task_id: str, *, repair_service=None) -> Path | None:
288
+ def write_correction_manifest(
289
+ project_root: Path, task_id: str, *, repair_service=None, config=None, include_incomplete: bool = False
290
+ ) -> Path | None:
258
291
  db = get_db(project_root)
259
292
  if not db:
260
293
  return None
@@ -262,14 +295,24 @@ def write_correction_manifest(project_root: Path, task_id: str, *, repair_servic
262
295
  task = TaskRepository(session).get_by_id(task_id)
263
296
  if not task:
264
297
  return None
265
- gaps = [g for g in GapRepository(session).get_all() if g.task_id == task_id and g.blocking]
298
+ gaps = GapRepository(session).get_blocking_for_task(task_id)
299
+ if not gaps and include_incomplete:
300
+ # No hard block, but the task is "incomplete" — drive a repair pass at the
301
+ # unproven-but-remediable acceptance criteria so arm B does not stall one
302
+ # proof short of done.
303
+ gaps = remediable_incomplete_gaps(GapRepository(session).get_for_task(task_id))
266
304
  if not gaps:
305
+ logger.debug("No gaps to repair for %s; skipping correction manifest", task_id)
267
306
  return None
268
307
  prior_record = CorrectionManifestRepository(session).latest_for_task(task_id)
269
308
  prior_attempts = (prior_record.attempt + 1) if prior_record else 1
270
309
 
310
+ logger.info(
311
+ "Writing correction manifest for %s: %d gap(s), prior_attempts=%d",
312
+ task_id, len(gaps), prior_attempts,
313
+ )
271
314
  manifest = build_correction_manifest(
272
- project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts
315
+ project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts, config=config
273
316
  )
274
317
  run_id = str(uuid.uuid4())
275
318
  run_dir = project_root / ".devcouncil" / "runs" / run_id
@@ -1,8 +1,11 @@
1
+ import logging
1
2
  from typing import List
2
3
  from pydantic import BaseModel
3
4
  from devcouncil.domain.critique import CritiqueFinding
4
5
  from devcouncil.llm.router import ModelRouter
5
6
 
7
+ logger = logging.getLogger(__name__)
8
+
6
9
  class CritiqueOutput(BaseModel):
7
10
  findings: List[CritiqueFinding]
8
11
 
@@ -36,7 +39,7 @@ Every finding must include a falsifiable_check.
36
39
  {"role": "user", "content": prompt}
37
40
  ]
38
41
 
39
- return await self.router.complete_structured(
42
+ result = await self.router.complete_structured(
40
43
  role=role,
41
44
  messages=messages,
42
45
  schema=CritiqueOutput,
@@ -44,6 +47,8 @@ Every finding must include a falsifiable_check.
44
47
  # usable plan, far better than crashing the whole planning run.
45
48
  fallback=CritiqueOutput(findings=[]),
46
49
  )
50
+ logger.info("Critique by %s: %d finding(s)", role, len(result.findings))
51
+ return result
47
52
 
48
53
  async def generate_rebuttal(self, role: str, original_plan_json: str, findings_json: str) -> RebuttalOutput:
49
54
  prompt = f"""
@@ -62,10 +67,12 @@ You are the planner who created the original plan. Review the critique findings.
62
67
  {"role": "user", "content": prompt}
63
68
  ]
64
69
 
65
- return await self.router.complete_structured(
70
+ result = await self.router.complete_structured(
66
71
  role=role,
67
72
  messages=messages,
68
73
  schema=RebuttalOutput,
69
74
  # No rebuttals means findings stand as-is — a safe, conservative default.
70
75
  fallback=RebuttalOutput(rebuttals=[]),
71
76
  )
77
+ logger.info("Rebuttal by %s: %d rebuttal(s)", role, len(result.rebuttals))
78
+ return result
@@ -1,13 +1,72 @@
1
- from typing import List
1
+ import logging
2
+ from typing import List, Tuple
2
3
  from pydantic import BaseModel
4
+ from devcouncil.domain.requirement import Requirement
3
5
  from devcouncil.domain.task import Task
4
6
  from devcouncil.llm.router import ModelRouter
5
7
 
8
+ logger = logging.getLogger(__name__)
9
+
6
10
  class PlanOutput(BaseModel):
7
11
  id: str
8
12
  rationale: str
9
13
  tasks: List[Task]
10
14
 
15
+
16
+ def backfill_acceptance_criteria(
17
+ tasks: List[Task], requirements: List[Requirement]
18
+ ) -> Tuple[List[Task], List[Tuple[str, str]]]:
19
+ """Guarantee every acceptance criterion is owned by a task.
20
+
21
+ The spec elaborates edge-case/error criteria, but a planner (especially a weak one)
22
+ may link only some — or none — of them to tasks via ``acceptance_criterion_ids``,
23
+ silently dropping the rest from per-criterion verification. That is a core reason a
24
+ planned+gated run can be no better than the raw prompt: the elaborated edges never
25
+ become something a task is accountable for building and proving.
26
+
27
+ For each criterion not covered by any task, attach it to a WRITABLE task that
28
+ implements its requirement (falling back to any task on that requirement). A criterion
29
+ whose requirement no task owns is left alone — the requirement-coverage gate already
30
+ flags that. Returns the (possibly rewritten) tasks and the ``(task_id, ac_id)`` links
31
+ that were added.
32
+ """
33
+ covered = {ac_id for task in tasks for ac_id in task.acceptance_criterion_ids}
34
+ assignments: dict[str, List[str]] = {}
35
+ for req in requirements:
36
+ uncovered = [ac.id for ac in req.acceptance_criteria if ac.id not in covered]
37
+ if not uncovered:
38
+ continue
39
+ req_ac_ids = {ac.id for ac in req.acceptance_criteria}
40
+ candidates = [t for t in tasks if req.id in t.requirement_ids]
41
+ writable = [
42
+ t for t in candidates
43
+ if any(pf.allowed_change in ("create", "modify", "delete") for pf in t.planned_files)
44
+ ]
45
+ # Prefer the writable task already implementing some of this requirement's criteria
46
+ # (the primary implementer — most likely where the missing behavior also belongs),
47
+ # so a backfilled criterion lands on the task that actually builds it rather than an
48
+ # unrelated sibling. Fall back to any writable task, then any task on the requirement.
49
+ primary = [t for t in writable if req_ac_ids.intersection(t.acceptance_criterion_ids)]
50
+ target = primary or writable or candidates
51
+ if not target:
52
+ continue # no task owns this requirement; requirement-coverage gap handles it
53
+ assignments.setdefault(target[0].id, []).extend(uncovered)
54
+
55
+ if not assignments:
56
+ return tasks, []
57
+
58
+ backfilled: List[Tuple[str, str]] = []
59
+ new_tasks: List[Task] = []
60
+ for task in tasks:
61
+ add = assignments.get(task.id)
62
+ if add:
63
+ merged = list(dict.fromkeys([*task.acceptance_criterion_ids, *add]))
64
+ new_tasks.append(task.model_copy(update={"acceptance_criterion_ids": merged}))
65
+ backfilled.extend((task.id, ac_id) for ac_id in add)
66
+ else:
67
+ new_tasks.append(task)
68
+ return new_tasks, backfilled
69
+
11
70
  class PlanService:
12
71
  def __init__(self, router: ModelRouter):
13
72
  self.router = router
@@ -41,6 +100,11 @@ Your task is to create a detailed implementation plan.
41
100
  append-only contents) and do NOT invoke flake8/mypy/ruff/eslint/tsc/npm unless the
42
101
  repo is already configured for them.
43
102
  - Ensure each task maps back to at least one requirement.
103
+ - Populate each task's acceptance_criterion_ids with the IDs of the specific acceptance
104
+ criteria that task implements. EVERY acceptance criterion in the requirements above must
105
+ be owned by exactly one task — do NOT drop edge-case, boundary, or error-handling
106
+ criteria. An unowned criterion is a behavior nobody is accountable for building, which is
107
+ how subtle requirements get silently missed.
44
108
 
45
109
  Role-specific instructions:
46
110
  """
@@ -52,9 +116,11 @@ Role-specific instructions:
52
116
  messages = [
53
117
  {"role": "user", "content": prompt}
54
118
  ]
55
-
56
- return await self.router.complete_structured(
119
+
120
+ result = await self.router.complete_structured(
57
121
  role=role,
58
122
  messages=messages,
59
123
  schema=PlanOutput
60
124
  )
125
+ logger.info("Plan generated by %s: %d task(s)", role, len(result.tasks))
126
+ return result
@@ -1,9 +1,12 @@
1
+ import logging
1
2
  from pathlib import Path
2
3
 
3
4
  from pydantic import BaseModel, Field
4
5
 
5
6
  from devcouncil.llm.router import ModelRouter
6
7
 
8
+ logger = logging.getLogger(__name__)
9
+
7
10
  # Cap how much skill text we feed the enhancer so a repo matching many skills
8
11
  # can't blow up the planning prompt. Domain skills are ~50 lines each.
9
12
  _MAX_SKILLS_FOR_INTAKE = 4
@@ -22,6 +25,11 @@ class PromptEnhancement(BaseModel):
22
25
  # deterministically after the model call — the LLM does not populate them.
23
26
  applied_skills: list[str] = Field(default_factory=list)
24
27
  skills_brief: str = ""
28
+ # Curated project knowledge (Open Knowledge Format bundles) and the project design
29
+ # system (design.md), selected from ``.devcouncil/knowledge`` for this goal. Like the
30
+ # skills fields, set deterministically after the model call.
31
+ applied_knowledge: list[str] = Field(default_factory=list)
32
+ knowledge_brief: str = ""
25
33
 
26
34
  def normalized(self, original_goal: str) -> "PromptEnhancement":
27
35
  enhanced_goal = self.enhanced_goal.strip() or original_goal
@@ -63,9 +71,65 @@ class PromptEnhancement(BaseModel):
63
71
  "agent receives the full skill text; the plan must already assume it.",
64
72
  self.skills_brief,
65
73
  ])
74
+ if self.knowledge_brief:
75
+ sections.extend([
76
+ "",
77
+ "## Project knowledge & design system (ground the plan in these)",
78
+ "Curated org/domain knowledge (Open Knowledge Format) and the project's "
79
+ "design.md. Honor design tokens/components and reuse known facts rather than "
80
+ "re-deriving or contradicting them.",
81
+ self.knowledge_brief,
82
+ ])
66
83
  return "\n".join(sections)
67
84
 
68
85
 
86
+ # Stable copy of the enhancement that produced the CURRENTLY ACTIVE plan. Written when a
87
+ # plan is persisted, so the executor reads the guidance tied to the plan it is running —
88
+ # not whichever run happens to have the newest mtime (a later dry-run/replan would otherwise
89
+ # win). Lives next to the plan state under .devcouncil/.
90
+ _ACTIVE_ENHANCEMENT_FILE = "active_prompt_enhancement.json"
91
+
92
+
93
+ def load_latest_prompt_enhancement(project_root: Path) -> "PromptEnhancement | None":
94
+ """Load the prompt-enhancement for the active plan, or None.
95
+
96
+ Prefers the stable ``.devcouncil/active_prompt_enhancement.json`` written when the plan
97
+ was persisted (so the executor gets the guidance tied to the plan it is running). Falls
98
+ back to the most recent per-run artifact for plans persisted before that file existed.
99
+ Best-effort: any read/parse failure returns None so prompt building never breaks."""
100
+ import json
101
+
102
+ def _load(path: Path) -> "PromptEnhancement | None":
103
+ try:
104
+ return PromptEnhancement.model_validate(json.loads(path.read_text(encoding="utf-8")))
105
+ except Exception:
106
+ return None
107
+
108
+ active = project_root / ".devcouncil" / _ACTIVE_ENHANCEMENT_FILE
109
+ if active.is_file():
110
+ loaded = _load(active)
111
+ if loaded is not None:
112
+ return loaded
113
+
114
+ runs = project_root / ".devcouncil" / "runs"
115
+ if not runs.exists():
116
+ return None
117
+ artifacts = [d / "prompt_enhancement.json" for d in runs.iterdir() if (d / "prompt_enhancement.json").is_file()]
118
+ if not artifacts:
119
+ return None
120
+ return _load(max(artifacts, key=lambda p: p.stat().st_mtime))
121
+
122
+
123
+ def save_active_prompt_enhancement(project_root: Path, enhancement: "PromptEnhancement") -> None:
124
+ """Persist the enhancement for the active plan to the stable path. Best-effort."""
125
+ try:
126
+ path = project_root / ".devcouncil" / _ACTIVE_ENHANCEMENT_FILE
127
+ path.parent.mkdir(parents=True, exist_ok=True)
128
+ path.write_text(enhancement.model_dump_json(indent=2), encoding="utf-8")
129
+ except Exception:
130
+ pass
131
+
132
+
69
133
  class PromptEnhancerService:
70
134
  def __init__(self, router: ModelRouter):
71
135
  self.router = router
@@ -80,6 +144,12 @@ class PromptEnhancerService:
80
144
  skills = _select_skills(goal, project_root)
81
145
  skills_intake = _full_intake(skills)
82
146
  skills_brief = _compact_brief(skills)
147
+ if skills:
148
+ logger.info("Prompt enhancer matched %d skill(s): %s", len(skills), ", ".join(s.name for s in skills))
149
+
150
+ knowledge = _select_knowledge(goal, project_root)
151
+ knowledge_intake = _knowledge_intake(knowledge)
152
+ knowledge_brief = _knowledge_brief(knowledge)
83
153
 
84
154
  prompt = f"""
85
155
  Original user goal:
@@ -94,6 +164,9 @@ Code review graph context:
94
164
  Applicable engineering skills (senior-level domain intake for this codebase/goal):
95
165
  {skills_intake or "(no domain skills matched; rely on general engineering judgment)"}
96
166
 
167
+ Project knowledge & design system (curated facts and design tokens for this codebase):
168
+ {knowledge_intake or "(no project knowledge ingested)"}
169
+
97
170
  You are DevCouncil's codebase-specific prompt enhancer.
98
171
  Rewrite the user goal into a better planning prompt before it is sent to the council debate.
99
172
 
@@ -122,6 +195,8 @@ Requirements:
122
195
  update={
123
196
  "applied_skills": [skill.name for skill in skills],
124
197
  "skills_brief": skills_brief,
198
+ "applied_knowledge": [source.name for source in knowledge],
199
+ "knowledge_brief": knowledge_brief,
125
200
  }
126
201
  )
127
202
 
@@ -163,5 +238,54 @@ def _compact_brief(skills: list) -> str:
163
238
  return "\n".join(lines).strip()
164
239
 
165
240
 
241
+ def _select_knowledge(goal: str, project_root: Path | None):
242
+ """OKF/design knowledge selection for planning; never raises (best-effort)."""
243
+ if project_root is None:
244
+ return []
245
+ try:
246
+ from devcouncil.app.config import load_config
247
+ from devcouncil.knowledge.sources import select_knowledge_sources
248
+
249
+ cfg = load_config(project_root).knowledge
250
+ if not cfg.enabled:
251
+ return []
252
+ return select_knowledge_sources(
253
+ goal=goal, project_root=project_root,
254
+ directory=cfg.directory, design_always=cfg.design_always,
255
+ )
256
+ except Exception:
257
+ return []
258
+
259
+
260
+ def _knowledge_intake(sources: list) -> str:
261
+ """Full knowledge bodies (capped) for the one-shot enhancer call."""
262
+ if not sources:
263
+ return ""
264
+ blocks: list[str] = []
265
+ total = 0
266
+ for source in sources[:_MAX_SKILLS_FOR_INTAKE]:
267
+ body = (getattr(source, "body", "") or "").strip()
268
+ if not body:
269
+ continue
270
+ kind = getattr(source, "kind", "knowledge")
271
+ block = f"### {kind}: {getattr(source, 'name', '')}\n{body}"
272
+ total += len(block)
273
+ if total > _MAX_INTAKE_CHARS:
274
+ break
275
+ blocks.append(block)
276
+ return "\n\n".join(blocks).strip()
277
+
278
+
279
+ def _knowledge_brief(sources: list) -> str:
280
+ """One line per knowledge source (kind + name + description) for the debate prompt."""
281
+ lines = []
282
+ for source in sources:
283
+ description = (getattr(source, "description", "") or "").strip()
284
+ kind = getattr(source, "kind", "knowledge")
285
+ head = f"- **{getattr(source, 'name', '')}** ({kind})"
286
+ lines.append(f"{head} — {description}" if description else head)
287
+ return "\n".join(lines).strip()
288
+
289
+
166
290
  def _clean_items(items: list[str]) -> list[str]:
167
291
  return [item.strip() for item in items if item.strip()]
@@ -1,10 +1,13 @@
1
1
  from typing import List
2
2
  import json
3
+ import logging
3
4
  from pydantic import BaseModel
4
5
  from devcouncil.domain.gap import Gap
5
6
  from devcouncil.domain.task import Task
6
7
  from devcouncil.llm.router import ModelRouter
7
8
 
9
+ logger = logging.getLogger(__name__)
10
+
8
11
  class RepairOutput(BaseModel):
9
12
  suggested_tasks: List[Task]
10
13
 
@@ -31,9 +34,12 @@ Your task is to generate focused implementation tasks to fix these gaps.
31
34
  Return a JSON object with 'suggested_tasks'.
32
35
  """
33
36
  messages = [{"role": "user", "content": prompt}]
34
-
35
- return await self.router.complete_structured(
37
+
38
+ logger.info("Generating repair plan from %d gap(s)", len(gaps))
39
+ result = await self.router.complete_structured(
36
40
  role="planner_a", # Pragmatic tech lead is best suited for repair task generation
37
41
  messages=messages,
38
42
  schema=RepairOutput
39
43
  )
44
+ logger.info("Repair plan: %d suggested task(s)", len(result.suggested_tasks))
45
+ return result
@@ -1,9 +1,12 @@
1
+ import logging
1
2
  from typing import List
2
3
  from pydantic import BaseModel
3
4
  from devcouncil.domain.requirement import Requirement
4
5
  from devcouncil.domain.assumption import Assumption
5
6
  from devcouncil.llm.router import ModelRouter
6
7
 
8
+ logger = logging.getLogger(__name__)
9
+
7
10
  class BlockingQuestion(BaseModel):
8
11
  id: str
9
12
  question: str
@@ -62,9 +65,14 @@ Each assumption MUST have a confidence and impact level.
62
65
  messages = [
63
66
  {"role": "user", "content": prompt}
64
67
  ]
65
-
66
- return await self.router.complete_structured(
68
+
69
+ result = await self.router.complete_structured(
67
70
  role="spec_writer",
68
71
  messages=messages,
69
72
  schema=SpecOutput
70
73
  )
74
+ logger.info(
75
+ "Spec generated: %d requirement(s), %d assumption(s), %d blocking question(s)",
76
+ len(result.requirements), len(result.assumptions), len(result.blocking_questions),
77
+ )
78
+ return result
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from pathlib import Path
11
11
 
12
- from devcouncil.app.config import load_config
12
+ from devcouncil.app.config import DevCouncilConfig, load_config
13
13
 
14
14
  WORKFLOW_RELPATH = Path(".github") / "workflows" / "devcouncil.yml"
15
15
 
@@ -86,9 +86,14 @@ def _python_version(project_root: Path) -> str:
86
86
  return "3.12"
87
87
 
88
88
 
89
- def render_workflow(project_root: Path, default_branch: str = "main") -> str:
89
+ def render_workflow(
90
+ project_root: Path,
91
+ default_branch: str = "main",
92
+ config: DevCouncilConfig | None = None,
93
+ ) -> str:
90
94
  """Render the workflow YAML text deterministically from config + detected stacks."""
91
- config = load_config(project_root)
95
+ if config is None:
96
+ config = load_config(project_root)
92
97
  stacks = detect_stacks(project_root)
93
98
  commands = config.commands
94
99
 
@@ -151,7 +156,10 @@ def scaffold_ci(project_root: Path, force: bool = False) -> Path | None:
151
156
  target = project_root / WORKFLOW_RELPATH
152
157
  if target.exists() and not force:
153
158
  return None
154
- default_branch = load_config(project_root).project.default_branch or "main"
159
+ config = load_config(project_root)
160
+ default_branch = config.project.default_branch or "main"
155
161
  target.parent.mkdir(parents=True, exist_ok=True)
156
- target.write_text(render_workflow(project_root, default_branch), encoding="utf-8")
162
+ target.write_text(
163
+ render_workflow(project_root, default_branch, config), encoding="utf-8"
164
+ )
157
165
  return target
@@ -153,11 +153,21 @@ class ScaScanner:
153
153
  # whether a tool "exists"; we skip the PATH gate so canned output flows.
154
154
  self._injected = auditor_runner is not None
155
155
  self._runner: AuditorRunner = auditor_runner or _default_runner(timeout)
156
+ # project_root is immutable per instance, so lockfile existence is stable;
157
+ # memoize per filename to avoid repeated stat calls across auditors.
158
+ self._lockfile_cache: dict[str, bool] = {}
156
159
 
157
160
  # -- detection -----------------------------------------------------------
158
161
 
162
+ def _check_cached_lockfile(self, name: str) -> bool:
163
+ cached = self._lockfile_cache.get(name)
164
+ if cached is None:
165
+ cached = (self.project_root / name).exists()
166
+ self._lockfile_cache[name] = cached
167
+ return cached
168
+
159
169
  def _has_lockfile(self, auditor: _Auditor) -> bool:
160
- return any((self.project_root / name).exists() for name in auditor.lockfiles)
170
+ return any(self._check_cached_lockfile(name) for name in auditor.lockfiles)
161
171
 
162
172
  def _is_runnable(self, auditor: _Auditor) -> bool:
163
173
  """Relevant to the repo (lockfile present) and runnable (on PATH or injected)."""
@@ -19,9 +19,20 @@ class JsonReportGenerator:
19
19
  verdict = "incomplete"
20
20
  else:
21
21
  verdict = "passed"
22
+ # Proof-rigor breakdown: of the criteria that ARE proven, how were they proven?
23
+ # ``compiled``/``vote`` are precise per-criterion checks (trustworthy); ``coarse``
24
+ # means proven only by a passing acceptance-capable command (weak). Surfacing this
25
+ # lets an auditor see that a "passed" verdict rests on rigorous, not coarse, evidence
26
+ # — the difference that matters most when a weak/local reviewer compiled the checks.
27
+ proof_modes: dict[str, int] = {}
28
+ for ev in getattr(graph, "test_evidence", []):
29
+ if getattr(ev, "status", "") == "passed":
30
+ key = getattr(ev, "mode", "") or "unspecified"
31
+ proof_modes[key] = proof_modes.get(key, 0) + 1
22
32
  report = {
23
33
  "verdict": verdict,
24
34
  "coverage_summary": summary,
35
+ "proof_modes": proof_modes,
25
36
  "blocking_gaps": [g.model_dump() for g in graph.blocking_gaps()]
26
37
  }
27
38
  if live_review is not None:
@@ -40,7 +40,20 @@ class MarkdownReportGenerator:
40
40
  md_output += "## Coverage Summary\n"
41
41
  md_output += f"- **Requirements**: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
42
42
  md_output += f"- **Tasks**: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
43
- md_output += f"- **Evidence**: {summary['total_ac'] - summary['ac_without_evidence']}/{summary['total_ac']} AC verified\n\n"
43
+ md_output += f"- **Evidence**: {summary['total_ac'] - summary['ac_without_evidence']}/{summary['total_ac']} AC verified\n"
44
+ # Proof rigor: HOW the verified criteria were proven. Precise per-criterion checks
45
+ # (compiled/vote) are trustworthy; ``coarse`` (a passing acceptance-capable command,
46
+ # not a check tied to the criterion) is weak evidence worth flagging to a reader.
47
+ proof_modes: dict[str, int] = {}
48
+ for ev in getattr(graph, "test_evidence", []):
49
+ if getattr(ev, "status", "") == "passed":
50
+ proof_modes[getattr(ev, "mode", "") or "unspecified"] = (
51
+ proof_modes.get(getattr(ev, "mode", "") or "unspecified", 0) + 1
52
+ )
53
+ if proof_modes:
54
+ rigor = ", ".join(f"{count} {mode}" for mode, count in sorted(proof_modes.items()))
55
+ md_output += f"- **Proof rigor**: {rigor}\n"
56
+ md_output += "\n"
44
57
 
45
58
  md_output += "## Requirements Coverage Table\n"
46
59
  md_output += "| Requirement | Task Mapping | Status |\n"