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,4 @@
1
+ import logging
1
2
  import subprocess
2
3
  import sys
3
4
  from pathlib import Path
@@ -8,6 +9,7 @@ from devcouncil.execution.executor import Executor, ExecutionResult
8
9
  from devcouncil.execution.prompt_builder import PromptBuilder
9
10
 
10
11
  console = Console()
12
+ logger = logging.getLogger(__name__)
11
13
 
12
14
  class MiniSWEExecutor(Executor):
13
15
  def __init__(self, project_root: Path):
@@ -22,6 +24,7 @@ class MiniSWEExecutor(Executor):
22
24
  instruction_file.parent.mkdir(parents=True, exist_ok=True)
23
25
  instruction_file.write_text(task_prompt, encoding="utf-8")
24
26
 
27
+ logger.info("mini-SWE-agent starting for %s", task.id)
25
28
  console.print(f"Starting [bold]mini-SWE-agent[/bold] for task {task.id}...")
26
29
 
27
30
  # In a real implementation, we'd invoke the agent CLI
@@ -50,10 +53,13 @@ class MiniSWEExecutor(Executor):
50
53
  )
51
54
  self._write_log(task.id, result)
52
55
  if result.returncode != 0:
56
+ logger.error("mini-SWE-agent exited %s for %s", result.returncode, task.id)
53
57
  console.print(f"[red]mini-SWE-agent exited with {result.returncode}.[/red]")
54
58
  return ExecutionResult(success=False, message='Execution failed')
59
+ logger.info("mini-SWE-agent finished for %s", task.id)
55
60
  return ExecutionResult(success=True, message='Execution successful')
56
61
  except Exception as e:
62
+ logger.exception("mini-SWE-agent error for %s: %s", task.id, e)
57
63
  console.print(f"[red]Error running mini-SWE-agent: {e}[/red]")
58
64
  return ExecutionResult(success=False, message='Execution failed')
59
65
 
@@ -1,15 +1,25 @@
1
1
  from typing import List, Dict, Any
2
+ import asyncio
3
+ import logging
2
4
  from rich.console import Console
3
5
  from pydantic import BaseModel
4
6
  from devcouncil.domain.task import Task
5
7
  from devcouncil.domain.requirement import Requirement
6
8
  from devcouncil.execution.executor import Executor, ExecutionResult
7
- from devcouncil.llm.router import ModelRouter
9
+ from devcouncil.llm.router import ModelRouter, StructuredOutputError
8
10
  from devcouncil.execution.task_runner import TaskRunner
9
11
  from devcouncil.execution.context_builder import ContextBuilder
12
+ from devcouncil.execution.prompt_builder import PromptBuilder
10
13
  from devcouncil.execution.paths import resolve_project_path
14
+ from devcouncil.app.errors import ExecutionError
11
15
 
12
16
  console = Console()
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Resilience bounds for the preview native loop.
20
+ MAX_AGENT_STEPS = 10
21
+ MAX_STRUCTURED_FAILURES = 2 # model can't produce a valid action -> give up cleanly
22
+ MAX_CONSECUTIVE_PATCH_FAILURES = 3 # stop spinning on a patch the model can't fix
13
23
 
14
24
  class ToolCall(BaseModel):
15
25
  tool: str
@@ -24,23 +34,41 @@ class NativeAgent(Executor):
24
34
  def __init__(self, router: ModelRouter, task_runner: TaskRunner):
25
35
  self.router = router
26
36
  self.task_runner = task_runner
37
+ # ContextBuilder is retained only for the cheap list_files file listing; the
38
+ # implementation context itself uses the budgeted PromptBuilder so the native
39
+ # executor gets the same repo-map orientation, symbol outlines, dependents and
40
+ # context-window budgeting as the CLI executors (rather than a flat JSON dump).
27
41
  self.context_builder = ContextBuilder(task_runner.project_root)
42
+ self.prompt_builder = PromptBuilder(task_runner.project_root)
43
+
44
+ def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
45
+ """Run the preview native executor behind the normal synchronous executor contract."""
46
+ return asyncio.run(self._run_task_async(task, requirements))
28
47
 
29
- async def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
48
+ async def _run_task_async(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
49
+ logger.info("Native agent starting for %s (max_steps=%d)", task.id, MAX_AGENT_STEPS)
30
50
  console.print(f"Starting [bold]Native Executor[/bold] for task {task.id}...")
51
+ console.print("[yellow]Native executor is preview quality; DevCouncil verification remains the completion gate.[/yellow]")
31
52
 
32
- # 1. Gather rich context
33
- context_json = self.context_builder.build_task_context(task, requirements)
34
-
53
+ # 1. Gather rich context (budgeted; includes repo-map orientation + symbol outlines)
54
+ context_block = self.prompt_builder.build_task_prompt(task, requirements)
55
+ from devcouncil.planning.correction_manifest import load_latest_correction_manifest
56
+
57
+ correction = load_latest_correction_manifest(self.task_runner.project_root, task.id)
58
+ correction_block = ""
59
+ if correction is not None:
60
+ correction_block = f"\nCorrection Manifest:\n{correction.model_dump_json(indent=2)}\n"
61
+
35
62
  system_prompt = f"""
36
63
  You are the DevCouncil Native Agent. Your goal is to implement the provided task.
37
64
  Current Project Context:
38
- {context_json}
65
+ {context_block}
66
+ {correction_block}
39
67
 
40
68
  You have access to the following tools:
41
69
  - read_file(path: str)
42
70
  - list_files()
43
- - apply_patch(patch: str)
71
+ - apply_patch(patch: str) OR apply_patch(path: str, content: str) as a fallback when a valid unified diff cannot be produced
44
72
  - run_command(command: str)
45
73
 
46
74
  Rules:
@@ -54,22 +82,71 @@ Rules:
54
82
  # Initial task prompt
55
83
  messages.append({"role": "user", "content": f"Begin implementing task {task.id} based on the context provided."})
56
84
 
57
- # Basic tool loop (Max 10 steps for safety in MVP)
58
- for step in range(10):
59
- action = await self.router.complete_structured(
60
- role="native_agent",
61
- messages=messages,
62
- schema=AgentAction
85
+ # Bounded tool loop. Counters let us fail a single task cleanly instead of
86
+ # crashing the whole run (structured-output faults) or spinning on an
87
+ # unfixable patch.
88
+ structured_failures = 0
89
+ consecutive_patch_failures = 0
90
+ for step in range(MAX_AGENT_STEPS):
91
+ try:
92
+ action = await self.router.complete_structured(
93
+ role="native_agent",
94
+ messages=messages,
95
+ schema=AgentAction,
96
+ )
97
+ except StructuredOutputError as exc:
98
+ # The model could not produce a valid action even after healing/retry.
99
+ # native_agent has no fallback by design, so handle it here rather than
100
+ # letting it propagate and abort the entire `dev go` run.
101
+ structured_failures += 1
102
+ logger.warning("Native agent step %d: unparseable action (%d/%d): %s", step + 1, structured_failures, MAX_STRUCTURED_FAILURES, exc)
103
+ console.print(f"[red]Native agent could not parse a valid action: {exc}[/red]")
104
+ if structured_failures >= MAX_STRUCTURED_FAILURES:
105
+ logger.error("Native agent giving up on %s after %d unparseable responses", task.id, structured_failures)
106
+ return ExecutionResult(
107
+ success=False,
108
+ message=f"Native agent gave up after {structured_failures} unparseable responses.",
109
+ )
110
+ messages.append({
111
+ "role": "user",
112
+ "content": (
113
+ "[System] Your previous response was not valid JSON for the "
114
+ "AgentAction schema. Reply with a single valid JSON object only "
115
+ "(fields: thought, tool_calls, finish) — no prose, no fences."
116
+ ),
117
+ })
118
+ continue
119
+ structured_failures = 0
120
+
121
+ logger.info(
122
+ "Native agent %s step %d/%d: %d tool call(s)%s",
123
+ task.id, step + 1, MAX_AGENT_STEPS, len(action.tool_calls),
124
+ " finish=True" if action.finish else "",
63
125
  )
64
-
65
126
  console.print(f"\n[bold]Step {step+1}:[/bold] {action.thought}")
66
-
127
+
128
+ # Record the agent's own turn so subsequent steps see what it already did.
129
+ # Without this the model only sees tool RESULTS, not its prior actions, and
130
+ # tends to repeat itself and never converge within the step budget.
131
+ messages.append({"role": "assistant", "content": action.model_dump_json()})
132
+
67
133
  if action.finish:
134
+ logger.info("Native agent signaled completion for %s at step %d", task.id, step + 1)
68
135
  console.print("[green]Native agent signaled completion.[/green]")
69
- return ExecutionResult(success=True, message="Agent signaled completion")
136
+ return ExecutionResult(success=True, message="Agent signaled completion; pending DevCouncil verification")
137
+
138
+ if not action.tool_calls:
139
+ # No action and not finished — nudge instead of silently burning a step.
140
+ messages.append({"role": "user", "content": (
141
+ "[System] You produced no tool_calls and did not finish. Call a tool "
142
+ "(read_file/list_files/apply_patch/run_command) to make progress, or set "
143
+ "finish=true if the task is complete."
144
+ )})
145
+ continue
70
146
 
71
147
  for tool_call in action.tool_calls:
72
148
  result_summary = ""
149
+ logger.debug("Native agent tool call: %s args=%s", tool_call.tool, list(tool_call.args))
73
150
  try:
74
151
  if tool_call.tool == "read_file":
75
152
  path = tool_call.args["path"]
@@ -90,8 +167,26 @@ Rules:
90
167
  elif tool_call.tool == "write_file":
91
168
  raise PermissionError("write_file is disabled for the native executor; use apply_patch.")
92
169
  elif tool_call.tool == "apply_patch":
93
- self.task_runner.apply_patch(tool_call.args["patch"], task)
94
- result_summary = "Successfully applied patch."
170
+ if "path" in tool_call.args and "content" in tool_call.args:
171
+ # Fallback for when the model can't produce a valid unified
172
+ # diff. Routes through write_file, which enforces the same
173
+ # planned-files permission check — no widening of scope.
174
+ self.task_runner.write_file(
175
+ tool_call.args["path"], tool_call.args["content"], task
176
+ )
177
+ consecutive_patch_failures = 0
178
+ result_summary = f"Wrote {tool_call.args['path']} via path+content fallback."
179
+ else:
180
+ patch = tool_call.args.get("patch", "")
181
+ if not patch or not patch.strip():
182
+ raise ExecutionError(
183
+ "Empty patch. Provide a unified git diff beginning with "
184
+ "'diff --git a/<path> b/<path>', then '--- a/<path>' (or "
185
+ "'--- /dev/null' for a new file), '+++ b/<path>', and '@@' hunks."
186
+ )
187
+ self.task_runner.apply_patch(patch, task)
188
+ consecutive_patch_failures = 0
189
+ result_summary = "Successfully applied patch."
95
190
  elif tool_call.tool == "run_command":
96
191
  cmd_result = self.task_runner.run_command(tool_call.args["command"], task)
97
192
  result_summary = f"Command finished with exit code {cmd_result.exit_code}."
@@ -100,8 +195,29 @@ Rules:
100
195
 
101
196
  messages.append({"role": "user", "content": f"[Tool Result] '{tool_call.tool}': {result_summary}"})
102
197
  except Exception as e:
198
+ logger.warning("Native agent tool %s failed for %s: %s", tool_call.tool, task.id, e)
103
199
  console.print(f"[red]Error executing tool {tool_call.tool}: {e}[/red]")
104
- messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
200
+ if tool_call.tool == "apply_patch":
201
+ consecutive_patch_failures += 1
202
+ if consecutive_patch_failures >= MAX_CONSECUTIVE_PATCH_FAILURES:
203
+ logger.error("Native agent giving up on %s after %d consecutive patch failures", task.id, consecutive_patch_failures)
204
+ return ExecutionResult(
205
+ success=False,
206
+ message=(
207
+ f"Native agent failed to apply a patch "
208
+ f"{consecutive_patch_failures} times in a row."
209
+ ),
210
+ )
211
+ messages.append({"role": "user", "content": (
212
+ f"[Tool Error] 'apply_patch' failed: {e}\n"
213
+ "Re-read the target file, match the existing context lines EXACTLY, "
214
+ "and do NOT resubmit the same patch. If you cannot produce a valid "
215
+ "unified diff, call apply_patch with 'path' and 'content' instead to "
216
+ "write the whole file."
217
+ )})
218
+ else:
219
+ messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
105
220
 
221
+ logger.warning("Native agent reached max step limit (%d) for %s", MAX_AGENT_STEPS, task.id)
106
222
  console.print("[red]Native agent reached maximum step limit.[/red]")
107
223
  return ExecutionResult(success=False, message="Reached maximum step limit")
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import subprocess
2
3
  from pathlib import Path
3
4
  from rich.console import Console
@@ -7,6 +8,7 @@ from devcouncil.execution.executor import Executor, ExecutionResult
7
8
  from devcouncil.execution.prompt_builder import PromptBuilder
8
9
 
9
10
  console = Console()
11
+ logger = logging.getLogger(__name__)
10
12
 
11
13
  class OpenHandsExecutor(Executor):
12
14
  def __init__(self, project_root: Path):
@@ -16,6 +18,7 @@ class OpenHandsExecutor(Executor):
16
18
  builder = PromptBuilder(self.project_root)
17
19
  task_prompt = builder.build_task_prompt(task, requirements)
18
20
 
21
+ logger.info("OpenHands starting for %s", task.id)
19
22
  console.print(f"Starting [bold]OpenHands[/bold] for task {task.id}...")
20
23
 
21
24
  # OpenHands often expects a workspace mount and an instruction.
@@ -48,10 +51,13 @@ class OpenHandsExecutor(Executor):
48
51
  )
49
52
  self._write_log(task.id, result)
50
53
  if result.returncode != 0:
54
+ logger.error("OpenHands exited %s for %s", result.returncode, task.id)
51
55
  console.print(f"[red]OpenHands exited with {result.returncode}.[/red]")
52
56
  return ExecutionResult(success=False, message=f"Exited with code {result.returncode}")
57
+ logger.info("OpenHands finished for %s", task.id)
53
58
  return ExecutionResult(success=True, message="Completed successfully")
54
59
  except Exception as e:
60
+ logger.exception("OpenHands error for %s: %s", task.id, e)
55
61
  console.print(f"[red]Error running OpenHands: {e}[/red]")
56
62
  return ExecutionResult(success=False, message=str(e))
57
63
 
@@ -9,7 +9,9 @@ class CleanGitCheck:
9
9
 
10
10
  def _is_runtime_state(self, line: str) -> bool:
11
11
  path = line[3:].strip().replace("\\", "/")
12
- return path.startswith(".devcouncil/")
12
+ # DevCouncil manages the root .gitignore itself (ensure_gitignore runs on
13
+ # init and before every task), so it must not block execution.
14
+ return path.startswith(".devcouncil/") or path == ".gitignore"
13
15
 
14
16
  def check(self, project_root, task_id: str) -> list[Gap]:
15
17
  try:
@@ -1,34 +1,60 @@
1
+ import logging
2
+ import re
1
3
  from typing import List
2
4
  from devcouncil.domain.gap import Gap
3
5
  from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
4
6
 
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Captures the new-file starting line from a unified-diff hunk header (@@ -a,b +c,d @@).
10
+ _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
11
+
12
+
5
13
  class SecretScanner:
6
14
  """Scans code diffs for potential secrets (API keys, tokens, etc.)."""
7
-
15
+
8
16
  def scan_diff(self, diff_content: str, task_id: str) -> List[Gap]:
9
- gaps = []
10
- lines = diff_content.splitlines()
17
+ gaps: List[Gap] = []
11
18
  current_file = "unknown_file"
12
-
13
- for i, line in enumerate(lines):
19
+ new_line_no = 0 # line number in the new file, tracked across hunks
20
+ counter = 0 # ensures unique gap ids
21
+
22
+ for line in diff_content.splitlines():
14
23
  if line.startswith("+++ b/"):
15
24
  current_file = line[6:]
16
25
  continue
17
-
18
- # Only scan added lines in diff
19
- if not line.startswith("+") or line.startswith("+++"):
26
+ if line.startswith("+++") or line.startswith("---") or line.startswith("diff "):
27
+ continue
28
+ hunk = _HUNK_RE.match(line)
29
+ if hunk:
30
+ new_line_no = int(hunk.group(1))
31
+ continue
32
+ if line.startswith("-"):
33
+ continue # removed line — does not advance the new-file counter
34
+ if line.startswith("+"):
35
+ for key_type, pattern in SECRET_PATTERNS.items():
36
+ if pattern.search(line):
37
+ counter += 1
38
+ logger.warning(
39
+ "Potential %s secret detected in %s:%d (task %s)",
40
+ key_type, current_file, new_line_no, task_id,
41
+ )
42
+ gaps.append(Gap(
43
+ id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{new_line_no}-{counter}",
44
+ severity="critical",
45
+ gap_type="security_risk",
46
+ task_id=task_id,
47
+ description=f"Potential {key_type} found in {current_file}:{new_line_no}.",
48
+ evidence=[redact_string(line.strip())],
49
+ recommended_fix="Remove the secret and use environment variables or a secret manager.",
50
+ blocking=True,
51
+ # Populate the routing fields so the security NextAction points
52
+ # the agent straight at the file:line instead of forcing a re-grep.
53
+ file=current_file,
54
+ line=new_line_no,
55
+ ))
56
+ new_line_no += 1
20
57
  continue
21
-
22
- for key_type, pattern in SECRET_PATTERNS.items():
23
- if pattern.search(line):
24
- gaps.append(Gap(
25
- id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{i}",
26
- severity="critical",
27
- gap_type="security_risk",
28
- task_id=task_id,
29
- description=f"Potential {key_type} found in {current_file} (diff line {i+1}).",
30
- evidence=[redact_string(line.strip())],
31
- recommended_fix="Remove the secret and use environment variables or a secret manager.",
32
- blocking=True
33
- ))
58
+ # Context or blank line — advances the new-file counter.
59
+ new_line_no += 1
34
60
  return gaps
@@ -1,3 +1,5 @@
1
+ import logging
2
+ from collections import deque
1
3
  from pydantic import BaseModel
2
4
  from typing import Any, List, Optional
3
5
  from pathlib import Path
@@ -11,10 +13,94 @@ from devcouncil.gating.checks.requirement_coverage import RequirementCoverageChe
11
13
  from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
12
14
  from devcouncil.gating.checks.clean_git import CleanGitCheck
13
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _log_gate(name: str, gaps: List[Gap], *, routine: bool = False, **context: Any) -> bool:
20
+ """Log a gate decision and return whether it passed (no blocking gaps).
21
+
22
+ A passing gate is logged at INFO for once-per-plan checks (a real milestone) but at
23
+ DEBUG for ``routine`` per-task checks (e.g. task_ready, which fires for every task and
24
+ every repair attempt) so the ``-v`` stream stays milestone-level. A FAILED gate is
25
+ always WARNING — that's the signal you actually chase.
26
+ """
27
+ blocking = [g for g in gaps if g.blocking]
28
+ passed = not blocking
29
+ suffix = "".join(f" {k}={v}" for k, v in context.items())
30
+ if passed:
31
+ log = logger.debug if routine else logger.info
32
+ log("Gate %s PASSED (%d advisory gap(s))%s", name, len(gaps), suffix)
33
+ else:
34
+ logger.warning(
35
+ "Gate %s FAILED%s: %s",
36
+ name, suffix,
37
+ "; ".join(f"{g.gap_type}: {g.description}" for g in blocking),
38
+ )
39
+ return passed
40
+
41
+
14
42
  class GateResult(BaseModel):
15
43
  passed: bool
16
44
  gaps: List[Gap]
17
45
 
46
+
47
+ def _find_dependency_cycle(tasks: List[Task]) -> Optional[List[str]]:
48
+ """Return one dependency cycle as an id path (e.g. [A, B, A]), or None. Only edges
49
+ to known task ids are followed; unknown deps are reported separately."""
50
+ ids = {t.id for t in tasks}
51
+ graph = {t.id: [d for d in t.depends_on if d in ids] for t in tasks}
52
+ WHITE, GREY, BLACK = 0, 1, 2
53
+ color = {tid: WHITE for tid in graph}
54
+ stack: List[str] = []
55
+
56
+ def visit(node: str) -> Optional[List[str]]:
57
+ color[node] = GREY
58
+ stack.append(node)
59
+ for nxt in graph.get(node, []):
60
+ if color[nxt] == GREY:
61
+ return stack[stack.index(nxt):] + [nxt]
62
+ if color[nxt] == WHITE:
63
+ found = visit(nxt)
64
+ if found:
65
+ return found
66
+ stack.pop()
67
+ color[node] = BLACK
68
+ return None
69
+
70
+ for tid in graph:
71
+ if color[tid] == WHITE:
72
+ found = visit(tid)
73
+ if found:
74
+ return found
75
+ return None
76
+
77
+
78
+ def topological_order(tasks: List[Task]) -> List[Task]:
79
+ """Order tasks so every task follows the ones it depends on. Stable: preserves the
80
+ given order among independent tasks. Falls back to the original order if a cycle
81
+ makes a full ordering impossible (the plan gate blocks cycles separately)."""
82
+ by_id = {t.id: t for t in tasks}
83
+ indegree = {t.id: 0 for t in tasks}
84
+ dependents: dict[str, List[str]] = {t.id: [] for t in tasks}
85
+ for task in tasks:
86
+ for dep in task.depends_on:
87
+ if dep in by_id:
88
+ indegree[task.id] += 1
89
+ dependents[dep].append(task.id)
90
+ # Kahn's algorithm, seeded in original order for stability.
91
+ ready = deque(t.id for t in tasks if indegree[t.id] == 0)
92
+ ordered: List[str] = []
93
+ while ready:
94
+ current = ready.popleft()
95
+ ordered.append(current)
96
+ for child in dependents[current]:
97
+ indegree[child] -= 1
98
+ if indegree[child] == 0:
99
+ ready.append(child)
100
+ if len(ordered) != len(tasks): # cycle — fall back to original order
101
+ return list(tasks)
102
+ return [by_id[tid] for tid in ordered]
103
+
18
104
  class GatePolicy:
19
105
  """Central engine for executing project and task level quality gates."""
20
106
 
@@ -104,6 +190,68 @@ class GatePolicy:
104
190
  blocking=True,
105
191
  ))
106
192
 
193
+ # Surface read-only-only tasks at PLANNING time (advisory): a task that declares
194
+ # planned files but none writable can implement nothing, and was previously only
195
+ # caught at execution by the task-readiness gate.
196
+ for task in tasks:
197
+ if task.planned_files and not any(
198
+ pf.allowed_change in ("create", "modify", "delete") for pf in task.planned_files
199
+ ):
200
+ gaps.append(Gap(
201
+ id=f"GAP-PLAN-{task.id}-READ-ONLY",
202
+ severity="medium",
203
+ gap_type="task_not_implemented",
204
+ task_id=task.id,
205
+ description=(
206
+ f"Task {task.id} declares planned files but none are writable "
207
+ "(all read_only); it cannot implement any change. Expected only if "
208
+ "this is an analysis-only task."
209
+ ),
210
+ recommended_fix=(
211
+ "Grant 'create', 'modify', or 'delete' to at least one planned file, "
212
+ "or confirm the task is intentionally analysis-only."
213
+ ),
214
+ blocking=False,
215
+ ))
216
+
217
+ # Surface overlapping ownership (advisory): when 2+ tasks each declare a
218
+ # writable (create/modify/delete) change to the SAME file, the plan is
219
+ # over-decomposed — the later task tends to duplicate or conflict with the
220
+ # earlier one (e.g. both add the same function), which then fails per-task
221
+ # verification. Consolidating a file's work into one task avoids this.
222
+ writers_by_file: dict[str, list[str]] = {}
223
+ writer_sets: dict[str, set[str]] = {}
224
+ for task in tasks:
225
+ for pf in task.planned_files:
226
+ if pf.allowed_change in ("create", "modify", "delete"):
227
+ path = pf.path.replace("\\", "/")
228
+ owners = writers_by_file.setdefault(path, [])
229
+ seen = writer_sets.setdefault(path, set())
230
+ if task.id not in seen:
231
+ seen.add(task.id)
232
+ owners.append(task.id)
233
+ for path, owners in writers_by_file.items():
234
+ if len(owners) > 1:
235
+ gaps.append(Gap(
236
+ id=f"GAP-PLAN-OVERLAP-{owners[0]}-{path.replace('/', '_')}",
237
+ severity="medium",
238
+ gap_type="task_not_implemented",
239
+ description=(
240
+ f"{len(owners)} tasks ({', '.join(owners)}) each declare writable "
241
+ f"changes to {path}; overlapping ownership over-decomposes the plan "
242
+ "and tends to cause duplicate/conflicting edits at execution."
243
+ ),
244
+ recommended_fix=(
245
+ f"Consolidate the work on {path} into a single task, or scope the "
246
+ "others to read_only."
247
+ ),
248
+ blocking=False,
249
+ ))
250
+
251
+ # Validate the task dependency DAG: unknown depends_on ids and cycles would make
252
+ # execution ordering impossible / stall the run, so block the plan on them.
253
+ gaps.extend(self._validate_task_dependencies(tasks))
254
+
107
255
  for assumption in assumptions or []:
108
256
  if (
109
257
  assumption.impact == "high"
@@ -145,7 +293,7 @@ class GatePolicy:
145
293
  ))
146
294
 
147
295
  return GateResult(
148
- passed=len([g for g in gaps if g.blocking]) == 0,
296
+ passed=_log_gate("plan_approval", gaps),
149
297
  gaps=gaps
150
298
  )
151
299
 
@@ -159,32 +307,63 @@ class GatePolicy:
159
307
  # 2. Check planned files
160
308
  gaps.extend(self.planned_files.check(task))
161
309
 
162
- # 3. Check execution/verification evidence contract
163
- if not task.allowed_commands:
310
+ # 3. Surface a missing execution/verification contract — but do NOT block
311
+ # execution on it. The executor still needs to run to implement the code,
312
+ # and the evidence requirement is genuinely enforced at verify time
313
+ # (acceptance_criteria_unproven / NOAC gaps). Blocking here only prevents
314
+ # implementation and stalls multi-task plans when the planner under-specs a
315
+ # task; these stay advisory so the work can proceed and be judged on output.
316
+ if not task.allowed_commands and not task.expected_tests:
164
317
  gaps.append(Gap(
165
318
  id=f"GAP-{task.id}-NO-COMMANDS",
166
- severity="high",
319
+ severity="medium",
167
320
  gap_type="missing_test",
168
321
  task_id=task.id,
169
322
  description=f"Task {task.id} has no allowed commands for execution or verification.",
170
- recommended_fix="Add explicit allowed_commands for the task before execution.",
171
- blocking=True,
323
+ recommended_fix="Add explicit allowed_commands or expected_tests so verification can prove the acceptance criteria.",
324
+ blocking=False,
172
325
  ))
173
326
 
174
327
  if not task.expected_tests:
175
328
  gaps.append(Gap(
176
329
  id=f"GAP-{task.id}-NO-EXPECTED-EVIDENCE",
177
- severity="high",
330
+ severity="medium",
178
331
  gap_type="missing_test",
179
332
  task_id=task.id,
180
333
  description=f"Task {task.id} has no expected verification evidence.",
181
334
  recommended_fix="Add expected_tests or targeted static/manual review commands that prove the acceptance criteria.",
182
- blocking=True,
335
+ blocking=False,
183
336
  ))
184
337
 
185
- # 4. Check for task dependencies (if implemented)
186
-
187
338
  return GateResult(
188
- passed=len([g for g in gaps if g.blocking]) == 0,
339
+ passed=_log_gate("task_ready", gaps, routine=True, task_id=task.id),
189
340
  gaps=gaps
190
341
  )
342
+
343
+ def _validate_task_dependencies(self, tasks: List[Task]) -> List[Gap]:
344
+ """Block on a malformed dependency DAG: unknown depends_on ids and cycles."""
345
+ gaps: List[Gap] = []
346
+ ids = {t.id for t in tasks}
347
+ for task in tasks:
348
+ unknown = [dep for dep in task.depends_on if dep not in ids]
349
+ if unknown:
350
+ gaps.append(Gap(
351
+ id=f"GAP-PLAN-{task.id}-UNKNOWN-DEP",
352
+ severity="high",
353
+ gap_type="task_not_implemented",
354
+ task_id=task.id,
355
+ description=f"Task {task.id} depends on unknown task(s): {', '.join(unknown)}.",
356
+ recommended_fix="Reference only task IDs that exist in this plan, or remove the dependency.",
357
+ blocking=True,
358
+ ))
359
+ cycle = _find_dependency_cycle(tasks)
360
+ if cycle:
361
+ gaps.append(Gap(
362
+ id="GAP-PLAN-DEP-CYCLE",
363
+ severity="high",
364
+ gap_type="task_not_implemented",
365
+ description=f"Task dependency cycle detected: {' -> '.join(cycle)}.",
366
+ recommended_fix="Break the cycle so the tasks can be ordered and executed.",
367
+ blocking=True,
368
+ ))
369
+ return gaps