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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- 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 +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- 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 +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- 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 +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- 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 +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -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/__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 +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
1
5
|
from pathlib import Path
|
|
2
6
|
from typing import List
|
|
3
7
|
|
|
@@ -5,15 +9,649 @@ from devcouncil.domain.requirement import Requirement
|
|
|
5
9
|
from devcouncil.domain.task import Task
|
|
6
10
|
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
7
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Context budget for injected file bodies. Skills are bounded separately; these keep
|
|
15
|
+
# a task that touches many/large files from blowing up the prompt. Lowest-priority
|
|
16
|
+
# content (later files) is truncated/omitted first, with an explicit marker.
|
|
17
|
+
MAX_FILE_CONTEXT_CHARS = 24_000 # total across all injected file bodies
|
|
18
|
+
MAX_PER_FILE_CHARS = 8_000 # cap on any single file body
|
|
19
|
+
MAX_SYMBOLS_PER_FILE = 40
|
|
20
|
+
# Global ceiling on the assembled prompt. The core (goal/requirements/scope/instructions)
|
|
21
|
+
# is always kept; optional context sections are fitted in priority order and the
|
|
22
|
+
# lowest-priority ones are dropped (with a marker) if the whole prompt would exceed this.
|
|
23
|
+
MAX_PROMPT_CHARS = 60_000
|
|
24
|
+
|
|
25
|
+
# Rough chars-per-token for English/code; deliberately conservative so the derived
|
|
26
|
+
# budget under-fills the window rather than over-fills it.
|
|
27
|
+
_CHARS_PER_TOKEN = 4
|
|
28
|
+
# Tokens reserved inside the model's context window for the model's own completion plus
|
|
29
|
+
# the schema/JSON instructions the router appends to each call.
|
|
30
|
+
_RESERVED_COMPLETION_TOKENS = 1536
|
|
31
|
+
# Never shrink the budget below this; a window this small can't run the council anyway,
|
|
32
|
+
# and clamping here keeps the core prompt intact instead of pathologically truncating.
|
|
33
|
+
_MIN_PROMPT_CHARS = 8_000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _local_context_window_budget(project_root: Path, cfg=None) -> int | None:
|
|
37
|
+
"""Char budget derived from a constrained local context window, or ``None``.
|
|
38
|
+
|
|
39
|
+
When the run targets the local Ollama provider with an explicit ``OLLAMA_NUM_CTX``,
|
|
40
|
+
the server silently truncates anything past that window — so a char-only budget that
|
|
41
|
+
ignores it lets the carefully-assembled prompt get cut off mid-stream. Returns a char
|
|
42
|
+
budget that fits the window (minus completion headroom) so :meth:`build_task_prompt`
|
|
43
|
+
can cap itself. Returns ``None`` for cloud providers / unset windows, leaving the
|
|
44
|
+
default behavior (and the large cloud CLIs' big windows) untouched. Best-effort:
|
|
45
|
+
any error degrades to ``None``.
|
|
46
|
+
|
|
47
|
+
``cfg`` may be a pre-loaded config (loaded once per task by ``build_task_prompt``); when
|
|
48
|
+
``None`` it is loaded here so other callers keep working."""
|
|
49
|
+
try:
|
|
50
|
+
if cfg is None:
|
|
51
|
+
from devcouncil.app.config import load_config
|
|
52
|
+
|
|
53
|
+
cfg = load_config(project_root)
|
|
54
|
+
provider = cfg.models.provider.strip().lower()
|
|
55
|
+
if provider not in {"ollama", "ollama-local", "ollama_local"}:
|
|
56
|
+
return None
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
from devcouncil.llm.provider import OllamaProvider
|
|
61
|
+
|
|
62
|
+
num_ctx = OllamaProvider._resolve_num_ctx()
|
|
63
|
+
if not num_ctx:
|
|
64
|
+
# No explicit window: Ollama uses a small default, but DevCouncil cannot know it.
|
|
65
|
+
# `dev doctor` already warns to set OLLAMA_NUM_CTX; don't guess a cap here.
|
|
66
|
+
return None
|
|
67
|
+
usable_tokens = num_ctx - _RESERVED_COMPLETION_TOKENS
|
|
68
|
+
if usable_tokens <= 0:
|
|
69
|
+
return _MIN_PROMPT_CHARS
|
|
70
|
+
return max(_MIN_PROMPT_CHARS, usable_tokens * _CHARS_PER_TOKEN)
|
|
71
|
+
|
|
72
|
+
_LANG_BY_EXT = {
|
|
73
|
+
".py": "python", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript",
|
|
74
|
+
".tsx": "tsx", ".go": "go", ".rs": "rust", ".java": "java", ".kt": "kotlin",
|
|
75
|
+
".swift": "swift", ".rb": "ruby", ".cs": "csharp", ".cpp": "cpp", ".c": "c",
|
|
76
|
+
".sh": "bash", ".yml": "yaml", ".yaml": "yaml", ".json": "json", ".toml": "toml",
|
|
77
|
+
".md": "markdown", ".sql": "sql",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
8
81
|
class PromptBuilder:
|
|
9
82
|
def __init__(self, project_root: Path = Path(".")):
|
|
10
83
|
self.project_root = project_root
|
|
11
84
|
|
|
12
|
-
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _lang_for(path: str) -> str:
|
|
87
|
+
return _LANG_BY_EXT.get(Path(path).suffix.lower(), "")
|
|
88
|
+
|
|
89
|
+
def _symbol_outline(self, path: str, text: str) -> List[str]:
|
|
90
|
+
"""Cheap top-level symbol index (signatures + line numbers) so the agent edits
|
|
91
|
+
in place and uses correct names/arities instead of guessing or duplicating.
|
|
92
|
+
|
|
93
|
+
Python uses stdlib ``ast`` (method signatures, async/@property/@staticmethod
|
|
94
|
+
markers under each class). Other languages (ts/tsx/js/jsx/go/rs/java) use bounded
|
|
95
|
+
regex over exported/public declarations. No tree-sitter, no model call. Never
|
|
96
|
+
raises; honors the per-file symbol cap.
|
|
97
|
+
|
|
98
|
+
Results are memoized per ``build_task_prompt`` run via ``self._outline_cache`` so
|
|
99
|
+
the same file is parsed once even though both the planned-files section and the
|
|
100
|
+
call-sites section need its outline. The key includes the ``text`` itself (not just
|
|
101
|
+
``path``) so that if the file's content differs between the two reads, the outline
|
|
102
|
+
is recomputed from the current text rather than served stale — and using the text
|
|
103
|
+
directly (rather than its hash) means there is no collision risk."""
|
|
104
|
+
cache = getattr(self, "_outline_cache", None)
|
|
105
|
+
key = (path, text)
|
|
106
|
+
if cache is not None and key in cache:
|
|
107
|
+
return cache[key]
|
|
108
|
+
if path.endswith(".py"):
|
|
109
|
+
result = self._python_symbol_outline(text)
|
|
110
|
+
else:
|
|
111
|
+
result = self._regex_symbol_outline(path, text)
|
|
112
|
+
if cache is not None:
|
|
113
|
+
cache[key] = result
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
def _python_symbol_outline(self, text: str) -> List[str]:
|
|
117
|
+
try:
|
|
118
|
+
tree = ast.parse(text)
|
|
119
|
+
except Exception:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
def _decorator_markers(node) -> str:
|
|
123
|
+
names: set[str] = set()
|
|
124
|
+
for dec in getattr(node, "decorator_list", []):
|
|
125
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
126
|
+
if isinstance(target, ast.Attribute):
|
|
127
|
+
names.add(target.attr)
|
|
128
|
+
elif isinstance(target, ast.Name):
|
|
129
|
+
names.add(target.id)
|
|
130
|
+
marks = [m for m in ("property", "staticmethod", "classmethod") if m in names]
|
|
131
|
+
return (" @" + " @".join(marks)) if marks else ""
|
|
132
|
+
|
|
133
|
+
def _func_sig(node) -> str:
|
|
134
|
+
args = ", ".join(a.arg for a in node.args.args)
|
|
135
|
+
kw = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
|
|
136
|
+
return f"{kw}def {node.name}({args}) L{node.lineno}{_decorator_markers(node)}"
|
|
137
|
+
|
|
138
|
+
out: List[str] = []
|
|
139
|
+
for node in tree.body:
|
|
140
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
141
|
+
out.append(_func_sig(node))
|
|
142
|
+
elif isinstance(node, ast.ClassDef):
|
|
143
|
+
out.append(f"class {node.name} L{node.lineno}")
|
|
144
|
+
for n in node.body:
|
|
145
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
146
|
+
out.append(" " + _func_sig(n))
|
|
147
|
+
if len(out) >= MAX_SYMBOLS_PER_FILE:
|
|
148
|
+
break
|
|
149
|
+
if len(out) >= MAX_SYMBOLS_PER_FILE:
|
|
150
|
+
break
|
|
151
|
+
return out[:MAX_SYMBOLS_PER_FILE]
|
|
152
|
+
|
|
153
|
+
# Bounded per-language regexes over exported/public top-level declarations. Each
|
|
154
|
+
# capture group 2 is the symbol name; group 1 (when present) is the keyword/kind.
|
|
155
|
+
_OUTLINE_PATTERNS: dict[str, list[tuple[str, re.Pattern[str]]]] = {}
|
|
156
|
+
|
|
157
|
+
def _regex_symbol_outline(self, path: str, text: str) -> List[str]:
|
|
158
|
+
suffix = Path(path).suffix.lower()
|
|
159
|
+
lang = {
|
|
160
|
+
".ts": "ts", ".tsx": "ts", ".js": "js", ".jsx": "js",
|
|
161
|
+
".go": "go", ".rs": "rs", ".java": "java",
|
|
162
|
+
}.get(suffix)
|
|
163
|
+
if not lang:
|
|
164
|
+
return []
|
|
165
|
+
patterns = self._regex_outline_patterns().get(lang)
|
|
166
|
+
if not patterns:
|
|
167
|
+
return []
|
|
168
|
+
out: List[str] = []
|
|
169
|
+
for lineno, raw in enumerate(text.splitlines(), start=1):
|
|
170
|
+
for label, pattern in patterns:
|
|
171
|
+
m = pattern.match(raw)
|
|
172
|
+
if not m:
|
|
173
|
+
continue
|
|
174
|
+
name = m.group("name")
|
|
175
|
+
out.append(f"{label} {name} L{lineno}")
|
|
176
|
+
break
|
|
177
|
+
if len(out) >= MAX_SYMBOLS_PER_FILE:
|
|
178
|
+
break
|
|
179
|
+
return out[:MAX_SYMBOLS_PER_FILE]
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def _regex_outline_patterns(cls):
|
|
183
|
+
if cls._OUTLINE_PATTERNS:
|
|
184
|
+
return cls._OUTLINE_PATTERNS
|
|
185
|
+
n = r"(?P<name>[A-Za-z_$][\w$]*)"
|
|
186
|
+
ts = [
|
|
187
|
+
("export class", re.compile(r"^\s*export\s+(?:default\s+)?(?:abstract\s+)?class\s+" + n)),
|
|
188
|
+
("export interface", re.compile(r"^\s*export\s+interface\s+" + n)),
|
|
189
|
+
("export type", re.compile(r"^\s*export\s+type\s+" + n)),
|
|
190
|
+
("export enum", re.compile(r"^\s*export\s+(?:const\s+)?enum\s+" + n)),
|
|
191
|
+
("export function", re.compile(r"^\s*export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s+" + n)),
|
|
192
|
+
("export const", re.compile(r"^\s*export\s+(?:const|let|var)\s+" + n)),
|
|
193
|
+
]
|
|
194
|
+
js = [
|
|
195
|
+
("export class", re.compile(r"^\s*export\s+(?:default\s+)?class\s+" + n)),
|
|
196
|
+
("export function", re.compile(r"^\s*export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s+" + n)),
|
|
197
|
+
("export const", re.compile(r"^\s*export\s+(?:const|let|var)\s+" + n)),
|
|
198
|
+
("class", re.compile(r"^\s*class\s+" + n)),
|
|
199
|
+
("function", re.compile(r"^\s*(?:async\s+)?function\s*\*?\s+" + n)),
|
|
200
|
+
]
|
|
201
|
+
go = [
|
|
202
|
+
# Go exports = capitalized identifiers; func may carry a receiver.
|
|
203
|
+
("func", re.compile(r"^func\s+(?:\([^)]*\)\s*)?(?P<name>[A-Z]\w*)\s*[\(\[]")),
|
|
204
|
+
("type", re.compile(r"^type\s+(?P<name>[A-Z]\w*)\s+")),
|
|
205
|
+
]
|
|
206
|
+
rs = [
|
|
207
|
+
("pub fn", re.compile(r"^\s*pub(?:\([^)]*\))?\s+(?:async\s+)?(?:unsafe\s+)?fn\s+" + n)),
|
|
208
|
+
("pub struct", re.compile(r"^\s*pub(?:\([^)]*\))?\s+struct\s+" + n)),
|
|
209
|
+
("pub enum", re.compile(r"^\s*pub(?:\([^)]*\))?\s+enum\s+" + n)),
|
|
210
|
+
("pub trait", re.compile(r"^\s*pub(?:\([^)]*\))?\s+trait\s+" + n)),
|
|
211
|
+
]
|
|
212
|
+
java = [
|
|
213
|
+
("class", re.compile(r"^\s*(?:public|protected|private)?\s*(?:abstract\s+|final\s+)?class\s+" + n)),
|
|
214
|
+
("interface", re.compile(r"^\s*(?:public|protected|private)?\s*interface\s+" + n)),
|
|
215
|
+
("enum", re.compile(r"^\s*(?:public|protected|private)?\s*enum\s+" + n)),
|
|
216
|
+
# public/protected methods: <modifiers> <return-type> name(
|
|
217
|
+
("method", re.compile(
|
|
218
|
+
r"^\s*(?:public|protected)\s+(?:static\s+|final\s+|abstract\s+|synchronized\s+|native\s+)*"
|
|
219
|
+
r"[\w<>\[\],.?\s]+?\s+(?P<name>[A-Za-z_]\w*)\s*\(")),
|
|
220
|
+
]
|
|
221
|
+
cls._OUTLINE_PATTERNS = {"ts": ts, "js": js, "go": go, "rs": rs, "java": java}
|
|
222
|
+
return cls._OUTLINE_PATTERNS
|
|
223
|
+
|
|
224
|
+
def _planned_files_section(self, task: Task) -> str:
|
|
225
|
+
"""Inject the current (redacted) contents of the task's planned files.
|
|
226
|
+
|
|
227
|
+
The capable production agents previously received file PATHS only and had to
|
|
228
|
+
rediscover every file and guess signatures — a leading cause of wrong-arity /
|
|
229
|
+
wrong-import edits that fail verification. Reading the real contents here lifts
|
|
230
|
+
one-shot success. Bounded by a total + per-file char budget (see constants);
|
|
231
|
+
new files are shown as headers only."""
|
|
232
|
+
from devcouncil.utils.redaction import redact_string
|
|
233
|
+
|
|
234
|
+
blocks: List[str] = []
|
|
235
|
+
budget = MAX_FILE_CONTEXT_CHARS
|
|
236
|
+
omitted = 0
|
|
237
|
+
for pf in task.planned_files:
|
|
238
|
+
label = pf.allowed_change
|
|
239
|
+
file_path = self.project_root / pf.path
|
|
240
|
+
if not (file_path.exists() and file_path.is_file()):
|
|
241
|
+
blocks.append(f"### `{pf.path}` [{label}] — new file (does not exist yet)\n")
|
|
242
|
+
continue
|
|
243
|
+
if budget <= 0:
|
|
244
|
+
omitted += 1
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
|
248
|
+
except Exception:
|
|
249
|
+
blocks.append(f"### `{pf.path}` [{label}] — [error reading file]\n")
|
|
250
|
+
continue
|
|
251
|
+
content = redact_string(raw)
|
|
252
|
+
cap = min(MAX_PER_FILE_CHARS, budget)
|
|
253
|
+
truncated = len(content) > cap
|
|
254
|
+
if truncated:
|
|
255
|
+
content = content[:cap]
|
|
256
|
+
budget -= len(content)
|
|
257
|
+
symbols = self._symbol_outline(pf.path, raw)
|
|
258
|
+
block = f"### `{pf.path}` [{label}]\n"
|
|
259
|
+
if symbols:
|
|
260
|
+
block += "Symbols: " + "; ".join(symbols) + "\n"
|
|
261
|
+
block += f"```{self._lang_for(pf.path)}\n{content}\n```"
|
|
262
|
+
if truncated:
|
|
263
|
+
block += f"\n_[truncated to {cap} chars — open the file for the rest]_"
|
|
264
|
+
blocks.append(block + "\n")
|
|
265
|
+
if omitted:
|
|
266
|
+
blocks.append(f"_[{omitted} more planned file(s) omitted to fit the context budget — open them directly]_\n")
|
|
267
|
+
if not blocks:
|
|
268
|
+
return ""
|
|
269
|
+
return (
|
|
270
|
+
"\n## Current file contents (read before editing)\n"
|
|
271
|
+
"_Edit these in place; redacted secrets shown as ***._\n\n"
|
|
272
|
+
+ "\n".join(blocks)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _load_prompt_enhancement(self):
|
|
276
|
+
"""Latest run's prompt-enhancement (None if absent/unreadable). Best-effort: a
|
|
277
|
+
failure here must never break prompt construction."""
|
|
278
|
+
try:
|
|
279
|
+
from devcouncil.planning.prompt_enhancer_service import load_latest_prompt_enhancement
|
|
280
|
+
return load_latest_prompt_enhancement(self.project_root)
|
|
281
|
+
except Exception:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
def _load_repo_map(self) -> dict | None:
|
|
285
|
+
"""Parse ``.devcouncil/repo_map.json`` once per prompt (None if absent/unreadable)."""
|
|
286
|
+
map_path = self.project_root / ".devcouncil" / "repo_map.json"
|
|
287
|
+
if not map_path.exists():
|
|
288
|
+
return None
|
|
289
|
+
try:
|
|
290
|
+
data = json.loads(map_path.read_text(encoding="utf-8"))
|
|
291
|
+
return data if isinstance(data, dict) else None
|
|
292
|
+
except Exception:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def _repo_map_stale(self, data: dict | None) -> bool:
|
|
296
|
+
"""Whether the loaded repo map is behind the repo's current state, so its
|
|
297
|
+
structural context / dependents may be wrong. Best-effort; never raises."""
|
|
298
|
+
if not data:
|
|
299
|
+
return False
|
|
300
|
+
try:
|
|
301
|
+
from devcouncil.indexing.repo_mapper import RepoMapper
|
|
302
|
+
|
|
303
|
+
return RepoMapper(self.project_root).map_is_stale(data)
|
|
304
|
+
except Exception:
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
_STALE_MAP_NOTE = (
|
|
308
|
+
"_⚠ The repo map is behind the current code (run `dev map` to refresh); "
|
|
309
|
+
"treat the structure below as approximate._\n"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
_NO_MAP_NOTE = (
|
|
313
|
+
"_(no repo map; run `dev map` for structural orientation)_\n"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _repo_map_section(self, planned_paths: List[str], data: dict | None = None) -> str:
|
|
317
|
+
"""Structural orientation from ``.devcouncil/repo_map.json`` — the fallback used
|
|
318
|
+
when the optional code-review-graph CLI is absent (the common case). Surfaces the
|
|
319
|
+
subsystem(s) the planned files live in, their key files, neighbors, and flow, so
|
|
320
|
+
an agent in an unfamiliar repo knows where it is before editing."""
|
|
321
|
+
if data is None:
|
|
322
|
+
data = self._load_repo_map()
|
|
323
|
+
if not data:
|
|
324
|
+
return ""
|
|
325
|
+
subsystems = data.get("subsystems") or []
|
|
326
|
+
files_by_path = {
|
|
327
|
+
f.get("path"): f for f in (data.get("files") or []) if isinstance(f, dict)
|
|
328
|
+
}
|
|
329
|
+
norm = [p.replace("\\", "/") for p in planned_paths]
|
|
330
|
+
relevant = [
|
|
331
|
+
s for s in subsystems
|
|
332
|
+
if isinstance(s, dict) and s.get("area")
|
|
333
|
+
and any(p == s["area"] or p.startswith(s["area"] + "/") for p in norm)
|
|
334
|
+
]
|
|
335
|
+
if not relevant:
|
|
336
|
+
return ""
|
|
337
|
+
lines = ["## Repo map (structural context)"]
|
|
338
|
+
for s in relevant[:3]:
|
|
339
|
+
lines.append(f"\n**{s.get('area')}** — {s.get('summary', '')}".rstrip())
|
|
340
|
+
critical = [c for c in (s.get("critical_files") or []) if c not in norm][:6]
|
|
341
|
+
if critical:
|
|
342
|
+
lines.append("Key files:")
|
|
343
|
+
for c in critical:
|
|
344
|
+
summary = (files_by_path.get(c) or {}).get("summary", "")
|
|
345
|
+
lines.append(f"- `{c}`" + (f" — {summary}" if summary else ""))
|
|
346
|
+
neighbors = s.get("neighbors") or []
|
|
347
|
+
if neighbors:
|
|
348
|
+
lines.append("Neighboring subsystems: " + ", ".join(f"`{n}`" for n in neighbors[:6]))
|
|
349
|
+
handoffs = s.get("handoff_paths") or []
|
|
350
|
+
if handoffs:
|
|
351
|
+
lines.append("Cross-subsystem flow: " + "; ".join(handoffs[:4]))
|
|
352
|
+
return "\n".join(lines).strip() + "\n"
|
|
353
|
+
|
|
354
|
+
def _skills_section(self, task: Task) -> str:
|
|
355
|
+
"""The full engineering-skill intake that applies to this task.
|
|
356
|
+
|
|
357
|
+
Selection is codebase-aware (task goal keywords + the repo's own files, so an
|
|
358
|
+
Android repo pulls the android skill via build.gradle even when the task text
|
|
359
|
+
doesn't say "android"). The full skill text is injected inline — the senior-dev
|
|
360
|
+
intake (current libraries, deprecations to avoid, the right build/test CLI
|
|
361
|
+
commands) goes straight to the coding agent rather than relying on it to open
|
|
362
|
+
scaffolded files. Never raises — a skills failure must not break prompt building.
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
from devcouncil.skills.registry import bound_skills, render_preamble, select_skills
|
|
366
|
+
|
|
367
|
+
goal = f"{task.title}\n{task.description}"
|
|
368
|
+
selected = select_skills(goal=goal, project_root=self.project_root)
|
|
369
|
+
# Bound how much skill text rides inline so a repo that matches many skills
|
|
370
|
+
# can't blow up the task prompt; deferred skills are still on disk.
|
|
371
|
+
inline, deferred = bound_skills(selected)
|
|
372
|
+
preamble = render_preamble(inline)
|
|
373
|
+
except Exception:
|
|
374
|
+
return ""
|
|
375
|
+
if not selected or not preamble:
|
|
376
|
+
return ""
|
|
377
|
+
|
|
378
|
+
names = ", ".join(skill.name for skill in selected)
|
|
379
|
+
section = (
|
|
380
|
+
"\n## Engineering skills (apply before and while coding)\n"
|
|
381
|
+
f"_Applicable skills: {names}. Follow this current-practice intake; "
|
|
382
|
+
"don't rely on stale training data._\n\n"
|
|
383
|
+
f"{preamble}\n"
|
|
384
|
+
)
|
|
385
|
+
if deferred:
|
|
386
|
+
section += "\n_Also applicable (read the full text in `.claude/skills/<name>/SKILL.md`):_\n"
|
|
387
|
+
for skill in deferred:
|
|
388
|
+
blurb = skill.description or skill.title
|
|
389
|
+
suffix = f" — {blurb}" if blurb else ""
|
|
390
|
+
section += f"- `{skill.name}`{suffix}\n"
|
|
391
|
+
return section
|
|
392
|
+
|
|
393
|
+
def _knowledge_sections(self, task: Task, cfg=None) -> tuple[str, str]:
|
|
394
|
+
"""Selected design-system and OKF knowledge context for this task.
|
|
395
|
+
|
|
396
|
+
Returns ``(design_text, knowledge_text)`` — either may be empty. Sourced from
|
|
397
|
+
``.devcouncil/knowledge/{design,okf}`` via the same trigger-based selection the
|
|
398
|
+
skills library uses: a design system is always-on (a UI agent must honor it),
|
|
399
|
+
OKF knowledge fires on goal keywords / document tags. Bounded by config char
|
|
400
|
+
budgets. Never raises — a knowledge failure must not break prompt building.
|
|
401
|
+
|
|
402
|
+
``cfg`` may be a pre-loaded config (loaded once per task by ``build_task_prompt``);
|
|
403
|
+
when ``None`` it is loaded here so other callers keep working."""
|
|
404
|
+
try:
|
|
405
|
+
from devcouncil.knowledge.sources import (
|
|
406
|
+
render_knowledge_preamble,
|
|
407
|
+
select_knowledge_sources,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if cfg is None:
|
|
411
|
+
from devcouncil.app.config import load_config
|
|
412
|
+
|
|
413
|
+
cfg = load_config(self.project_root)
|
|
414
|
+
kcfg = cfg.knowledge
|
|
415
|
+
if not kcfg.enabled:
|
|
416
|
+
return "", ""
|
|
417
|
+
goal = f"{task.title}\n{task.description}"
|
|
418
|
+
sources = select_knowledge_sources(
|
|
419
|
+
goal=goal, project_root=self.project_root,
|
|
420
|
+
directory=kcfg.directory, design_always=kcfg.design_always,
|
|
421
|
+
)
|
|
422
|
+
design_text = render_knowledge_preamble(sources, max_chars=kcfg.design_max_chars, kind="design")
|
|
423
|
+
knowledge_text = render_knowledge_preamble(sources, max_chars=kcfg.okf_max_chars, kind="okf")
|
|
424
|
+
except Exception:
|
|
425
|
+
return "", ""
|
|
426
|
+
|
|
427
|
+
design_block = ""
|
|
428
|
+
if design_text:
|
|
429
|
+
design_block = (
|
|
430
|
+
"\n## Design system (honor these tokens and rules)\n"
|
|
431
|
+
"_The project's design.md. Use these tokens/components; don't invent ad-hoc styles._\n\n"
|
|
432
|
+
f"{design_text}\n"
|
|
433
|
+
)
|
|
434
|
+
knowledge_block = ""
|
|
435
|
+
if knowledge_text:
|
|
436
|
+
knowledge_block = (
|
|
437
|
+
"\n## Project knowledge (Open Knowledge Format)\n"
|
|
438
|
+
"_Curated org/domain knowledge relevant to this task. Ground your work in it._\n\n"
|
|
439
|
+
f"{knowledge_text}\n"
|
|
440
|
+
)
|
|
441
|
+
return design_block, knowledge_block
|
|
442
|
+
|
|
443
|
+
def _dependents_section(self, task: Task, data: dict | None) -> str:
|
|
444
|
+
"""List, per planned file the agent will change, the files that import it — the
|
|
445
|
+
blast radius. Sourced from repo_map.json's precomputed reverse-import index, so
|
|
446
|
+
the agent updates or preserves call sites instead of silently breaking them."""
|
|
447
|
+
dependents = (data or {}).get("dependents") or {}
|
|
448
|
+
if not isinstance(dependents, dict) or not dependents:
|
|
449
|
+
return ""
|
|
450
|
+
lines: List[str] = []
|
|
451
|
+
for pf in task.planned_files:
|
|
452
|
+
# New files have no dependents yet; only existing code carries blast radius.
|
|
453
|
+
if pf.allowed_change == "create":
|
|
454
|
+
continue
|
|
455
|
+
# Normalize the planned path to posix before the lookup — the map keys are
|
|
456
|
+
# always posix, so a backslash planned path on Windows would otherwise miss
|
|
457
|
+
# and silently drop the whole blast-radius entry (see _repo_map_section).
|
|
458
|
+
importers = dependents.get(pf.path.replace("\\", "/")) or []
|
|
459
|
+
if not importers:
|
|
460
|
+
continue
|
|
461
|
+
shown = importers[:8]
|
|
462
|
+
more = f" (+{len(importers) - len(shown)} more)" if len(importers) > len(shown) else ""
|
|
463
|
+
lines.append(f"- `{pf.path}` is imported by: " + ", ".join(f"`{p}`" for p in shown) + more)
|
|
464
|
+
if not lines:
|
|
465
|
+
return ""
|
|
466
|
+
return (
|
|
467
|
+
"\n## Dependents (blast radius)\n"
|
|
468
|
+
"_These files import the files you're changing — keep their call sites working, "
|
|
469
|
+
"or update them in scope._\n"
|
|
470
|
+
+ "\n".join(lines)
|
|
471
|
+
+ "\n"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Call-sites block bounds: keep it tight so this lowest-priority context can't crowd
|
|
475
|
+
# out the file bodies / dependents it complements.
|
|
476
|
+
_CALL_SITES_MAX_DEP_FILES = 3 # dependent files grepped per changed file
|
|
477
|
+
_CALL_SITES_MAX_SYMBOLS = 6 # exported symbols searched per changed file
|
|
478
|
+
_CALL_SITES_MAX_LINES_PER_FILE = 3 # referencing lines emitted per dependent file
|
|
479
|
+
_CALL_SITES_MAX_TOTAL = 24 # hard cap on emitted file:line rows
|
|
480
|
+
_CALL_SITES_LINE_CHARS = 160 # truncate a long using line
|
|
481
|
+
|
|
482
|
+
def _exported_symbol_names(self, path: str, text: str) -> List[str]:
|
|
483
|
+
"""Top-level symbol names from a file's outline (no signatures), used to grep
|
|
484
|
+
dependents for referencing lines."""
|
|
485
|
+
names: List[str] = []
|
|
486
|
+
for entry in self._symbol_outline(path, text):
|
|
487
|
+
stripped = entry.strip()
|
|
488
|
+
# Outline rows look like "def name(args) L1", "class Name L5",
|
|
489
|
+
# "export function Name L3", " async def m(...) Lx" — pull the identifier
|
|
490
|
+
# that precedes the first "(" or " L".
|
|
491
|
+
head = stripped.split(" L")[0]
|
|
492
|
+
head = head.split("(")[0].strip()
|
|
493
|
+
ident = head.split()[-1] if head.split() else ""
|
|
494
|
+
ident = ident.strip(":")
|
|
495
|
+
if ident and ident.isidentifier() and ident not in names:
|
|
496
|
+
names.append(ident)
|
|
497
|
+
if len(names) >= self._CALL_SITES_MAX_SYMBOLS:
|
|
498
|
+
break
|
|
499
|
+
return names
|
|
500
|
+
|
|
501
|
+
def _call_sites_section(self, task: Task, data: dict | None) -> str:
|
|
502
|
+
"""Lowest-priority context: for each changed file, show where its exported symbols
|
|
503
|
+
are actually used in the top dependent files (file:line + the using line). Helps
|
|
504
|
+
the agent update call sites in scope. Tightly bounded; never raises."""
|
|
505
|
+
dependents = (data or {}).get("dependents") or {}
|
|
506
|
+
if not isinstance(dependents, dict) or not dependents:
|
|
507
|
+
return ""
|
|
508
|
+
lines: List[str] = []
|
|
509
|
+
emitted = 0
|
|
510
|
+
for pf in task.planned_files:
|
|
511
|
+
if pf.allowed_change == "create" or emitted >= self._CALL_SITES_MAX_TOTAL:
|
|
512
|
+
continue
|
|
513
|
+
key = pf.path.replace("\\", "/")
|
|
514
|
+
importers = dependents.get(key) or []
|
|
515
|
+
if not importers:
|
|
516
|
+
continue
|
|
517
|
+
src_path = self.project_root / pf.path
|
|
518
|
+
try:
|
|
519
|
+
src_text = src_path.read_text(encoding="utf-8", errors="replace")
|
|
520
|
+
except Exception:
|
|
521
|
+
continue
|
|
522
|
+
symbols = self._exported_symbol_names(pf.path, src_text)
|
|
523
|
+
if not symbols:
|
|
524
|
+
continue
|
|
525
|
+
file_rows: List[str] = []
|
|
526
|
+
for importer in importers[: self._CALL_SITES_MAX_DEP_FILES]:
|
|
527
|
+
if emitted >= self._CALL_SITES_MAX_TOTAL:
|
|
528
|
+
break
|
|
529
|
+
try:
|
|
530
|
+
dep_text = (self.project_root / importer).read_text(encoding="utf-8", errors="replace")
|
|
531
|
+
except Exception:
|
|
532
|
+
continue
|
|
533
|
+
hits = 0
|
|
534
|
+
for lineno, raw in enumerate(dep_text.splitlines(), start=1):
|
|
535
|
+
if hits >= self._CALL_SITES_MAX_LINES_PER_FILE or emitted >= self._CALL_SITES_MAX_TOTAL:
|
|
536
|
+
break
|
|
537
|
+
if any(self._references_symbol(raw, sym) for sym in symbols):
|
|
538
|
+
snippet = raw.strip()[: self._CALL_SITES_LINE_CHARS]
|
|
539
|
+
file_rows.append(f" - `{importer}:{lineno}` — `{snippet}`")
|
|
540
|
+
hits += 1
|
|
541
|
+
emitted += 1
|
|
542
|
+
if file_rows:
|
|
543
|
+
lines.append(f"- `{pf.path}` (uses of {', '.join(f'`{s}`' for s in symbols)}):")
|
|
544
|
+
lines.extend(file_rows)
|
|
545
|
+
if not lines:
|
|
546
|
+
return ""
|
|
547
|
+
return (
|
|
548
|
+
"\n## Call sites (where your symbols are used)\n"
|
|
549
|
+
"_Referencing lines in dependent files — update these if you change a signature._\n"
|
|
550
|
+
+ "\n".join(lines)
|
|
551
|
+
+ "\n"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
@staticmethod
|
|
555
|
+
def _references_symbol(line: str, symbol: str) -> bool:
|
|
556
|
+
"""Whole-word match of ``symbol`` in ``line``. Cheap; avoids matching substrings
|
|
557
|
+
of longer identifiers."""
|
|
558
|
+
idx = line.find(symbol)
|
|
559
|
+
if idx < 0:
|
|
560
|
+
return False
|
|
561
|
+
before = line[idx - 1] if idx > 0 else ""
|
|
562
|
+
after = line[idx + len(symbol)] if idx + len(symbol) < len(line) else ""
|
|
563
|
+
return not (before.isalnum() or before == "_") and not (after.isalnum() or after == "_")
|
|
564
|
+
|
|
565
|
+
# Bound the dependency-risk block so this low-priority, opt-in context can't
|
|
566
|
+
# crowd out file bodies / dependents.
|
|
567
|
+
_DEP_RISKS_MAX = 12
|
|
568
|
+
|
|
569
|
+
def _dependency_risks_section(self, data: dict | None) -> str:
|
|
570
|
+
"""Surface dependency vulnerabilities recorded in repo_map.json (opt-in SCA).
|
|
571
|
+
|
|
572
|
+
Lowest-priority, optional context: warns an agent that may bump a vulnerable
|
|
573
|
+
dependency. Absent unless `dev map` was run with SCA enabled. Never raises."""
|
|
574
|
+
risks = (data or {}).get("dependency_risks") or []
|
|
575
|
+
if not isinstance(risks, list) or not risks:
|
|
576
|
+
return ""
|
|
577
|
+
lines: List[str] = []
|
|
578
|
+
for risk in risks[: self._DEP_RISKS_MAX]:
|
|
579
|
+
if not isinstance(risk, dict):
|
|
580
|
+
continue
|
|
581
|
+
pkg = str(risk.get("package", "")).strip() or "(unknown)"
|
|
582
|
+
version = str(risk.get("installed_version", "")).strip()
|
|
583
|
+
severity = str(risk.get("severity", "")).strip() or "unknown"
|
|
584
|
+
advisory = str(risk.get("advisory_id", "")).strip()
|
|
585
|
+
summary = str(risk.get("summary", "")).strip()
|
|
586
|
+
head = f"`{pkg}`" + (f" {version}" if version else "")
|
|
587
|
+
tail = f" [{severity}]"
|
|
588
|
+
if advisory:
|
|
589
|
+
tail += f" {advisory}"
|
|
590
|
+
if summary:
|
|
591
|
+
tail += f" — {summary[:160]}"
|
|
592
|
+
lines.append(f"- {head}{tail}")
|
|
593
|
+
if not lines:
|
|
594
|
+
return ""
|
|
595
|
+
more = len(risks) - len(lines)
|
|
596
|
+
if more > 0:
|
|
597
|
+
lines.append(f"- _(+{more} more — see `.devcouncil/repo_map.json`)_")
|
|
598
|
+
return (
|
|
599
|
+
"\n## Dependency risks (known vulnerabilities)\n"
|
|
600
|
+
"_Reported by a local dependency auditor. Avoid bumping a listed package to a "
|
|
601
|
+
"still-vulnerable version; prefer a patched release._\n"
|
|
602
|
+
+ "\n".join(lines)
|
|
603
|
+
+ "\n"
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
@staticmethod
|
|
607
|
+
def _fit_segments(segments: list[dict], budget: int) -> str:
|
|
608
|
+
"""Fit optional context segments into ``budget`` chars. Segments are kept in
|
|
609
|
+
priority order (lower = more important) and emitted in display order; any that
|
|
610
|
+
don't fit are dropped with an explicit marker so truncation is never silent."""
|
|
611
|
+
kept: list[dict] = []
|
|
612
|
+
used = 0
|
|
613
|
+
dropped: list[dict] = []
|
|
614
|
+
for seg in sorted(segments, key=lambda s: (s["priority"], s["order"])):
|
|
615
|
+
if budget > 0 and used + len(seg["text"]) <= budget:
|
|
616
|
+
kept.append(seg)
|
|
617
|
+
used += len(seg["text"])
|
|
618
|
+
else:
|
|
619
|
+
dropped.append(seg)
|
|
620
|
+
body = "".join(seg["text"] for seg in sorted(kept, key=lambda s: s["order"]))
|
|
621
|
+
if dropped:
|
|
622
|
+
names = ", ".join(s["name"] for s in sorted(dropped, key=lambda s: s["order"]))
|
|
623
|
+
body += f"\n_[Context budget reached — omitted: {names}. Open these directly if needed.]_\n"
|
|
624
|
+
return body
|
|
625
|
+
|
|
626
|
+
def build_task_prompt(
|
|
627
|
+
self, task: Task, requirements: List[Requirement], *, max_chars: int | None = None
|
|
628
|
+
) -> str:
|
|
629
|
+
if max_chars is None:
|
|
630
|
+
max_chars = MAX_PROMPT_CHARS
|
|
631
|
+
# Per-task outline cache so a planned file's symbol outline is computed once even
|
|
632
|
+
# though both the planned-files section and the call-sites section consume it.
|
|
633
|
+
self._outline_cache: dict[str, List[str]] = {}
|
|
634
|
+
# Load the project config once and share it with the helpers that need it, instead
|
|
635
|
+
# of each independently re-reading + re-parsing it. Best-effort: if it fails the
|
|
636
|
+
# helpers fall back to loading it themselves (and degrade the same way).
|
|
637
|
+
try:
|
|
638
|
+
from devcouncil.app.config import load_config
|
|
639
|
+
|
|
640
|
+
cfg = load_config(self.project_root)
|
|
641
|
+
except Exception:
|
|
642
|
+
cfg = None
|
|
643
|
+
# When the run targets a constrained local window (Ollama + OLLAMA_NUM_CTX),
|
|
644
|
+
# cap the budget so the server doesn't silently truncate past the window. Never
|
|
645
|
+
# raises the budget above the caller's value — only lowers it to fit.
|
|
646
|
+
window_budget = _local_context_window_budget(self.project_root, cfg=cfg)
|
|
647
|
+
if window_budget is not None:
|
|
648
|
+
max_chars = min(max_chars, window_budget)
|
|
649
|
+
|
|
13
650
|
req_map = {r.id: r for r in requirements}
|
|
14
651
|
task_reqs = [req_map[rid] for rid in task.requirement_ids if rid in req_map]
|
|
15
|
-
|
|
16
|
-
|
|
652
|
+
|
|
653
|
+
# --- Core: always kept (the goal/scope/instructions the agent must have). ---
|
|
654
|
+
core = f"""# Implement {task.id}: {task.title}
|
|
17
655
|
|
|
18
656
|
## Goal
|
|
19
657
|
{task.description}
|
|
@@ -21,39 +659,129 @@ class PromptBuilder:
|
|
|
21
659
|
## Requirements
|
|
22
660
|
"""
|
|
23
661
|
for req in task_reqs:
|
|
24
|
-
|
|
662
|
+
core += f"- {req.id}: {req.title}\n"
|
|
25
663
|
for ac in req.acceptance_criteria:
|
|
26
|
-
|
|
664
|
+
core += f" - [ ] {ac.description} ({ac.verification_method})\n"
|
|
665
|
+
|
|
666
|
+
# Carry the planning prompt-enhancer's codebase-specific constraints through to the
|
|
667
|
+
# one who writes the code. Otherwise that domain guidance (e.g. "division truncates
|
|
668
|
+
# toward zero", "no eval") shapes only the planning debate and reaches the executor
|
|
669
|
+
# only if a planner happened to encode it into an acceptance criterion.
|
|
670
|
+
enhancement = self._load_prompt_enhancement()
|
|
671
|
+
if enhancement is not None and (enhancement.constraints or enhancement.applied_skills):
|
|
672
|
+
core += "\n## Codebase-specific constraints (from planning — honor these)\n"
|
|
673
|
+
for constraint in enhancement.constraints[:8]:
|
|
674
|
+
core += f"- {constraint}\n"
|
|
675
|
+
if enhancement.applied_skills:
|
|
676
|
+
core += f"- Apply current senior-level practices for: {', '.join(enhancement.applied_skills[:6])}.\n"
|
|
27
677
|
|
|
28
|
-
|
|
678
|
+
core += "\n## Allowed files\n"
|
|
29
679
|
for pf in task.planned_files:
|
|
30
|
-
|
|
680
|
+
core += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
|
|
31
681
|
|
|
32
682
|
if task.forbidden_changes:
|
|
33
|
-
|
|
683
|
+
core += (
|
|
684
|
+
"\n## Forbidden changes\n"
|
|
685
|
+
"_Do not modify these. Verification always rejects them; on hook-enabled "
|
|
686
|
+
"clients they are also blocked before the write._\n"
|
|
687
|
+
)
|
|
34
688
|
for fc in task.forbidden_changes:
|
|
35
|
-
|
|
689
|
+
core += f"- `{fc}`\n"
|
|
36
690
|
|
|
37
|
-
|
|
691
|
+
core += "\n## Expected tests\n"
|
|
38
692
|
for et in task.expected_tests:
|
|
39
|
-
|
|
693
|
+
core += f"- `{et}`\n"
|
|
40
694
|
|
|
41
|
-
|
|
695
|
+
core += "\n## Allowed commands\n"
|
|
42
696
|
for cmd in task.allowed_commands:
|
|
43
|
-
|
|
697
|
+
core += f"- `{cmd}`\n"
|
|
44
698
|
|
|
45
|
-
|
|
46
|
-
[planned.path for planned in task.planned_files]
|
|
47
|
-
)
|
|
48
|
-
if graph_context:
|
|
49
|
-
prompt += f"\n{graph_context}"
|
|
50
|
-
|
|
51
|
-
prompt += """
|
|
699
|
+
instructions = """
|
|
52
700
|
## Instructions
|
|
53
701
|
1. Implement the goal described above.
|
|
54
702
|
2. Ensure all acceptance criteria are met.
|
|
55
703
|
3. Only modify the allowed files.
|
|
56
|
-
4.
|
|
57
|
-
|
|
704
|
+
4. Stay within this task's scope even inside an allowed file: change only what the
|
|
705
|
+
acceptance criteria require. Do NOT remove, rename, or alter the signature of an
|
|
706
|
+
existing public symbol the task did not ask you to touch — verification flags an
|
|
707
|
+
unrequested public-API change as scope drift and blocks it.
|
|
708
|
+
5. Run the allowed commands to verify your work.
|
|
709
|
+
6. Provide evidence of passing tests.
|
|
58
710
|
"""
|
|
59
|
-
|
|
711
|
+
|
|
712
|
+
# --- Optional context: fitted within the remaining budget, dropped lowest-
|
|
713
|
+
# priority first. Priority: file contents (1) > structural (2) ~ dependents (2)
|
|
714
|
+
# > skills (3); display order keeps the original reading sequence. ---
|
|
715
|
+
repo_map_data = self._load_repo_map()
|
|
716
|
+
repo_map_stale = self._repo_map_stale(repo_map_data)
|
|
717
|
+
planned_paths = [planned.path for planned in task.planned_files]
|
|
718
|
+
segments: list[dict] = []
|
|
719
|
+
|
|
720
|
+
graph_context = CodeReviewGraphAdapter(self.project_root).prompt_section(planned_paths)
|
|
721
|
+
struct_text = ""
|
|
722
|
+
struct_has_stale_note = False
|
|
723
|
+
if graph_context:
|
|
724
|
+
struct_text = f"\n{graph_context}"
|
|
725
|
+
else:
|
|
726
|
+
repo_map_context = self._repo_map_section(planned_paths, repo_map_data)
|
|
727
|
+
if repo_map_context:
|
|
728
|
+
prefix = f"\n{self._STALE_MAP_NOTE}" if repo_map_stale else ""
|
|
729
|
+
struct_has_stale_note = repo_map_stale
|
|
730
|
+
struct_text = f"{prefix}\n{repo_map_context}"
|
|
731
|
+
if struct_text:
|
|
732
|
+
segments.append({"order": 1, "priority": 2, "name": "structural context", "text": struct_text})
|
|
733
|
+
elif repo_map_data is None:
|
|
734
|
+
# No graph CLI and the repo map file is entirely absent (not merely stale):
|
|
735
|
+
# nudge the agent to run `dev map`, surfaced the same way staleness is.
|
|
736
|
+
segments.append({
|
|
737
|
+
"order": 1, "priority": 2, "name": "no repo map note",
|
|
738
|
+
"text": f"\n{self._NO_MAP_NOTE}",
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
files_text = self._planned_files_section(task)
|
|
742
|
+
if files_text:
|
|
743
|
+
segments.append({"order": 2, "priority": 1, "name": "file contents", "text": files_text})
|
|
744
|
+
|
|
745
|
+
dependents_section = self._dependents_section(task, repo_map_data)
|
|
746
|
+
if dependents_section:
|
|
747
|
+
prefix = f"\n{self._STALE_MAP_NOTE}" if (repo_map_stale and not struct_has_stale_note) else ""
|
|
748
|
+
segments.append({"order": 3, "priority": 2, "name": "dependents", "text": prefix + dependents_section})
|
|
749
|
+
|
|
750
|
+
skills_text = self._skills_section(task)
|
|
751
|
+
if skills_text:
|
|
752
|
+
segments.append({"order": 4, "priority": 3, "name": "engineering skills", "text": skills_text})
|
|
753
|
+
|
|
754
|
+
# Design system (a hard constraint for UI work) and OKF project knowledge. The
|
|
755
|
+
# design system rides just above skills; OKF knowledge alongside them. Both are
|
|
756
|
+
# bounded by config char budgets in `_knowledge_sections`.
|
|
757
|
+
design_text, knowledge_text = self._knowledge_sections(task, cfg=cfg)
|
|
758
|
+
if design_text:
|
|
759
|
+
segments.append({"order": 4, "priority": 2, "name": "design system", "text": design_text})
|
|
760
|
+
if knowledge_text:
|
|
761
|
+
segments.append({"order": 4, "priority": 3, "name": "project knowledge", "text": knowledge_text})
|
|
762
|
+
|
|
763
|
+
# Lowest priority (4): the budget drops call sites first. It only adds value once
|
|
764
|
+
# the file bodies + dependents are present anyway.
|
|
765
|
+
call_sites_text = self._call_sites_section(task, repo_map_data)
|
|
766
|
+
if call_sites_text:
|
|
767
|
+
segments.append({"order": 5, "priority": 4, "name": "call sites", "text": call_sites_text})
|
|
768
|
+
|
|
769
|
+
# Lowest priority (5): dependency risks are opt-in, advisory context — the
|
|
770
|
+
# budget drops them first so they never displace structural/file context.
|
|
771
|
+
dependency_risks_text = self._dependency_risks_section(repo_map_data)
|
|
772
|
+
if dependency_risks_text:
|
|
773
|
+
segments.append({"order": 6, "priority": 5, "name": "dependency risks", "text": dependency_risks_text})
|
|
774
|
+
|
|
775
|
+
# The core + instructions are never dropped; if they alone exceed the budget the
|
|
776
|
+
# model's server will truncate them, so warn loudly instead of failing silently.
|
|
777
|
+
core_len = len(core) + len(instructions)
|
|
778
|
+
if core_len > max_chars:
|
|
779
|
+
logger.warning(
|
|
780
|
+
"Task %s core prompt (%d chars) exceeds the context budget (%d chars). "
|
|
781
|
+
"On a local model with a small OLLAMA_NUM_CTX this will be truncated server-side; "
|
|
782
|
+
"raise OLLAMA_NUM_CTX or split the task.",
|
|
783
|
+
task.id, core_len, max_chars,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
optional = self._fit_segments(segments, max_chars - core_len)
|
|
787
|
+
return core + optional + instructions
|