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
@@ -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
@@ -0,0 +1,346 @@
1
+ """Correction manifest generation for repair loops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from devcouncil.app.config import load_config
14
+ from devcouncil.domain.gap import Gap
15
+ from devcouncil.domain.task import Task
16
+ from devcouncil.storage.db import get_db
17
+ from devcouncil.storage.native import CorrectionManifestRepository
18
+ from devcouncil.storage.repositories import EvidenceRepository, GapRepository, TaskRepository
19
+ from devcouncil.utils.redaction import redact_text
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Bounds for the prior-attempt context folded into the manifest. These reach the
24
+ # next executor's prompt verbatim, so they must stay small enough not to crowd out
25
+ # the task spec / blow the context window while still carrying the signal the agent
26
+ # needs (what it changed last time, and why verification rejected it).
27
+ _MAX_PRIOR_DIFF_CHARS = 8000
28
+ _MAX_FAILING_OUTPUT_CHARS = 4000
29
+ # Per failed command, how much of the captured stdout/stderr tail to keep. Test
30
+ # runners put the actual assertion/traceback at the end, so we keep the tail.
31
+ _MAX_PER_COMMAND_OUTPUT_CHARS = 1500
32
+
33
+
34
+ class CorrectionManifest(BaseModel):
35
+ task_id: str
36
+ root_cause: str
37
+ failed_evidence: list[str] = Field(default_factory=list)
38
+ allowed_repair_files: list[str] = Field(default_factory=list)
39
+ forbidden_changes: list[str] = Field(default_factory=list)
40
+ commands_to_rerun: list[str] = Field(default_factory=list)
41
+ prior_failed_attempts: int = 0
42
+ retry_budget: int = 3
43
+ executor_recommendation: str = "manual"
44
+ created_at: str
45
+ # Blocking gaps ordered most-actionable-first (severity, then gap-type priority),
46
+ # so the repair loop is steered at the real defect (a failing test) rather than an
47
+ # arbitrary first gap (e.g. an orphan_diff). The first entry is the root_cause.
48
+ ordered_blocking_gaps: list[str] = Field(default_factory=list)
49
+ # Prior-attempt context (optional, backward-compatible). Without these the repair
50
+ # executor only sees the root_cause text and re-derives the same wrong approach
51
+ # blind. ``prior_diff`` is what the previous attempt actually changed; it lets the
52
+ # agent see (and stop re-applying) its rejected edit. ``failing_output`` is the
53
+ # captured failing test / verification output that explains *why* it was rejected.
54
+ # Both are redacted and size-bounded before being written.
55
+ prior_diff: str = ""
56
+ failing_output: str = ""
57
+
58
+
59
+ # Severity ordering: most severe first.
60
+ _SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
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
+
85
+ # Gap-type priority within a severity band. Lower sorts first. Executable-evidence
86
+ # failures (a failing test / unproven acceptance criterion) are the real defect signal
87
+ # and must outrank scope (orphan/dependency) and advisory (review/secret) gaps so the
88
+ # repair loop targets the failing test, not an orphan_diff.
89
+ _GAP_TYPE_PRIORITY = {
90
+ "test_failed": 0,
91
+ "acceptance_criteria_unproven": 1,
92
+ "diff_not_exercised": 1,
93
+ "task_not_implemented": 2,
94
+ "migration_gap": 2,
95
+ "orphan_diff": 3,
96
+ "planned_file_not_changed": 3,
97
+ "dependency_risk": 3,
98
+ "architecture_drift": 4,
99
+ "assumption_violated": 4,
100
+ "security_risk": 5,
101
+ }
102
+
103
+
104
+ def _ordered_blocking_gaps(blocking_gaps: list[Gap]) -> list[Gap]:
105
+ """Stable-sort blocking gaps by (severity, gap-type priority).
106
+
107
+ ``test_failed`` / ``acceptance_*`` gaps come before orphan/dependency before
108
+ review/secret, so the picked root_cause is the failing behavior rather than an
109
+ incidental scope finding. Unknown severities/types sort last (defensive)."""
110
+ return sorted(
111
+ blocking_gaps,
112
+ key=lambda g: (
113
+ _SEVERITY_RANK.get(g.severity, 9),
114
+ _GAP_TYPE_PRIORITY.get(g.gap_type, 9),
115
+ ),
116
+ )
117
+
118
+
119
+ def _latest_agent_run(project_root: Path, task_id: str) -> dict | None:
120
+ runs_dir = project_root / ".devcouncil" / "runs"
121
+ if not runs_dir.exists():
122
+ return None
123
+ candidates = sorted(runs_dir.glob("*/agent-run.json"), reverse=True)
124
+ for path in candidates:
125
+ try:
126
+ payload = json.loads(path.read_text(encoding="utf-8"))
127
+ except Exception:
128
+ continue
129
+ if payload.get("task_id") == task_id:
130
+ return payload
131
+ return None
132
+
133
+
134
+ def _truncate_tail(text: str, limit: int) -> str:
135
+ """Keep the last ``limit`` chars of ``text`` (the actionable tail of test output),
136
+ prefixing a marker when truncated. Empty/whitespace input returns ""."""
137
+ text = (text or "").strip()
138
+ if len(text) <= limit:
139
+ return text
140
+ return "[devcouncil: output truncated, showing last "f"{limit} chars]\n" + text[-limit:]
141
+
142
+
143
+ def _truncate_head(text: str, limit: int) -> str:
144
+ """Keep the first ``limit`` chars of ``text`` (diffs read top-down), with a marker
145
+ when truncated. Empty/whitespace input returns ""."""
146
+ text = (text or "").strip()
147
+ if len(text) <= limit:
148
+ return text
149
+ return text[:limit] + "\n[devcouncil: diff truncated, "f"{len(text) - limit} chars omitted]"
150
+
151
+
152
+ def _read_text_tail(path: Path, limit: int) -> str:
153
+ """Best-effort read of a captured stdout/stderr file, keeping its tail. Never raises."""
154
+ try:
155
+ if not path.is_file():
156
+ return ""
157
+ return _truncate_tail(path.read_text(encoding="utf-8", errors="replace"), limit)
158
+ except Exception:
159
+ return ""
160
+
161
+
162
+ def _collect_prior_diff(project_root: Path, task_id: str) -> str:
163
+ """The prior attempt's working-tree diff, from the task's ``after`` checkpoint patch.
164
+
165
+ The checkpoint service writes ``<task_id>-after.patch`` after each executor run, so
166
+ this is exactly what the previous attempt changed. Redacted and head-bounded so the
167
+ repair executor can see (and avoid re-applying) its rejected edit without the diff
168
+ swamping the prompt. Returns "" when no patch exists (e.g. first attempt)."""
169
+ patch_path = project_root / ".devcouncil" / "checkpoints" / f"{task_id}-after.patch"
170
+ try:
171
+ if not patch_path.is_file():
172
+ return ""
173
+ raw = patch_path.read_text(encoding="utf-8", errors="replace")
174
+ except Exception:
175
+ return ""
176
+ return _truncate_head(redact_text(raw), _MAX_PRIOR_DIFF_CHARS)
177
+
178
+
179
+ def _collect_failing_output(project_root: Path, failed_results) -> str:
180
+ """The captured stdout/stderr of the failing verification commands.
181
+
182
+ Folds each failed command's summary plus the tail of its captured stdout/stderr so
183
+ the repair executor sees *why* it was rejected (the actual assertion / traceback),
184
+ not just that a command exited non-zero. Redacted and size-bounded. Returns ""
185
+ when there is nothing useful to show."""
186
+ blocks: list[str] = []
187
+ for result in failed_results:
188
+ parts = [f"$ {result.command} (exit {result.exit_code})"]
189
+ if result.summary and result.summary.strip():
190
+ parts.append(result.summary.strip())
191
+ for label, rel in (("stdout", result.stdout_path), ("stderr", result.stderr_path)):
192
+ if not rel:
193
+ continue
194
+ path = Path(rel)
195
+ if not path.is_absolute():
196
+ path = project_root / rel
197
+ tail = _read_text_tail(path, _MAX_PER_COMMAND_OUTPUT_CHARS)
198
+ if tail:
199
+ parts.append(f"--- {label} ---\n{tail}")
200
+ blocks.append("\n".join(parts))
201
+ if not blocks:
202
+ return ""
203
+ return _truncate_tail(redact_text("\n\n".join(blocks)), _MAX_FAILING_OUTPUT_CHARS)
204
+
205
+
206
+ def build_correction_manifest(
207
+ project_root: Path,
208
+ task: Task,
209
+ blocking_gaps: list[Gap],
210
+ *,
211
+ repair_service=None,
212
+ prior_attempts: int = 0,
213
+ config=None,
214
+ ) -> CorrectionManifest:
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)
220
+ failed: list[str] = []
221
+ failed_results: list = []
222
+ db = get_db(project_root)
223
+ if db:
224
+ with db.get_session() as session:
225
+ # Scope failed evidence to THIS task. Scanning every evidence row made a
226
+ # repair for one task chase unrelated failures from another, so the loop
227
+ # never converged on the real defect.
228
+ for result in EvidenceRepository(session).get_command_results_for_task(task.id):
229
+ if result.exit_code != 0:
230
+ failed.append(f"{result.command} (exit {result.exit_code})")
231
+ failed_results.append(result)
232
+
233
+ # Steer the repair at the most actionable failure (a failing test / unproven AC),
234
+ # not an arbitrary first gap such as an orphan_diff.
235
+ ordered_gaps = _ordered_blocking_gaps(blocking_gaps)
236
+ root_cause = ordered_gaps[0].description if ordered_gaps else "Unknown failure"
237
+ manifest = CorrectionManifest(
238
+ task_id=task.id,
239
+ root_cause=root_cause,
240
+ ordered_blocking_gaps=[g.description for g in ordered_gaps],
241
+ failed_evidence=failed,
242
+ allowed_repair_files=[pf.path for pf in task.planned_files],
243
+ forbidden_changes=list(task.forbidden_changes),
244
+ commands_to_rerun=task.expected_tests or task.allowed_commands,
245
+ # The number of repair attempts already made on this task — real, not a
246
+ # hardcoded 0. The agent sees how much of its budget is spent so it knows
247
+ # when to change approach rather than retry the same fix.
248
+ prior_failed_attempts=prior_attempts,
249
+ retry_budget=config.execution.max_repair_attempts,
250
+ executor_recommendation=config.execution.default_executor,
251
+ created_at=datetime.now(timezone.utc).isoformat(),
252
+ # Prior-attempt context so the next executor repairs against what actually
253
+ # happened (its rejected diff + the failing output) instead of re-deriving
254
+ # the same wrong approach blind. Both are redacted and size-bounded.
255
+ prior_diff=_collect_prior_diff(project_root, task.id),
256
+ failing_output=_collect_failing_output(project_root, failed_results),
257
+ )
258
+
259
+ if repair_service is not None:
260
+ try:
261
+ import asyncio
262
+
263
+ plan = asyncio.run(repair_service.generate_repair_plan(blocking_gaps, task.description))
264
+ if plan.suggested_tasks:
265
+ suggested = plan.suggested_tasks[0]
266
+ manifest.root_cause = suggested.description or manifest.root_cause
267
+ # Use the repair plan's concrete scope instead of throwing it away:
268
+ # union its targeted files/tests with the task's so the re-implement
269
+ # step focuses on what actually needs fixing without losing task scope.
270
+ manifest.allowed_repair_files = _union(
271
+ manifest.allowed_repair_files, [pf.path for pf in suggested.planned_files]
272
+ )
273
+ manifest.commands_to_rerun = _union(manifest.commands_to_rerun, suggested.expected_tests)
274
+ except Exception:
275
+ pass
276
+ return manifest
277
+
278
+
279
+ def _union(base: list[str], extra: list[str]) -> list[str]:
280
+ """Append items from ``extra`` not already in ``base`` (order-preserving dedupe)."""
281
+ merged = list(base)
282
+ for item in extra:
283
+ if item and item not in merged:
284
+ merged.append(item)
285
+ return merged
286
+
287
+
288
+ def write_correction_manifest(
289
+ project_root: Path, task_id: str, *, repair_service=None, config=None, include_incomplete: bool = False
290
+ ) -> Path | None:
291
+ db = get_db(project_root)
292
+ if not db:
293
+ return None
294
+ with db.get_session() as session:
295
+ task = TaskRepository(session).get_by_id(task_id)
296
+ if not task:
297
+ return None
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))
304
+ if not gaps:
305
+ logger.debug("No gaps to repair for %s; skipping correction manifest", task_id)
306
+ return None
307
+ prior_record = CorrectionManifestRepository(session).latest_for_task(task_id)
308
+ prior_attempts = (prior_record.attempt + 1) if prior_record else 1
309
+
310
+ logger.info(
311
+ "Writing correction manifest for %s: %d gap(s), prior_attempts=%d",
312
+ task_id, len(gaps), prior_attempts,
313
+ )
314
+ manifest = build_correction_manifest(
315
+ project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts, config=config
316
+ )
317
+ run_id = str(uuid.uuid4())
318
+ run_dir = project_root / ".devcouncil" / "runs" / run_id
319
+ run_dir.mkdir(parents=True, exist_ok=True)
320
+ path = run_dir / "correction-manifest.json"
321
+ path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
322
+
323
+ with db.get_session() as session:
324
+ CorrectionManifestRepository(session).save(
325
+ task_id,
326
+ str(path),
327
+ "open",
328
+ run_id=run_id,
329
+ retry_budget=manifest.retry_budget,
330
+ attempt=manifest.prior_failed_attempts,
331
+ )
332
+ return path
333
+
334
+
335
+ def load_latest_correction_manifest(project_root: Path, task_id: str) -> CorrectionManifest | None:
336
+ db = get_db(project_root)
337
+ if not db:
338
+ return None
339
+ with db.get_session() as session:
340
+ record = CorrectionManifestRepository(session).latest_for_task(task_id)
341
+ if not record:
342
+ return None
343
+ path = Path(record.manifest_path)
344
+ if not path.exists():
345
+ return None
346
+ return CorrectionManifest.model_validate(json.loads(path.read_text(encoding="utf-8")))
@@ -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,11 +39,16 @@ 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
- schema=CritiqueOutput
45
+ schema=CritiqueOutput,
46
+ # Degrade gracefully on weaker models: an un-critiqued plan is still a
47
+ # usable plan, far better than crashing the whole planning run.
48
+ fallback=CritiqueOutput(findings=[]),
43
49
  )
50
+ logger.info("Critique by %s: %d finding(s)", role, len(result.findings))
51
+ return result
44
52
 
45
53
  async def generate_rebuttal(self, role: str, original_plan_json: str, findings_json: str) -> RebuttalOutput:
46
54
  prompt = f"""
@@ -59,8 +67,12 @@ You are the planner who created the original plan. Review the critique findings.
59
67
  {"role": "user", "content": prompt}
60
68
  ]
61
69
 
62
- return await self.router.complete_structured(
70
+ result = await self.router.complete_structured(
63
71
  role=role,
64
72
  messages=messages,
65
- schema=RebuttalOutput
73
+ schema=RebuttalOutput,
74
+ # No rebuttals means findings stand as-is — a safe, conservative default.
75
+ fallback=RebuttalOutput(rebuttals=[]),
66
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
@@ -23,10 +82,29 @@ Repository Map:
23
82
  {repo_map_json}
24
83
 
25
84
  Your task is to create a detailed implementation plan.
26
- - Break down the requirements into atomic implementation tasks.
27
- - For each task, specify which files will be created or modified.
28
- - Specify which tests are expected to verify the task.
85
+ - Break down the requirements into atomic implementation tasks, but use the FEWEST
86
+ tasks that cover them do NOT over-decompose. A small goal (e.g. add one function
87
+ plus its test) is typically one or two tasks, not four.
88
+ - Each file's changes must be OWNED BY A SINGLE TASK. Never create two tasks that both
89
+ create/modify the same file — that causes duplicate or conflicting edits. If work on
90
+ a file spans concerns, keep it in one task or split by FILE, not by sub-edit.
91
+ - For each task, specify which files will be created or modified. Every implementation
92
+ task must declare at least one writable (create/modify) planned file — a task that
93
+ only reads files cannot implement anything.
94
+ - Fill expected_tests with RUNNABLE shell commands (not prose) that exit 0 iff the
95
+ task's acceptance criteria hold and can run immediately after THIS task with no
96
+ missing tools or files. Prove BEHAVIOR with self-contained inline assertions, e.g.
97
+ python -c "import calc; assert calc.add(2,3)==5". Use pytest only on a whole test
98
+ file this or an earlier task creates (python -m pytest tests/test_x.py -q), never a
99
+ ::node id. Do NOT assert repository/git state (git status, changed-file sets,
100
+ append-only contents) and do NOT invoke flake8/mypy/ruff/eslint/tsc/npm unless the
101
+ repo is already configured for them.
29
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.
30
108
 
31
109
  Role-specific instructions:
32
110
  """
@@ -38,9 +116,11 @@ Role-specific instructions:
38
116
  messages = [
39
117
  {"role": "user", "content": prompt}
40
118
  ]
41
-
42
- return await self.router.complete_structured(
119
+
120
+ result = await self.router.complete_structured(
43
121
  role=role,
44
122
  messages=messages,
45
123
  schema=PlanOutput
46
124
  )
125
+ logger.info("Plan generated by %s: %d task(s)", role, len(result.tasks))
126
+ return result