devcouncil 0.2.0 → 0.2.1

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 (205) hide show
  1. package/README.md +191 -253
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +6 -17
  4. package/pyproject.toml +0 -66
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -320
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -163
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/assets/__init__.py +0 -1
  23. package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
  24. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  25. package/src/devcouncil/cli/__init__.py +0 -0
  26. package/src/devcouncil/cli/commands/__init__.py +0 -0
  27. package/src/devcouncil/cli/commands/agents.py +0 -292
  28. package/src/devcouncil/cli/commands/artifacts.py +0 -54
  29. package/src/devcouncil/cli/commands/ast.py +0 -22
  30. package/src/devcouncil/cli/commands/baseline.py +0 -35
  31. package/src/devcouncil/cli/commands/check.py +0 -209
  32. package/src/devcouncil/cli/commands/config.py +0 -115
  33. package/src/devcouncil/cli/commands/cost.py +0 -57
  34. package/src/devcouncil/cli/commands/dashboard.py +0 -31
  35. package/src/devcouncil/cli/commands/doctor.py +0 -340
  36. package/src/devcouncil/cli/commands/evidence.py +0 -48
  37. package/src/devcouncil/cli/commands/go.py +0 -656
  38. package/src/devcouncil/cli/commands/handoff.py +0 -69
  39. package/src/devcouncil/cli/commands/hook.py +0 -237
  40. package/src/devcouncil/cli/commands/init.py +0 -289
  41. package/src/devcouncil/cli/commands/integrate.py +0 -1515
  42. package/src/devcouncil/cli/commands/lsp.py +0 -20
  43. package/src/devcouncil/cli/commands/map.py +0 -112
  44. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  45. package/src/devcouncil/cli/commands/plan.py +0 -488
  46. package/src/devcouncil/cli/commands/prompt.py +0 -61
  47. package/src/devcouncil/cli/commands/repair.py +0 -89
  48. package/src/devcouncil/cli/commands/report.py +0 -137
  49. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
  50. package/src/devcouncil/cli/commands/rollback.py +0 -59
  51. package/src/devcouncil/cli/commands/run.py +0 -289
  52. package/src/devcouncil/cli/commands/runs.py +0 -223
  53. package/src/devcouncil/cli/commands/scaffold.py +0 -32
  54. package/src/devcouncil/cli/commands/semantic.py +0 -47
  55. package/src/devcouncil/cli/commands/setup.py +0 -362
  56. package/src/devcouncil/cli/commands/shell.py +0 -73
  57. package/src/devcouncil/cli/commands/show.py +0 -76
  58. package/src/devcouncil/cli/commands/skills.py +0 -88
  59. package/src/devcouncil/cli/commands/status.py +0 -141
  60. package/src/devcouncil/cli/commands/tasks.py +0 -55
  61. package/src/devcouncil/cli/commands/trace.py +0 -88
  62. package/src/devcouncil/cli/commands/verify.py +0 -328
  63. package/src/devcouncil/cli/commands/version.py +0 -20
  64. package/src/devcouncil/cli/commands/watch.py +0 -574
  65. package/src/devcouncil/cli/commands/watch_fs.py +0 -40
  66. package/src/devcouncil/cli/main.py +0 -137
  67. package/src/devcouncil/council/__init__.py +0 -0
  68. package/src/devcouncil/council/prompts/__init__.py +0 -0
  69. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  70. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  71. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  72. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  73. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  74. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  75. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  76. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  77. package/src/devcouncil/domain/__init__.py +0 -0
  78. package/src/devcouncil/domain/assumption.py +0 -17
  79. package/src/devcouncil/domain/critique.py +0 -32
  80. package/src/devcouncil/domain/evidence.py +0 -47
  81. package/src/devcouncil/domain/gap.py +0 -52
  82. package/src/devcouncil/domain/requirement.py +0 -22
  83. package/src/devcouncil/domain/task.py +0 -55
  84. package/src/devcouncil/execution/__init__.py +0 -1
  85. package/src/devcouncil/execution/checkpoints.py +0 -246
  86. package/src/devcouncil/execution/context_builder.py +0 -60
  87. package/src/devcouncil/execution/executor.py +0 -15
  88. package/src/devcouncil/execution/fs_watcher.py +0 -180
  89. package/src/devcouncil/execution/handoff.py +0 -102
  90. package/src/devcouncil/execution/hook_policy.py +0 -253
  91. package/src/devcouncil/execution/patch.py +0 -77
  92. package/src/devcouncil/execution/paths.py +0 -14
  93. package/src/devcouncil/execution/permissions.py +0 -85
  94. package/src/devcouncil/execution/policy_engine.py +0 -343
  95. package/src/devcouncil/execution/prompt_builder.py +0 -671
  96. package/src/devcouncil/execution/shell_session.py +0 -225
  97. package/src/devcouncil/execution/task_runner.py +0 -170
  98. package/src/devcouncil/executors/__init__.py +0 -1
  99. package/src/devcouncil/executors/agent_registry.py +0 -575
  100. package/src/devcouncil/executors/coding_cli.py +0 -736
  101. package/src/devcouncil/executors/mini_swe.py +0 -73
  102. package/src/devcouncil/executors/native/__init__.py +0 -0
  103. package/src/devcouncil/executors/native/agent.py +0 -208
  104. package/src/devcouncil/executors/openhands.py +0 -71
  105. package/src/devcouncil/gating/__init__.py +0 -1
  106. package/src/devcouncil/gating/checks/__init__.py +0 -0
  107. package/src/devcouncil/gating/checks/clean_git.py +0 -52
  108. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  109. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  110. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
  111. package/src/devcouncil/gating/policy.py +0 -338
  112. package/src/devcouncil/hardware.py +0 -184
  113. package/src/devcouncil/indexing/__init__.py +0 -1
  114. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  115. package/src/devcouncil/indexing/graph_index.py +0 -48
  116. package/src/devcouncil/indexing/lsp.py +0 -161
  117. package/src/devcouncil/indexing/repo_mapper.py +0 -1455
  118. package/src/devcouncil/indexing/semantic_index.py +0 -205
  119. package/src/devcouncil/integrations/actions.py +0 -146
  120. package/src/devcouncil/integrations/check.py +0 -423
  121. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  122. package/src/devcouncil/integrations/github.py +0 -39
  123. package/src/devcouncil/integrations/github_intent.py +0 -142
  124. package/src/devcouncil/integrations/gitnexus.py +0 -62
  125. package/src/devcouncil/integrations/graphify.py +0 -34
  126. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  127. package/src/devcouncil/integrations/mcp/server.py +0 -2122
  128. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
  129. package/src/devcouncil/integrations/pr_comments.py +0 -62
  130. package/src/devcouncil/live/__init__.py +0 -2
  131. package/src/devcouncil/live/cards.py +0 -349
  132. package/src/devcouncil/live/models.py +0 -63
  133. package/src/devcouncil/live/repair_prompt.py +0 -83
  134. package/src/devcouncil/live/reviewer.py +0 -70
  135. package/src/devcouncil/live/signals.py +0 -135
  136. package/src/devcouncil/live/summary.py +0 -34
  137. package/src/devcouncil/live/tasks.py +0 -18
  138. package/src/devcouncil/live/transcripts.py +0 -141
  139. package/src/devcouncil/llm/__init__.py +0 -1
  140. package/src/devcouncil/llm/cache.py +0 -42
  141. package/src/devcouncil/llm/model_defaults.yaml +0 -44
  142. package/src/devcouncil/llm/provider.py +0 -627
  143. package/src/devcouncil/llm/router.py +0 -310
  144. package/src/devcouncil/optimization/__init__.py +0 -1
  145. package/src/devcouncil/optimization/gepa_agent.py +0 -318
  146. package/src/devcouncil/planning/__init__.py +0 -1
  147. package/src/devcouncil/planning/arbiter_service.py +0 -57
  148. package/src/devcouncil/planning/correction_manifest.py +0 -303
  149. package/src/devcouncil/planning/critique_service.py +0 -71
  150. package/src/devcouncil/planning/plan_service.py +0 -60
  151. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
  152. package/src/devcouncil/planning/repair_service.py +0 -39
  153. package/src/devcouncil/planning/spec_service.py +0 -70
  154. package/src/devcouncil/repo/__init__.py +0 -0
  155. package/src/devcouncil/repo/ci_scaffold.py +0 -157
  156. package/src/devcouncil/repo/gitignore.py +0 -123
  157. package/src/devcouncil/repo/sca.py +0 -374
  158. package/src/devcouncil/reporting/__init__.py +0 -0
  159. package/src/devcouncil/reporting/github_check.py +0 -32
  160. package/src/devcouncil/reporting/json_report.py +0 -30
  161. package/src/devcouncil/reporting/markdown_report.py +0 -83
  162. package/src/devcouncil/reporting/report_builder.py +0 -14
  163. package/src/devcouncil/skills/__init__.py +0 -19
  164. package/src/devcouncil/skills/library/README.md +0 -46
  165. package/src/devcouncil/skills/library/ai-training.md +0 -50
  166. package/src/devcouncil/skills/library/android.md +0 -50
  167. package/src/devcouncil/skills/library/backend.md +0 -52
  168. package/src/devcouncil/skills/library/core-engineering.md +0 -95
  169. package/src/devcouncil/skills/library/data-engineering.md +0 -47
  170. package/src/devcouncil/skills/library/desktop.md +0 -46
  171. package/src/devcouncil/skills/library/devops.md +0 -48
  172. package/src/devcouncil/skills/library/game-dev.md +0 -46
  173. package/src/devcouncil/skills/library/ios.md +0 -48
  174. package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
  175. package/src/devcouncil/skills/library/security.md +0 -48
  176. package/src/devcouncil/skills/library/systems.md +0 -48
  177. package/src/devcouncil/skills/library/web.md +0 -47
  178. package/src/devcouncil/skills/library/windows.md +0 -47
  179. package/src/devcouncil/skills/registry.py +0 -330
  180. package/src/devcouncil/storage/__init__.py +0 -0
  181. package/src/devcouncil/storage/db.py +0 -147
  182. package/src/devcouncil/storage/models.py +0 -204
  183. package/src/devcouncil/storage/native.py +0 -557
  184. package/src/devcouncil/storage/repositories.py +0 -485
  185. package/src/devcouncil/telemetry/__init__.py +0 -0
  186. package/src/devcouncil/telemetry/cost.py +0 -140
  187. package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
  188. package/src/devcouncil/telemetry/pricing.py +0 -28
  189. package/src/devcouncil/telemetry/traces.py +0 -146
  190. package/src/devcouncil/telemetry/tracker.py +0 -52
  191. package/src/devcouncil/ui/__init__.py +0 -1
  192. package/src/devcouncil/ui/dashboard.py +0 -423
  193. package/src/devcouncil/utils/__init__.py +0 -1
  194. package/src/devcouncil/utils/redaction.py +0 -147
  195. package/src/devcouncil/utils/subprocess_env.py +0 -69
  196. package/src/devcouncil/verification/__init__.py +0 -1
  197. package/src/devcouncil/verification/acceptance_compiler.py +0 -125
  198. package/src/devcouncil/verification/ad_hoc_check.py +0 -129
  199. package/src/devcouncil/verification/diff_coverage.py +0 -353
  200. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  201. package/src/devcouncil/verification/next_actions.py +0 -189
  202. package/src/devcouncil/verification/sandbox.py +0 -178
  203. package/src/devcouncil/verification/test_resolver.py +0 -91
  204. package/src/devcouncil/verification/verifier.py +0 -1548
  205. package/uv.lock +0 -1226
@@ -1,671 +0,0 @@
1
- import ast
2
- import json
3
- import logging
4
- import re
5
- from pathlib import Path
6
- from typing import List
7
-
8
- from devcouncil.domain.requirement import Requirement
9
- from devcouncil.domain.task import Task
10
- from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
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
-
76
- class PromptBuilder:
77
- def __init__(self, project_root: Path = Path(".")):
78
- self.project_root = project_root
79
-
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"
576
- for pf in task.planned_files:
577
- core += f"- `{pf.path}` ({pf.allowed_change}): {pf.reason}\n"
578
-
579
- if task.forbidden_changes:
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
- )
585
- for fc in task.forbidden_changes:
586
- core += f"- `{fc}`\n"
587
-
588
- core += "\n## Expected tests\n"
589
- for et in task.expected_tests:
590
- core += f"- `{et}`\n"
591
-
592
- core += "\n## Allowed commands\n"
593
- for cmd in task.allowed_commands:
594
- core += f"- `{cmd}`\n"
595
-
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
616
- if 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
- })
633
-
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