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
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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"
|