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,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
- def build_task_prompt(self, task: Task, requirements: List[Requirement]) -> str:
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
- prompt = f"""# Implement {task.id}: {task.title}
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
- prompt += f"- {req.id}: {req.title}\n"
662
+ core += f"- {req.id}: {req.title}\n"
25
663
  for ac in req.acceptance_criteria:
26
- prompt += f" - [ ] {ac.description} ({ac.verification_method})\n"
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
- prompt += "\n## Allowed files\n"
678
+ core += "\n## Allowed files\n"
29
679
  for pf in task.planned_files:
30
- prompt += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
680
+ core += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
31
681
 
32
682
  if task.forbidden_changes:
33
- prompt += "\n## Forbidden changes\n"
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
- prompt += f"- `{fc}`\n"
689
+ core += f"- `{fc}`\n"
36
690
 
37
- prompt += "\n## Expected tests\n"
691
+ core += "\n## Expected tests\n"
38
692
  for et in task.expected_tests:
39
- prompt += f"- `{et}`\n"
693
+ core += f"- `{et}`\n"
40
694
 
41
- prompt += "\n## Allowed commands\n"
695
+ core += "\n## Allowed commands\n"
42
696
  for cmd in task.allowed_commands:
43
- prompt += f"- `{cmd}`\n"
697
+ core += f"- `{cmd}`\n"
44
698
 
45
- graph_context = CodeReviewGraphAdapter(self.project_root).prompt_section(
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. Run the allowed commands to verify your work.
57
- 5. Provide evidence of passing tests.
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
- return prompt
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