devcouncil 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. 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,55 +9,663 @@ 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) -> 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
+ try:
47
+ from devcouncil.app.config import load_config
48
+
49
+ provider = load_config(project_root).models.provider.strip().lower()
50
+ if provider not in {"ollama", "ollama-local", "ollama_local"}:
51
+ return None
52
+ except Exception:
53
+ return None
54
+
55
+ from devcouncil.llm.provider import OllamaProvider
56
+
57
+ num_ctx = OllamaProvider._resolve_num_ctx()
58
+ if not num_ctx:
59
+ # No explicit window: Ollama uses a small default, but DevCouncil cannot know it.
60
+ # `dev doctor` already warns to set OLLAMA_NUM_CTX; don't guess a cap here.
61
+ return None
62
+ usable_tokens = num_ctx - _RESERVED_COMPLETION_TOKENS
63
+ if usable_tokens <= 0:
64
+ return _MIN_PROMPT_CHARS
65
+ return max(_MIN_PROMPT_CHARS, usable_tokens * _CHARS_PER_TOKEN)
66
+
67
+ _LANG_BY_EXT = {
68
+ ".py": "python", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript",
69
+ ".tsx": "tsx", ".go": "go", ".rs": "rust", ".java": "java", ".kt": "kotlin",
70
+ ".swift": "swift", ".rb": "ruby", ".cs": "csharp", ".cpp": "cpp", ".c": "c",
71
+ ".sh": "bash", ".yml": "yaml", ".yaml": "yaml", ".json": "json", ".toml": "toml",
72
+ ".md": "markdown", ".sql": "sql",
73
+ }
74
+
75
+
8
76
  class PromptBuilder:
9
77
  def __init__(self, project_root: Path = Path(".")):
10
78
  self.project_root = project_root
11
79
 
12
- def build_task_prompt(self, task: Task, requirements: List[Requirement]) -> str:
13
- req_map = {r.id: r for r in requirements}
14
- 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}
17
-
18
- ## Goal
19
- {task.description}
20
-
21
- ## Requirements
22
- """
23
- for req in task_reqs:
24
- prompt += f"- {req.id}: {req.title}\n"
25
- for ac in req.acceptance_criteria:
26
- prompt += f" - [ ] {ac.description} ({ac.verification_method})\n"
27
-
28
- prompt += "\n## Allowed files\n"
80
+ @staticmethod
81
+ def _lang_for(path: str) -> str:
82
+ return _LANG_BY_EXT.get(Path(path).suffix.lower(), "")
83
+
84
+ def _symbol_outline(self, path: str, text: str) -> List[str]:
85
+ """Cheap top-level symbol index (signatures + line numbers) so the agent edits
86
+ in place and uses correct names/arities instead of guessing or duplicating.
87
+
88
+ Python uses stdlib ``ast`` (method signatures, async/@property/@staticmethod
89
+ markers under each class). Other languages (ts/tsx/js/jsx/go/rs/java) use bounded
90
+ regex over exported/public declarations. No tree-sitter, no model call. Never
91
+ raises; honors the per-file symbol cap."""
92
+ if path.endswith(".py"):
93
+ return self._python_symbol_outline(text)
94
+ return self._regex_symbol_outline(path, text)
95
+
96
+ def _python_symbol_outline(self, text: str) -> List[str]:
97
+ try:
98
+ tree = ast.parse(text)
99
+ except Exception:
100
+ return []
101
+
102
+ def _decorator_markers(node) -> str:
103
+ names: set[str] = set()
104
+ for dec in getattr(node, "decorator_list", []):
105
+ target = dec.func if isinstance(dec, ast.Call) else dec
106
+ if isinstance(target, ast.Attribute):
107
+ names.add(target.attr)
108
+ elif isinstance(target, ast.Name):
109
+ names.add(target.id)
110
+ marks = [m for m in ("property", "staticmethod", "classmethod") if m in names]
111
+ return (" @" + " @".join(marks)) if marks else ""
112
+
113
+ def _func_sig(node) -> str:
114
+ args = ", ".join(a.arg for a in node.args.args)
115
+ kw = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
116
+ return f"{kw}def {node.name}({args}) L{node.lineno}{_decorator_markers(node)}"
117
+
118
+ out: List[str] = []
119
+ for node in tree.body:
120
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
121
+ out.append(_func_sig(node))
122
+ elif isinstance(node, ast.ClassDef):
123
+ out.append(f"class {node.name} L{node.lineno}")
124
+ for n in node.body:
125
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
126
+ out.append(" " + _func_sig(n))
127
+ if len(out) >= MAX_SYMBOLS_PER_FILE:
128
+ break
129
+ if len(out) >= MAX_SYMBOLS_PER_FILE:
130
+ break
131
+ return out[:MAX_SYMBOLS_PER_FILE]
132
+
133
+ # Bounded per-language regexes over exported/public top-level declarations. Each
134
+ # capture group 2 is the symbol name; group 1 (when present) is the keyword/kind.
135
+ _OUTLINE_PATTERNS: dict[str, list[tuple[str, re.Pattern[str]]]] = {}
136
+
137
+ def _regex_symbol_outline(self, path: str, text: str) -> List[str]:
138
+ suffix = Path(path).suffix.lower()
139
+ lang = {
140
+ ".ts": "ts", ".tsx": "ts", ".js": "js", ".jsx": "js",
141
+ ".go": "go", ".rs": "rs", ".java": "java",
142
+ }.get(suffix)
143
+ if not lang:
144
+ return []
145
+ patterns = self._regex_outline_patterns().get(lang)
146
+ if not patterns:
147
+ return []
148
+ out: List[str] = []
149
+ for lineno, raw in enumerate(text.splitlines(), start=1):
150
+ for label, pattern in patterns:
151
+ m = pattern.match(raw)
152
+ if not m:
153
+ continue
154
+ name = m.group("name")
155
+ out.append(f"{label} {name} L{lineno}")
156
+ break
157
+ if len(out) >= MAX_SYMBOLS_PER_FILE:
158
+ break
159
+ return out[:MAX_SYMBOLS_PER_FILE]
160
+
161
+ @classmethod
162
+ def _regex_outline_patterns(cls):
163
+ if cls._OUTLINE_PATTERNS:
164
+ return cls._OUTLINE_PATTERNS
165
+ n = r"(?P<name>[A-Za-z_$][\w$]*)"
166
+ ts = [
167
+ ("export class", re.compile(r"^\s*export\s+(?:default\s+)?(?:abstract\s+)?class\s+" + n)),
168
+ ("export interface", re.compile(r"^\s*export\s+interface\s+" + n)),
169
+ ("export type", re.compile(r"^\s*export\s+type\s+" + n)),
170
+ ("export enum", re.compile(r"^\s*export\s+(?:const\s+)?enum\s+" + n)),
171
+ ("export function", re.compile(r"^\s*export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s+" + n)),
172
+ ("export const", re.compile(r"^\s*export\s+(?:const|let|var)\s+" + n)),
173
+ ]
174
+ js = [
175
+ ("export class", re.compile(r"^\s*export\s+(?:default\s+)?class\s+" + n)),
176
+ ("export function", re.compile(r"^\s*export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s+" + n)),
177
+ ("export const", re.compile(r"^\s*export\s+(?:const|let|var)\s+" + n)),
178
+ ("class", re.compile(r"^\s*class\s+" + n)),
179
+ ("function", re.compile(r"^\s*(?:async\s+)?function\s*\*?\s+" + n)),
180
+ ]
181
+ go = [
182
+ # Go exports = capitalized identifiers; func may carry a receiver.
183
+ ("func", re.compile(r"^func\s+(?:\([^)]*\)\s*)?(?P<name>[A-Z]\w*)\s*[\(\[]")),
184
+ ("type", re.compile(r"^type\s+(?P<name>[A-Z]\w*)\s+")),
185
+ ]
186
+ rs = [
187
+ ("pub fn", re.compile(r"^\s*pub(?:\([^)]*\))?\s+(?:async\s+)?(?:unsafe\s+)?fn\s+" + n)),
188
+ ("pub struct", re.compile(r"^\s*pub(?:\([^)]*\))?\s+struct\s+" + n)),
189
+ ("pub enum", re.compile(r"^\s*pub(?:\([^)]*\))?\s+enum\s+" + n)),
190
+ ("pub trait", re.compile(r"^\s*pub(?:\([^)]*\))?\s+trait\s+" + n)),
191
+ ]
192
+ java = [
193
+ ("class", re.compile(r"^\s*(?:public|protected|private)?\s*(?:abstract\s+|final\s+)?class\s+" + n)),
194
+ ("interface", re.compile(r"^\s*(?:public|protected|private)?\s*interface\s+" + n)),
195
+ ("enum", re.compile(r"^\s*(?:public|protected|private)?\s*enum\s+" + n)),
196
+ # public/protected methods: <modifiers> <return-type> name(
197
+ ("method", re.compile(
198
+ r"^\s*(?:public|protected)\s+(?:static\s+|final\s+|abstract\s+|synchronized\s+|native\s+)*"
199
+ r"[\w<>\[\],.?\s]+?\s+(?P<name>[A-Za-z_]\w*)\s*\(")),
200
+ ]
201
+ cls._OUTLINE_PATTERNS = {"ts": ts, "js": js, "go": go, "rs": rs, "java": java}
202
+ return cls._OUTLINE_PATTERNS
203
+
204
+ def _planned_files_section(self, task: Task) -> str:
205
+ """Inject the current (redacted) contents of the task's planned files.
206
+
207
+ The capable production agents previously received file PATHS only and had to
208
+ rediscover every file and guess signatures — a leading cause of wrong-arity /
209
+ wrong-import edits that fail verification. Reading the real contents here lifts
210
+ one-shot success. Bounded by a total + per-file char budget (see constants);
211
+ new files are shown as headers only."""
212
+ from devcouncil.utils.redaction import redact_string
213
+
214
+ blocks: List[str] = []
215
+ budget = MAX_FILE_CONTEXT_CHARS
216
+ omitted = 0
217
+ for pf in task.planned_files:
218
+ label = pf.allowed_change
219
+ file_path = self.project_root / pf.path
220
+ if not (file_path.exists() and file_path.is_file()):
221
+ blocks.append(f"### `{pf.path}` [{label}] — new file (does not exist yet)\n")
222
+ continue
223
+ if budget <= 0:
224
+ omitted += 1
225
+ continue
226
+ try:
227
+ raw = file_path.read_text(encoding="utf-8", errors="replace")
228
+ except Exception:
229
+ blocks.append(f"### `{pf.path}` [{label}] — [error reading file]\n")
230
+ continue
231
+ content = redact_string(raw)
232
+ cap = min(MAX_PER_FILE_CHARS, budget)
233
+ truncated = len(content) > cap
234
+ if truncated:
235
+ content = content[:cap]
236
+ budget -= len(content)
237
+ symbols = self._symbol_outline(pf.path, raw)
238
+ block = f"### `{pf.path}` [{label}]\n"
239
+ if symbols:
240
+ block += "Symbols: " + "; ".join(symbols) + "\n"
241
+ block += f"```{self._lang_for(pf.path)}\n{content}\n```"
242
+ if truncated:
243
+ block += f"\n_[truncated to {cap} chars — open the file for the rest]_"
244
+ blocks.append(block + "\n")
245
+ if omitted:
246
+ blocks.append(f"_[{omitted} more planned file(s) omitted to fit the context budget — open them directly]_\n")
247
+ if not blocks:
248
+ return ""
249
+ return (
250
+ "\n## Current file contents (read before editing)\n"
251
+ "_Edit these in place; redacted secrets shown as ***._\n\n"
252
+ + "\n".join(blocks)
253
+ )
254
+
255
+ def _load_repo_map(self) -> dict | None:
256
+ """Parse ``.devcouncil/repo_map.json`` once per prompt (None if absent/unreadable)."""
257
+ map_path = self.project_root / ".devcouncil" / "repo_map.json"
258
+ if not map_path.exists():
259
+ return None
260
+ try:
261
+ data = json.loads(map_path.read_text(encoding="utf-8"))
262
+ return data if isinstance(data, dict) else None
263
+ except Exception:
264
+ return None
265
+
266
+ def _repo_map_stale(self, data: dict | None) -> bool:
267
+ """Whether the loaded repo map is behind the repo's current state, so its
268
+ structural context / dependents may be wrong. Best-effort; never raises."""
269
+ if not data:
270
+ return False
271
+ try:
272
+ from devcouncil.indexing.repo_mapper import RepoMapper
273
+
274
+ return RepoMapper(self.project_root).map_is_stale(data)
275
+ except Exception:
276
+ return False
277
+
278
+ _STALE_MAP_NOTE = (
279
+ "_⚠ The repo map is behind the current code (run `dev map` to refresh); "
280
+ "treat the structure below as approximate._\n"
281
+ )
282
+
283
+ _NO_MAP_NOTE = (
284
+ "_(no repo map; run `dev map` for structural orientation)_\n"
285
+ )
286
+
287
+ def _repo_map_section(self, planned_paths: List[str], data: dict | None = None) -> str:
288
+ """Structural orientation from ``.devcouncil/repo_map.json`` — the fallback used
289
+ when the optional code-review-graph CLI is absent (the common case). Surfaces the
290
+ subsystem(s) the planned files live in, their key files, neighbors, and flow, so
291
+ an agent in an unfamiliar repo knows where it is before editing."""
292
+ if data is None:
293
+ data = self._load_repo_map()
294
+ if not data:
295
+ return ""
296
+ subsystems = data.get("subsystems") or []
297
+ files_by_path = {
298
+ f.get("path"): f for f in (data.get("files") or []) if isinstance(f, dict)
299
+ }
300
+ norm = [p.replace("\\", "/") for p in planned_paths]
301
+ relevant = [
302
+ s for s in subsystems
303
+ if isinstance(s, dict) and s.get("area")
304
+ and any(p == s["area"] or p.startswith(s["area"] + "/") for p in norm)
305
+ ]
306
+ if not relevant:
307
+ return ""
308
+ lines = ["## Repo map (structural context)"]
309
+ for s in relevant[:3]:
310
+ lines.append(f"\n**{s.get('area')}** — {s.get('summary', '')}".rstrip())
311
+ critical = [c for c in (s.get("critical_files") or []) if c not in norm][:6]
312
+ if critical:
313
+ lines.append("Key files:")
314
+ for c in critical:
315
+ summary = (files_by_path.get(c) or {}).get("summary", "")
316
+ lines.append(f"- `{c}`" + (f" — {summary}" if summary else ""))
317
+ neighbors = s.get("neighbors") or []
318
+ if neighbors:
319
+ lines.append("Neighboring subsystems: " + ", ".join(f"`{n}`" for n in neighbors[:6]))
320
+ handoffs = s.get("handoff_paths") or []
321
+ if handoffs:
322
+ lines.append("Cross-subsystem flow: " + "; ".join(handoffs[:4]))
323
+ return "\n".join(lines).strip() + "\n"
324
+
325
+ def _skills_section(self, task: Task) -> str:
326
+ """The full engineering-skill intake that applies to this task.
327
+
328
+ Selection is codebase-aware (task goal keywords + the repo's own files, so an
329
+ Android repo pulls the android skill via build.gradle even when the task text
330
+ doesn't say "android"). The full skill text is injected inline — the senior-dev
331
+ intake (current libraries, deprecations to avoid, the right build/test CLI
332
+ commands) goes straight to the coding agent rather than relying on it to open
333
+ scaffolded files. Never raises — a skills failure must not break prompt building.
334
+ """
335
+ try:
336
+ from devcouncil.skills.registry import bound_skills, render_preamble, select_skills
337
+
338
+ goal = f"{task.title}\n{task.description}"
339
+ selected = select_skills(goal=goal, project_root=self.project_root)
340
+ # Bound how much skill text rides inline so a repo that matches many skills
341
+ # can't blow up the task prompt; deferred skills are still on disk.
342
+ inline, deferred = bound_skills(selected)
343
+ preamble = render_preamble(inline)
344
+ except Exception:
345
+ return ""
346
+ if not selected or not preamble:
347
+ return ""
348
+
349
+ names = ", ".join(skill.name for skill in selected)
350
+ section = (
351
+ "\n## Engineering skills (apply before and while coding)\n"
352
+ f"_Applicable skills: {names}. Follow this current-practice intake; "
353
+ "don't rely on stale training data._\n\n"
354
+ f"{preamble}\n"
355
+ )
356
+ if deferred:
357
+ section += "\n_Also applicable (read the full text in `.claude/skills/<name>/SKILL.md`):_\n"
358
+ for skill in deferred:
359
+ blurb = skill.description or skill.title
360
+ suffix = f" — {blurb}" if blurb else ""
361
+ section += f"- `{skill.name}`{suffix}\n"
362
+ return section
363
+
364
+ def _dependents_section(self, task: Task, data: dict | None) -> str:
365
+ """List, per planned file the agent will change, the files that import it — the
366
+ blast radius. Sourced from repo_map.json's precomputed reverse-import index, so
367
+ the agent updates or preserves call sites instead of silently breaking them."""
368
+ dependents = (data or {}).get("dependents") or {}
369
+ if not isinstance(dependents, dict) or not dependents:
370
+ return ""
371
+ lines: List[str] = []
372
+ for pf in task.planned_files:
373
+ # New files have no dependents yet; only existing code carries blast radius.
374
+ if pf.allowed_change == "create":
375
+ continue
376
+ # Normalize the planned path to posix before the lookup — the map keys are
377
+ # always posix, so a backslash planned path on Windows would otherwise miss
378
+ # and silently drop the whole blast-radius entry (see _repo_map_section).
379
+ importers = dependents.get(pf.path.replace("\\", "/")) or []
380
+ if not importers:
381
+ continue
382
+ shown = importers[:8]
383
+ more = f" (+{len(importers) - len(shown)} more)" if len(importers) > len(shown) else ""
384
+ lines.append(f"- `{pf.path}` is imported by: " + ", ".join(f"`{p}`" for p in shown) + more)
385
+ if not lines:
386
+ return ""
387
+ return (
388
+ "\n## Dependents (blast radius)\n"
389
+ "_These files import the files you're changing — keep their call sites working, "
390
+ "or update them in scope._\n"
391
+ + "\n".join(lines)
392
+ + "\n"
393
+ )
394
+
395
+ # Call-sites block bounds: keep it tight so this lowest-priority context can't crowd
396
+ # out the file bodies / dependents it complements.
397
+ _CALL_SITES_MAX_DEP_FILES = 3 # dependent files grepped per changed file
398
+ _CALL_SITES_MAX_SYMBOLS = 6 # exported symbols searched per changed file
399
+ _CALL_SITES_MAX_LINES_PER_FILE = 3 # referencing lines emitted per dependent file
400
+ _CALL_SITES_MAX_TOTAL = 24 # hard cap on emitted file:line rows
401
+ _CALL_SITES_LINE_CHARS = 160 # truncate a long using line
402
+
403
+ def _exported_symbol_names(self, path: str, text: str) -> List[str]:
404
+ """Top-level symbol names from a file's outline (no signatures), used to grep
405
+ dependents for referencing lines."""
406
+ names: List[str] = []
407
+ for entry in self._symbol_outline(path, text):
408
+ stripped = entry.strip()
409
+ # Outline rows look like "def name(args) L1", "class Name L5",
410
+ # "export function Name L3", " async def m(...) Lx" — pull the identifier
411
+ # that precedes the first "(" or " L".
412
+ head = stripped.split(" L")[0]
413
+ head = head.split("(")[0].strip()
414
+ ident = head.split()[-1] if head.split() else ""
415
+ ident = ident.strip(":")
416
+ if ident and ident.isidentifier() and ident not in names:
417
+ names.append(ident)
418
+ if len(names) >= self._CALL_SITES_MAX_SYMBOLS:
419
+ break
420
+ return names
421
+
422
+ def _call_sites_section(self, task: Task, data: dict | None) -> str:
423
+ """Lowest-priority context: for each changed file, show where its exported symbols
424
+ are actually used in the top dependent files (file:line + the using line). Helps
425
+ the agent update call sites in scope. Tightly bounded; never raises."""
426
+ dependents = (data or {}).get("dependents") or {}
427
+ if not isinstance(dependents, dict) or not dependents:
428
+ return ""
429
+ lines: List[str] = []
430
+ emitted = 0
431
+ for pf in task.planned_files:
432
+ if pf.allowed_change == "create" or emitted >= self._CALL_SITES_MAX_TOTAL:
433
+ continue
434
+ key = pf.path.replace("\\", "/")
435
+ importers = dependents.get(key) or []
436
+ if not importers:
437
+ continue
438
+ src_path = self.project_root / pf.path
439
+ try:
440
+ src_text = src_path.read_text(encoding="utf-8", errors="replace")
441
+ except Exception:
442
+ continue
443
+ symbols = self._exported_symbol_names(pf.path, src_text)
444
+ if not symbols:
445
+ continue
446
+ file_rows: List[str] = []
447
+ for importer in importers[: self._CALL_SITES_MAX_DEP_FILES]:
448
+ if emitted >= self._CALL_SITES_MAX_TOTAL:
449
+ break
450
+ try:
451
+ dep_text = (self.project_root / importer).read_text(encoding="utf-8", errors="replace")
452
+ except Exception:
453
+ continue
454
+ hits = 0
455
+ for lineno, raw in enumerate(dep_text.splitlines(), start=1):
456
+ if hits >= self._CALL_SITES_MAX_LINES_PER_FILE or emitted >= self._CALL_SITES_MAX_TOTAL:
457
+ break
458
+ if any(self._references_symbol(raw, sym) for sym in symbols):
459
+ snippet = raw.strip()[: self._CALL_SITES_LINE_CHARS]
460
+ file_rows.append(f" - `{importer}:{lineno}` — `{snippet}`")
461
+ hits += 1
462
+ emitted += 1
463
+ if file_rows:
464
+ lines.append(f"- `{pf.path}` (uses of {', '.join(f'`{s}`' for s in symbols)}):")
465
+ lines.extend(file_rows)
466
+ if not lines:
467
+ return ""
468
+ return (
469
+ "\n## Call sites (where your symbols are used)\n"
470
+ "_Referencing lines in dependent files — update these if you change a signature._\n"
471
+ + "\n".join(lines)
472
+ + "\n"
473
+ )
474
+
475
+ @staticmethod
476
+ def _references_symbol(line: str, symbol: str) -> bool:
477
+ """Whole-word match of ``symbol`` in ``line``. Cheap; avoids matching substrings
478
+ of longer identifiers."""
479
+ idx = line.find(symbol)
480
+ if idx < 0:
481
+ return False
482
+ before = line[idx - 1] if idx > 0 else ""
483
+ after = line[idx + len(symbol)] if idx + len(symbol) < len(line) else ""
484
+ return not (before.isalnum() or before == "_") and not (after.isalnum() or after == "_")
485
+
486
+ # Bound the dependency-risk block so this low-priority, opt-in context can't
487
+ # crowd out file bodies / dependents.
488
+ _DEP_RISKS_MAX = 12
489
+
490
+ def _dependency_risks_section(self, data: dict | None) -> str:
491
+ """Surface dependency vulnerabilities recorded in repo_map.json (opt-in SCA).
492
+
493
+ Lowest-priority, optional context: warns an agent that may bump a vulnerable
494
+ dependency. Absent unless `dev map` was run with SCA enabled. Never raises."""
495
+ risks = (data or {}).get("dependency_risks") or []
496
+ if not isinstance(risks, list) or not risks:
497
+ return ""
498
+ lines: List[str] = []
499
+ for risk in risks[: self._DEP_RISKS_MAX]:
500
+ if not isinstance(risk, dict):
501
+ continue
502
+ pkg = str(risk.get("package", "")).strip() or "(unknown)"
503
+ version = str(risk.get("installed_version", "")).strip()
504
+ severity = str(risk.get("severity", "")).strip() or "unknown"
505
+ advisory = str(risk.get("advisory_id", "")).strip()
506
+ summary = str(risk.get("summary", "")).strip()
507
+ head = f"`{pkg}`" + (f" {version}" if version else "")
508
+ tail = f" [{severity}]"
509
+ if advisory:
510
+ tail += f" {advisory}"
511
+ if summary:
512
+ tail += f" — {summary[:160]}"
513
+ lines.append(f"- {head}{tail}")
514
+ if not lines:
515
+ return ""
516
+ more = len(risks) - len(lines)
517
+ if more > 0:
518
+ lines.append(f"- _(+{more} more — see `.devcouncil/repo_map.json`)_")
519
+ return (
520
+ "\n## Dependency risks (known vulnerabilities)\n"
521
+ "_Reported by a local dependency auditor. Avoid bumping a listed package to a "
522
+ "still-vulnerable version; prefer a patched release._\n"
523
+ + "\n".join(lines)
524
+ + "\n"
525
+ )
526
+
527
+ @staticmethod
528
+ def _fit_segments(segments: list[dict], budget: int) -> str:
529
+ """Fit optional context segments into ``budget`` chars. Segments are kept in
530
+ priority order (lower = more important) and emitted in display order; any that
531
+ don't fit are dropped with an explicit marker so truncation is never silent."""
532
+ kept: list[dict] = []
533
+ used = 0
534
+ dropped: list[dict] = []
535
+ for seg in sorted(segments, key=lambda s: (s["priority"], s["order"])):
536
+ if budget > 0 and used + len(seg["text"]) <= budget:
537
+ kept.append(seg)
538
+ used += len(seg["text"])
539
+ else:
540
+ dropped.append(seg)
541
+ body = "".join(seg["text"] for seg in sorted(kept, key=lambda s: s["order"]))
542
+ if dropped:
543
+ names = ", ".join(s["name"] for s in sorted(dropped, key=lambda s: s["order"]))
544
+ body += f"\n_[Context budget reached — omitted: {names}. Open these directly if needed.]_\n"
545
+ return body
546
+
547
+ def build_task_prompt(
548
+ self, task: Task, requirements: List[Requirement], *, max_chars: int | None = None
549
+ ) -> str:
550
+ if max_chars is None:
551
+ max_chars = MAX_PROMPT_CHARS
552
+ # When the run targets a constrained local window (Ollama + OLLAMA_NUM_CTX),
553
+ # cap the budget so the server doesn't silently truncate past the window. Never
554
+ # raises the budget above the caller's value — only lowers it to fit.
555
+ window_budget = _local_context_window_budget(self.project_root)
556
+ if window_budget is not None:
557
+ max_chars = min(max_chars, window_budget)
558
+
559
+ req_map = {r.id: r for r in requirements}
560
+ task_reqs = [req_map[rid] for rid in task.requirement_ids if rid in req_map]
561
+
562
+ # --- Core: always kept (the goal/scope/instructions the agent must have). ---
563
+ core = f"""# Implement {task.id}: {task.title}
564
+
565
+ ## Goal
566
+ {task.description}
567
+
568
+ ## Requirements
569
+ """
570
+ for req in task_reqs:
571
+ core += f"- {req.id}: {req.title}\n"
572
+ for ac in req.acceptance_criteria:
573
+ core += f" - [ ] {ac.description} ({ac.verification_method})\n"
574
+
575
+ core += "\n## Allowed files\n"
29
576
  for pf in task.planned_files:
30
- prompt += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
31
-
577
+ core += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
578
+
32
579
  if task.forbidden_changes:
33
- prompt += "\n## Forbidden changes\n"
580
+ core += (
581
+ "\n## Forbidden changes\n"
582
+ "_Do not modify these. Verification always rejects them; on hook-enabled "
583
+ "clients they are also blocked before the write._\n"
584
+ )
34
585
  for fc in task.forbidden_changes:
35
- prompt += f"- `{fc}`\n"
586
+ core += f"- `{fc}`\n"
36
587
 
37
- prompt += "\n## Expected tests\n"
588
+ core += "\n## Expected tests\n"
38
589
  for et in task.expected_tests:
39
- prompt += f"- `{et}`\n"
590
+ core += f"- `{et}`\n"
40
591
 
41
- prompt += "\n## Allowed commands\n"
592
+ core += "\n## Allowed commands\n"
42
593
  for cmd in task.allowed_commands:
43
- prompt += f"- `{cmd}`\n"
594
+ core += f"- `{cmd}`\n"
44
595
 
45
- graph_context = CodeReviewGraphAdapter(self.project_root).prompt_section(
46
- [planned.path for planned in task.planned_files]
47
- )
596
+ instructions = """
597
+ ## Instructions
598
+ 1. Implement the goal described above.
599
+ 2. Ensure all acceptance criteria are met.
600
+ 3. Only modify the allowed files.
601
+ 4. Run the allowed commands to verify your work.
602
+ 5. Provide evidence of passing tests.
603
+ """
604
+
605
+ # --- Optional context: fitted within the remaining budget, dropped lowest-
606
+ # priority first. Priority: file contents (1) > structural (2) ~ dependents (2)
607
+ # > skills (3); display order keeps the original reading sequence. ---
608
+ repo_map_data = self._load_repo_map()
609
+ repo_map_stale = self._repo_map_stale(repo_map_data)
610
+ planned_paths = [planned.path for planned in task.planned_files]
611
+ segments: list[dict] = []
612
+
613
+ graph_context = CodeReviewGraphAdapter(self.project_root).prompt_section(planned_paths)
614
+ struct_text = ""
615
+ struct_has_stale_note = False
48
616
  if graph_context:
49
- prompt += f"\n{graph_context}"
617
+ struct_text = f"\n{graph_context}"
618
+ else:
619
+ repo_map_context = self._repo_map_section(planned_paths, repo_map_data)
620
+ if repo_map_context:
621
+ prefix = f"\n{self._STALE_MAP_NOTE}" if repo_map_stale else ""
622
+ struct_has_stale_note = repo_map_stale
623
+ struct_text = f"{prefix}\n{repo_map_context}"
624
+ if struct_text:
625
+ segments.append({"order": 1, "priority": 2, "name": "structural context", "text": struct_text})
626
+ elif repo_map_data is None:
627
+ # No graph CLI and the repo map file is entirely absent (not merely stale):
628
+ # nudge the agent to run `dev map`, surfaced the same way staleness is.
629
+ segments.append({
630
+ "order": 1, "priority": 2, "name": "no repo map note",
631
+ "text": f"\n{self._NO_MAP_NOTE}",
632
+ })
50
633
 
51
- prompt += """
52
- ## Instructions
53
- 1. Implement the goal described above.
54
- 2. Ensure all acceptance criteria are met.
55
- 3. Only modify the allowed files.
56
- 4. Run the allowed commands to verify your work.
57
- 5. Provide evidence of passing tests.
58
- """
59
- return prompt
634
+ files_text = self._planned_files_section(task)
635
+ if files_text:
636
+ segments.append({"order": 2, "priority": 1, "name": "file contents", "text": files_text})
637
+
638
+ dependents_section = self._dependents_section(task, repo_map_data)
639
+ if dependents_section:
640
+ prefix = f"\n{self._STALE_MAP_NOTE}" if (repo_map_stale and not struct_has_stale_note) else ""
641
+ segments.append({"order": 3, "priority": 2, "name": "dependents", "text": prefix + dependents_section})
642
+
643
+ skills_text = self._skills_section(task)
644
+ if skills_text:
645
+ segments.append({"order": 4, "priority": 3, "name": "engineering skills", "text": skills_text})
646
+
647
+ # Lowest priority (4): the budget drops call sites first. It only adds value once
648
+ # the file bodies + dependents are present anyway.
649
+ call_sites_text = self._call_sites_section(task, repo_map_data)
650
+ if call_sites_text:
651
+ segments.append({"order": 5, "priority": 4, "name": "call sites", "text": call_sites_text})
652
+
653
+ # Lowest priority (5): dependency risks are opt-in, advisory context — the
654
+ # budget drops them first so they never displace structural/file context.
655
+ dependency_risks_text = self._dependency_risks_section(repo_map_data)
656
+ if dependency_risks_text:
657
+ segments.append({"order": 6, "priority": 5, "name": "dependency risks", "text": dependency_risks_text})
658
+
659
+ # The core + instructions are never dropped; if they alone exceed the budget the
660
+ # model's server will truncate them, so warn loudly instead of failing silently.
661
+ core_len = len(core) + len(instructions)
662
+ if core_len > max_chars:
663
+ logger.warning(
664
+ "Task %s core prompt (%d chars) exceeds the context budget (%d chars). "
665
+ "On a local model with a small OLLAMA_NUM_CTX this will be truncated server-side; "
666
+ "raise OLLAMA_NUM_CTX or split the task.",
667
+ task.id, core_len, max_chars,
668
+ )
669
+
670
+ optional = self._fit_segments(segments, max_chars - core_len)
671
+ return core + optional + instructions