devcouncil 0.1.0 → 0.2.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.
- package/LICENSE +201 -201
- package/README.md +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +163 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1342 -307
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Correction manifest generation for repair loops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from devcouncil.app.config import load_config
|
|
13
|
+
from devcouncil.domain.gap import Gap
|
|
14
|
+
from devcouncil.domain.task import Task
|
|
15
|
+
from devcouncil.storage.db import get_db
|
|
16
|
+
from devcouncil.storage.native import CorrectionManifestRepository
|
|
17
|
+
from devcouncil.storage.repositories import EvidenceRepository, GapRepository, TaskRepository
|
|
18
|
+
from devcouncil.utils.redaction import redact_text
|
|
19
|
+
|
|
20
|
+
# Bounds for the prior-attempt context folded into the manifest. These reach the
|
|
21
|
+
# next executor's prompt verbatim, so they must stay small enough not to crowd out
|
|
22
|
+
# the task spec / blow the context window while still carrying the signal the agent
|
|
23
|
+
# needs (what it changed last time, and why verification rejected it).
|
|
24
|
+
_MAX_PRIOR_DIFF_CHARS = 8000
|
|
25
|
+
_MAX_FAILING_OUTPUT_CHARS = 4000
|
|
26
|
+
# Per failed command, how much of the captured stdout/stderr tail to keep. Test
|
|
27
|
+
# runners put the actual assertion/traceback at the end, so we keep the tail.
|
|
28
|
+
_MAX_PER_COMMAND_OUTPUT_CHARS = 1500
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CorrectionManifest(BaseModel):
|
|
32
|
+
task_id: str
|
|
33
|
+
root_cause: str
|
|
34
|
+
failed_evidence: list[str] = Field(default_factory=list)
|
|
35
|
+
allowed_repair_files: list[str] = Field(default_factory=list)
|
|
36
|
+
forbidden_changes: list[str] = Field(default_factory=list)
|
|
37
|
+
commands_to_rerun: list[str] = Field(default_factory=list)
|
|
38
|
+
prior_failed_attempts: int = 0
|
|
39
|
+
retry_budget: int = 3
|
|
40
|
+
executor_recommendation: str = "manual"
|
|
41
|
+
created_at: str
|
|
42
|
+
# Blocking gaps ordered most-actionable-first (severity, then gap-type priority),
|
|
43
|
+
# so the repair loop is steered at the real defect (a failing test) rather than an
|
|
44
|
+
# arbitrary first gap (e.g. an orphan_diff). The first entry is the root_cause.
|
|
45
|
+
ordered_blocking_gaps: list[str] = Field(default_factory=list)
|
|
46
|
+
# Prior-attempt context (optional, backward-compatible). Without these the repair
|
|
47
|
+
# executor only sees the root_cause text and re-derives the same wrong approach
|
|
48
|
+
# blind. ``prior_diff`` is what the previous attempt actually changed; it lets the
|
|
49
|
+
# agent see (and stop re-applying) its rejected edit. ``failing_output`` is the
|
|
50
|
+
# captured failing test / verification output that explains *why* it was rejected.
|
|
51
|
+
# Both are redacted and size-bounded before being written.
|
|
52
|
+
prior_diff: str = ""
|
|
53
|
+
failing_output: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Severity ordering: most severe first.
|
|
57
|
+
_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
58
|
+
|
|
59
|
+
# Gap-type priority within a severity band. Lower sorts first. Executable-evidence
|
|
60
|
+
# failures (a failing test / unproven acceptance criterion) are the real defect signal
|
|
61
|
+
# and must outrank scope (orphan/dependency) and advisory (review/secret) gaps so the
|
|
62
|
+
# repair loop targets the failing test, not an orphan_diff.
|
|
63
|
+
_GAP_TYPE_PRIORITY = {
|
|
64
|
+
"test_failed": 0,
|
|
65
|
+
"acceptance_criteria_unproven": 1,
|
|
66
|
+
"diff_not_exercised": 1,
|
|
67
|
+
"task_not_implemented": 2,
|
|
68
|
+
"migration_gap": 2,
|
|
69
|
+
"orphan_diff": 3,
|
|
70
|
+
"planned_file_not_changed": 3,
|
|
71
|
+
"dependency_risk": 3,
|
|
72
|
+
"architecture_drift": 4,
|
|
73
|
+
"assumption_violated": 4,
|
|
74
|
+
"security_risk": 5,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ordered_blocking_gaps(blocking_gaps: list[Gap]) -> list[Gap]:
|
|
79
|
+
"""Stable-sort blocking gaps by (severity, gap-type priority).
|
|
80
|
+
|
|
81
|
+
``test_failed`` / ``acceptance_*`` gaps come before orphan/dependency before
|
|
82
|
+
review/secret, so the picked root_cause is the failing behavior rather than an
|
|
83
|
+
incidental scope finding. Unknown severities/types sort last (defensive)."""
|
|
84
|
+
return sorted(
|
|
85
|
+
blocking_gaps,
|
|
86
|
+
key=lambda g: (
|
|
87
|
+
_SEVERITY_RANK.get(g.severity, 9),
|
|
88
|
+
_GAP_TYPE_PRIORITY.get(g.gap_type, 9),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _latest_agent_run(project_root: Path, task_id: str) -> dict | None:
|
|
94
|
+
runs_dir = project_root / ".devcouncil" / "runs"
|
|
95
|
+
if not runs_dir.exists():
|
|
96
|
+
return None
|
|
97
|
+
candidates = sorted(runs_dir.glob("*/agent-run.json"), reverse=True)
|
|
98
|
+
for path in candidates:
|
|
99
|
+
try:
|
|
100
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
except Exception:
|
|
102
|
+
continue
|
|
103
|
+
if payload.get("task_id") == task_id:
|
|
104
|
+
return payload
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _truncate_tail(text: str, limit: int) -> str:
|
|
109
|
+
"""Keep the last ``limit`` chars of ``text`` (the actionable tail of test output),
|
|
110
|
+
prefixing a marker when truncated. Empty/whitespace input returns ""."""
|
|
111
|
+
text = (text or "").strip()
|
|
112
|
+
if len(text) <= limit:
|
|
113
|
+
return text
|
|
114
|
+
return "[devcouncil: output truncated, showing last "f"{limit} chars]\n" + text[-limit:]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _truncate_head(text: str, limit: int) -> str:
|
|
118
|
+
"""Keep the first ``limit`` chars of ``text`` (diffs read top-down), with a marker
|
|
119
|
+
when truncated. Empty/whitespace input returns ""."""
|
|
120
|
+
text = (text or "").strip()
|
|
121
|
+
if len(text) <= limit:
|
|
122
|
+
return text
|
|
123
|
+
return text[:limit] + "\n[devcouncil: diff truncated, "f"{len(text) - limit} chars omitted]"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_text_tail(path: Path, limit: int) -> str:
|
|
127
|
+
"""Best-effort read of a captured stdout/stderr file, keeping its tail. Never raises."""
|
|
128
|
+
try:
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
return ""
|
|
131
|
+
return _truncate_tail(path.read_text(encoding="utf-8", errors="replace"), limit)
|
|
132
|
+
except Exception:
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _collect_prior_diff(project_root: Path, task_id: str) -> str:
|
|
137
|
+
"""The prior attempt's working-tree diff, from the task's ``after`` checkpoint patch.
|
|
138
|
+
|
|
139
|
+
The checkpoint service writes ``<task_id>-after.patch`` after each executor run, so
|
|
140
|
+
this is exactly what the previous attempt changed. Redacted and head-bounded so the
|
|
141
|
+
repair executor can see (and avoid re-applying) its rejected edit without the diff
|
|
142
|
+
swamping the prompt. Returns "" when no patch exists (e.g. first attempt)."""
|
|
143
|
+
patch_path = project_root / ".devcouncil" / "checkpoints" / f"{task_id}-after.patch"
|
|
144
|
+
try:
|
|
145
|
+
if not patch_path.is_file():
|
|
146
|
+
return ""
|
|
147
|
+
raw = patch_path.read_text(encoding="utf-8", errors="replace")
|
|
148
|
+
except Exception:
|
|
149
|
+
return ""
|
|
150
|
+
return _truncate_head(redact_text(raw), _MAX_PRIOR_DIFF_CHARS)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _collect_failing_output(project_root: Path, failed_results) -> str:
|
|
154
|
+
"""The captured stdout/stderr of the failing verification commands.
|
|
155
|
+
|
|
156
|
+
Folds each failed command's summary plus the tail of its captured stdout/stderr so
|
|
157
|
+
the repair executor sees *why* it was rejected (the actual assertion / traceback),
|
|
158
|
+
not just that a command exited non-zero. Redacted and size-bounded. Returns ""
|
|
159
|
+
when there is nothing useful to show."""
|
|
160
|
+
blocks: list[str] = []
|
|
161
|
+
for result in failed_results:
|
|
162
|
+
parts = [f"$ {result.command} (exit {result.exit_code})"]
|
|
163
|
+
if result.summary and result.summary.strip():
|
|
164
|
+
parts.append(result.summary.strip())
|
|
165
|
+
for label, rel in (("stdout", result.stdout_path), ("stderr", result.stderr_path)):
|
|
166
|
+
if not rel:
|
|
167
|
+
continue
|
|
168
|
+
path = Path(rel)
|
|
169
|
+
if not path.is_absolute():
|
|
170
|
+
path = project_root / rel
|
|
171
|
+
tail = _read_text_tail(path, _MAX_PER_COMMAND_OUTPUT_CHARS)
|
|
172
|
+
if tail:
|
|
173
|
+
parts.append(f"--- {label} ---\n{tail}")
|
|
174
|
+
blocks.append("\n".join(parts))
|
|
175
|
+
if not blocks:
|
|
176
|
+
return ""
|
|
177
|
+
return _truncate_tail(redact_text("\n\n".join(blocks)), _MAX_FAILING_OUTPUT_CHARS)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_correction_manifest(
|
|
181
|
+
project_root: Path,
|
|
182
|
+
task: Task,
|
|
183
|
+
blocking_gaps: list[Gap],
|
|
184
|
+
*,
|
|
185
|
+
repair_service=None,
|
|
186
|
+
prior_attempts: int = 0,
|
|
187
|
+
) -> CorrectionManifest:
|
|
188
|
+
config = load_config(project_root)
|
|
189
|
+
failed: list[str] = []
|
|
190
|
+
failed_results: list = []
|
|
191
|
+
db = get_db(project_root)
|
|
192
|
+
if db:
|
|
193
|
+
with db.get_session() as session:
|
|
194
|
+
# Scope failed evidence to THIS task. Scanning every evidence row made a
|
|
195
|
+
# repair for one task chase unrelated failures from another, so the loop
|
|
196
|
+
# never converged on the real defect.
|
|
197
|
+
for result in EvidenceRepository(session).get_command_results_for_task(task.id):
|
|
198
|
+
if result.exit_code != 0:
|
|
199
|
+
failed.append(f"{result.command} (exit {result.exit_code})")
|
|
200
|
+
failed_results.append(result)
|
|
201
|
+
|
|
202
|
+
# Steer the repair at the most actionable failure (a failing test / unproven AC),
|
|
203
|
+
# not an arbitrary first gap such as an orphan_diff.
|
|
204
|
+
ordered_gaps = _ordered_blocking_gaps(blocking_gaps)
|
|
205
|
+
root_cause = ordered_gaps[0].description if ordered_gaps else "Unknown failure"
|
|
206
|
+
manifest = CorrectionManifest(
|
|
207
|
+
task_id=task.id,
|
|
208
|
+
root_cause=root_cause,
|
|
209
|
+
ordered_blocking_gaps=[g.description for g in ordered_gaps],
|
|
210
|
+
failed_evidence=failed,
|
|
211
|
+
allowed_repair_files=[pf.path for pf in task.planned_files],
|
|
212
|
+
forbidden_changes=list(task.forbidden_changes),
|
|
213
|
+
commands_to_rerun=task.expected_tests or task.allowed_commands,
|
|
214
|
+
# The number of repair attempts already made on this task — real, not a
|
|
215
|
+
# hardcoded 0. The agent sees how much of its budget is spent so it knows
|
|
216
|
+
# when to change approach rather than retry the same fix.
|
|
217
|
+
prior_failed_attempts=prior_attempts,
|
|
218
|
+
retry_budget=config.execution.max_repair_attempts,
|
|
219
|
+
executor_recommendation=config.execution.default_executor,
|
|
220
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
221
|
+
# Prior-attempt context so the next executor repairs against what actually
|
|
222
|
+
# happened (its rejected diff + the failing output) instead of re-deriving
|
|
223
|
+
# the same wrong approach blind. Both are redacted and size-bounded.
|
|
224
|
+
prior_diff=_collect_prior_diff(project_root, task.id),
|
|
225
|
+
failing_output=_collect_failing_output(project_root, failed_results),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if repair_service is not None:
|
|
229
|
+
try:
|
|
230
|
+
import asyncio
|
|
231
|
+
|
|
232
|
+
plan = asyncio.run(repair_service.generate_repair_plan(blocking_gaps, task.description))
|
|
233
|
+
if plan.suggested_tasks:
|
|
234
|
+
suggested = plan.suggested_tasks[0]
|
|
235
|
+
manifest.root_cause = suggested.description or manifest.root_cause
|
|
236
|
+
# Use the repair plan's concrete scope instead of throwing it away:
|
|
237
|
+
# union its targeted files/tests with the task's so the re-implement
|
|
238
|
+
# step focuses on what actually needs fixing without losing task scope.
|
|
239
|
+
manifest.allowed_repair_files = _union(
|
|
240
|
+
manifest.allowed_repair_files, [pf.path for pf in suggested.planned_files]
|
|
241
|
+
)
|
|
242
|
+
manifest.commands_to_rerun = _union(manifest.commands_to_rerun, suggested.expected_tests)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
return manifest
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _union(base: list[str], extra: list[str]) -> list[str]:
|
|
249
|
+
"""Append items from ``extra`` not already in ``base`` (order-preserving dedupe)."""
|
|
250
|
+
merged = list(base)
|
|
251
|
+
for item in extra:
|
|
252
|
+
if item and item not in merged:
|
|
253
|
+
merged.append(item)
|
|
254
|
+
return merged
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def write_correction_manifest(project_root: Path, task_id: str, *, repair_service=None) -> Path | None:
|
|
258
|
+
db = get_db(project_root)
|
|
259
|
+
if not db:
|
|
260
|
+
return None
|
|
261
|
+
with db.get_session() as session:
|
|
262
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
263
|
+
if not task:
|
|
264
|
+
return None
|
|
265
|
+
gaps = [g for g in GapRepository(session).get_all() if g.task_id == task_id and g.blocking]
|
|
266
|
+
if not gaps:
|
|
267
|
+
return None
|
|
268
|
+
prior_record = CorrectionManifestRepository(session).latest_for_task(task_id)
|
|
269
|
+
prior_attempts = (prior_record.attempt + 1) if prior_record else 1
|
|
270
|
+
|
|
271
|
+
manifest = build_correction_manifest(
|
|
272
|
+
project_root, task, gaps, repair_service=repair_service, prior_attempts=prior_attempts
|
|
273
|
+
)
|
|
274
|
+
run_id = str(uuid.uuid4())
|
|
275
|
+
run_dir = project_root / ".devcouncil" / "runs" / run_id
|
|
276
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
path = run_dir / "correction-manifest.json"
|
|
278
|
+
path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
|
|
279
|
+
|
|
280
|
+
with db.get_session() as session:
|
|
281
|
+
CorrectionManifestRepository(session).save(
|
|
282
|
+
task_id,
|
|
283
|
+
str(path),
|
|
284
|
+
"open",
|
|
285
|
+
run_id=run_id,
|
|
286
|
+
retry_budget=manifest.retry_budget,
|
|
287
|
+
attempt=manifest.prior_failed_attempts,
|
|
288
|
+
)
|
|
289
|
+
return path
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def load_latest_correction_manifest(project_root: Path, task_id: str) -> CorrectionManifest | None:
|
|
293
|
+
db = get_db(project_root)
|
|
294
|
+
if not db:
|
|
295
|
+
return None
|
|
296
|
+
with db.get_session() as session:
|
|
297
|
+
record = CorrectionManifestRepository(session).latest_for_task(task_id)
|
|
298
|
+
if not record:
|
|
299
|
+
return None
|
|
300
|
+
path = Path(record.manifest_path)
|
|
301
|
+
if not path.exists():
|
|
302
|
+
return None
|
|
303
|
+
return CorrectionManifest.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
|
@@ -1,66 +1,71 @@
|
|
|
1
|
-
from typing import List
|
|
2
|
-
from pydantic import BaseModel
|
|
3
|
-
from devcouncil.domain.critique import CritiqueFinding
|
|
4
|
-
from devcouncil.llm.router import ModelRouter
|
|
5
|
-
|
|
6
|
-
class CritiqueOutput(BaseModel):
|
|
7
|
-
findings: List[CritiqueFinding]
|
|
8
|
-
|
|
9
|
-
class RebuttalItem(BaseModel):
|
|
10
|
-
finding_id: str
|
|
11
|
-
decision: str # "accepted", "rejected"
|
|
12
|
-
reason: str
|
|
13
|
-
suggested_change: str | None = None
|
|
14
|
-
|
|
15
|
-
class RebuttalOutput(BaseModel):
|
|
16
|
-
rebuttals: List[RebuttalItem]
|
|
17
|
-
|
|
18
|
-
class CritiqueService:
|
|
19
|
-
def __init__(self, router: ModelRouter):
|
|
20
|
-
self.router = router
|
|
21
|
-
|
|
22
|
-
async def generate_critique(self, role: str, target_plan_json: str, requirements_json: str) -> CritiqueOutput:
|
|
23
|
-
prompt = f"""
|
|
24
|
-
Requirements:
|
|
25
|
-
{requirements_json}
|
|
26
|
-
|
|
27
|
-
Target Plan:
|
|
28
|
-
{target_plan_json}
|
|
29
|
-
|
|
30
|
-
You are a hostile staff engineer reviewing another team's implementation plan.
|
|
31
|
-
Find missing requirements, bad assumptions, missing tests, security risks, migration risks, and unverifiable claims.
|
|
32
|
-
Do not praise. Do not rewrite the plan.
|
|
33
|
-
Every finding must include a falsifiable_check.
|
|
34
|
-
"""
|
|
35
|
-
messages = [
|
|
36
|
-
{"role": "user", "content": prompt}
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
return await self.router.complete_structured(
|
|
40
|
-
role=role,
|
|
41
|
-
messages=messages,
|
|
42
|
-
schema=CritiqueOutput
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from devcouncil.domain.critique import CritiqueFinding
|
|
4
|
+
from devcouncil.llm.router import ModelRouter
|
|
5
|
+
|
|
6
|
+
class CritiqueOutput(BaseModel):
|
|
7
|
+
findings: List[CritiqueFinding]
|
|
8
|
+
|
|
9
|
+
class RebuttalItem(BaseModel):
|
|
10
|
+
finding_id: str
|
|
11
|
+
decision: str # "accepted", "rejected"
|
|
12
|
+
reason: str
|
|
13
|
+
suggested_change: str | None = None
|
|
14
|
+
|
|
15
|
+
class RebuttalOutput(BaseModel):
|
|
16
|
+
rebuttals: List[RebuttalItem]
|
|
17
|
+
|
|
18
|
+
class CritiqueService:
|
|
19
|
+
def __init__(self, router: ModelRouter):
|
|
20
|
+
self.router = router
|
|
21
|
+
|
|
22
|
+
async def generate_critique(self, role: str, target_plan_json: str, requirements_json: str) -> CritiqueOutput:
|
|
23
|
+
prompt = f"""
|
|
24
|
+
Requirements:
|
|
25
|
+
{requirements_json}
|
|
26
|
+
|
|
27
|
+
Target Plan:
|
|
28
|
+
{target_plan_json}
|
|
29
|
+
|
|
30
|
+
You are a hostile staff engineer reviewing another team's implementation plan.
|
|
31
|
+
Find missing requirements, bad assumptions, missing tests, security risks, migration risks, and unverifiable claims.
|
|
32
|
+
Do not praise. Do not rewrite the plan.
|
|
33
|
+
Every finding must include a falsifiable_check.
|
|
34
|
+
"""
|
|
35
|
+
messages = [
|
|
36
|
+
{"role": "user", "content": prompt}
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
return await self.router.complete_structured(
|
|
40
|
+
role=role,
|
|
41
|
+
messages=messages,
|
|
42
|
+
schema=CritiqueOutput,
|
|
43
|
+
# Degrade gracefully on weaker models: an un-critiqued plan is still a
|
|
44
|
+
# usable plan, far better than crashing the whole planning run.
|
|
45
|
+
fallback=CritiqueOutput(findings=[]),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def generate_rebuttal(self, role: str, original_plan_json: str, findings_json: str) -> RebuttalOutput:
|
|
49
|
+
prompt = f"""
|
|
50
|
+
Original Plan:
|
|
51
|
+
{original_plan_json}
|
|
52
|
+
|
|
53
|
+
Critique Findings:
|
|
54
|
+
{findings_json}
|
|
55
|
+
|
|
56
|
+
You are the planner who created the original plan. Review the critique findings.
|
|
57
|
+
- A finding can be rejected only with artifact evidence or strong justification.
|
|
58
|
+
- A finding can be accepted and converted into a requirement/task/test.
|
|
59
|
+
- No hand-wavy rebuttals.
|
|
60
|
+
"""
|
|
61
|
+
messages = [
|
|
62
|
+
{"role": "user", "content": prompt}
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
return await self.router.complete_structured(
|
|
66
|
+
role=role,
|
|
67
|
+
messages=messages,
|
|
68
|
+
schema=RebuttalOutput,
|
|
69
|
+
# No rebuttals means findings stand as-is — a safe, conservative default.
|
|
70
|
+
fallback=RebuttalOutput(rebuttals=[]),
|
|
71
|
+
)
|
|
@@ -1,46 +1,60 @@
|
|
|
1
|
-
from typing import List
|
|
2
|
-
from pydantic import BaseModel
|
|
3
|
-
from devcouncil.domain.task import Task
|
|
4
|
-
from devcouncil.llm.router import ModelRouter
|
|
5
|
-
|
|
6
|
-
class PlanOutput(BaseModel):
|
|
7
|
-
id: str
|
|
8
|
-
rationale: str
|
|
9
|
-
tasks: List[Task]
|
|
10
|
-
|
|
11
|
-
class PlanService:
|
|
12
|
-
def __init__(self, router: ModelRouter):
|
|
13
|
-
self.router = router
|
|
14
|
-
|
|
15
|
-
async def generate_plan(self, role: str, goal: str, requirements_json: str, repo_map_json: str) -> PlanOutput:
|
|
16
|
-
prompt = f"""
|
|
17
|
-
Goal: {goal}
|
|
18
|
-
|
|
19
|
-
Requirements:
|
|
20
|
-
{requirements_json}
|
|
21
|
-
|
|
22
|
-
Repository Map:
|
|
23
|
-
{repo_map_json}
|
|
24
|
-
|
|
25
|
-
Your task is to create a detailed implementation plan.
|
|
26
|
-
- Break down the requirements into atomic implementation tasks
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from devcouncil.domain.task import Task
|
|
4
|
+
from devcouncil.llm.router import ModelRouter
|
|
5
|
+
|
|
6
|
+
class PlanOutput(BaseModel):
|
|
7
|
+
id: str
|
|
8
|
+
rationale: str
|
|
9
|
+
tasks: List[Task]
|
|
10
|
+
|
|
11
|
+
class PlanService:
|
|
12
|
+
def __init__(self, router: ModelRouter):
|
|
13
|
+
self.router = router
|
|
14
|
+
|
|
15
|
+
async def generate_plan(self, role: str, goal: str, requirements_json: str, repo_map_json: str) -> PlanOutput:
|
|
16
|
+
prompt = f"""
|
|
17
|
+
Goal: {goal}
|
|
18
|
+
|
|
19
|
+
Requirements:
|
|
20
|
+
{requirements_json}
|
|
21
|
+
|
|
22
|
+
Repository Map:
|
|
23
|
+
{repo_map_json}
|
|
24
|
+
|
|
25
|
+
Your task is to create a detailed implementation plan.
|
|
26
|
+
- Break down the requirements into atomic implementation tasks, but use the FEWEST
|
|
27
|
+
tasks that cover them — do NOT over-decompose. A small goal (e.g. add one function
|
|
28
|
+
plus its test) is typically one or two tasks, not four.
|
|
29
|
+
- Each file's changes must be OWNED BY A SINGLE TASK. Never create two tasks that both
|
|
30
|
+
create/modify the same file — that causes duplicate or conflicting edits. If work on
|
|
31
|
+
a file spans concerns, keep it in one task or split by FILE, not by sub-edit.
|
|
32
|
+
- For each task, specify which files will be created or modified. Every implementation
|
|
33
|
+
task must declare at least one writable (create/modify) planned file — a task that
|
|
34
|
+
only reads files cannot implement anything.
|
|
35
|
+
- Fill expected_tests with RUNNABLE shell commands (not prose) that exit 0 iff the
|
|
36
|
+
task's acceptance criteria hold and can run immediately after THIS task with no
|
|
37
|
+
missing tools or files. Prove BEHAVIOR with self-contained inline assertions, e.g.
|
|
38
|
+
python -c "import calc; assert calc.add(2,3)==5". Use pytest only on a whole test
|
|
39
|
+
file this or an earlier task creates (python -m pytest tests/test_x.py -q), never a
|
|
40
|
+
::node id. Do NOT assert repository/git state (git status, changed-file sets,
|
|
41
|
+
append-only contents) and do NOT invoke flake8/mypy/ruff/eslint/tsc/npm unless the
|
|
42
|
+
repo is already configured for them.
|
|
43
|
+
- Ensure each task maps back to at least one requirement.
|
|
44
|
+
|
|
45
|
+
Role-specific instructions:
|
|
46
|
+
"""
|
|
47
|
+
if role == "planner_a":
|
|
48
|
+
prompt += "You are the pragmatic tech lead. Optimize for simplicity and minimal dependencies."
|
|
49
|
+
else:
|
|
50
|
+
prompt += "You are the production-readiness architect. Optimize for security, performance, and edge cases."
|
|
51
|
+
|
|
52
|
+
messages = [
|
|
53
|
+
{"role": "user", "content": prompt}
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
return await self.router.complete_structured(
|
|
57
|
+
role=role,
|
|
58
|
+
messages=messages,
|
|
59
|
+
schema=PlanOutput
|
|
60
|
+
)
|