devcouncil 0.1.1 → 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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,16 +1,24 @@
1
1
  from typing import List, Dict, Any
2
+ import asyncio
2
3
  from rich.console import Console
3
4
  from pydantic import BaseModel
4
5
  from devcouncil.domain.task import Task
5
6
  from devcouncil.domain.requirement import Requirement
6
7
  from devcouncil.execution.executor import Executor, ExecutionResult
7
- from devcouncil.llm.router import ModelRouter
8
+ from devcouncil.llm.router import ModelRouter, StructuredOutputError
8
9
  from devcouncil.execution.task_runner import TaskRunner
9
10
  from devcouncil.execution.context_builder import ContextBuilder
11
+ from devcouncil.execution.prompt_builder import PromptBuilder
10
12
  from devcouncil.execution.paths import resolve_project_path
13
+ from devcouncil.app.errors import ExecutionError
11
14
 
12
15
  console = Console()
13
16
 
17
+ # Resilience bounds for the preview native loop.
18
+ MAX_AGENT_STEPS = 10
19
+ MAX_STRUCTURED_FAILURES = 2 # model can't produce a valid action -> give up cleanly
20
+ MAX_CONSECUTIVE_PATCH_FAILURES = 3 # stop spinning on a patch the model can't fix
21
+
14
22
  class ToolCall(BaseModel):
15
23
  tool: str
16
24
  args: Dict[str, Any]
@@ -24,23 +32,40 @@ class NativeAgent(Executor):
24
32
  def __init__(self, router: ModelRouter, task_runner: TaskRunner):
25
33
  self.router = router
26
34
  self.task_runner = task_runner
35
+ # ContextBuilder is retained only for the cheap list_files file listing; the
36
+ # implementation context itself uses the budgeted PromptBuilder so the native
37
+ # executor gets the same repo-map orientation, symbol outlines, dependents and
38
+ # context-window budgeting as the CLI executors (rather than a flat JSON dump).
27
39
  self.context_builder = ContextBuilder(task_runner.project_root)
40
+ self.prompt_builder = PromptBuilder(task_runner.project_root)
41
+
42
+ def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
43
+ """Run the preview native executor behind the normal synchronous executor contract."""
44
+ return asyncio.run(self._run_task_async(task, requirements))
28
45
 
29
- async def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
46
+ async def _run_task_async(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
30
47
  console.print(f"Starting [bold]Native Executor[/bold] for task {task.id}...")
48
+ console.print("[yellow]Native executor is preview quality; DevCouncil verification remains the completion gate.[/yellow]")
31
49
 
32
- # 1. Gather rich context
33
- context_json = self.context_builder.build_task_context(task, requirements)
34
-
50
+ # 1. Gather rich context (budgeted; includes repo-map orientation + symbol outlines)
51
+ context_block = self.prompt_builder.build_task_prompt(task, requirements)
52
+ from devcouncil.planning.correction_manifest import load_latest_correction_manifest
53
+
54
+ correction = load_latest_correction_manifest(self.task_runner.project_root, task.id)
55
+ correction_block = ""
56
+ if correction is not None:
57
+ correction_block = f"\nCorrection Manifest:\n{correction.model_dump_json(indent=2)}\n"
58
+
35
59
  system_prompt = f"""
36
60
  You are the DevCouncil Native Agent. Your goal is to implement the provided task.
37
61
  Current Project Context:
38
- {context_json}
62
+ {context_block}
63
+ {correction_block}
39
64
 
40
65
  You have access to the following tools:
41
66
  - read_file(path: str)
42
67
  - list_files()
43
- - apply_patch(patch: str)
68
+ - apply_patch(patch: str) OR apply_patch(path: str, content: str) as a fallback when a valid unified diff cannot be produced
44
69
  - run_command(command: str)
45
70
 
46
71
  Rules:
@@ -54,19 +79,59 @@ Rules:
54
79
  # Initial task prompt
55
80
  messages.append({"role": "user", "content": f"Begin implementing task {task.id} based on the context provided."})
56
81
 
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
63
- )
64
-
82
+ # Bounded tool loop. Counters let us fail a single task cleanly instead of
83
+ # crashing the whole run (structured-output faults) or spinning on an
84
+ # unfixable patch.
85
+ structured_failures = 0
86
+ consecutive_patch_failures = 0
87
+ for step in range(MAX_AGENT_STEPS):
88
+ try:
89
+ action = await self.router.complete_structured(
90
+ role="native_agent",
91
+ messages=messages,
92
+ schema=AgentAction,
93
+ )
94
+ except StructuredOutputError as exc:
95
+ # The model could not produce a valid action even after healing/retry.
96
+ # native_agent has no fallback by design, so handle it here rather than
97
+ # letting it propagate and abort the entire `dev go` run.
98
+ structured_failures += 1
99
+ console.print(f"[red]Native agent could not parse a valid action: {exc}[/red]")
100
+ if structured_failures >= MAX_STRUCTURED_FAILURES:
101
+ return ExecutionResult(
102
+ success=False,
103
+ message=f"Native agent gave up after {structured_failures} unparseable responses.",
104
+ )
105
+ messages.append({
106
+ "role": "user",
107
+ "content": (
108
+ "[System] Your previous response was not valid JSON for the "
109
+ "AgentAction schema. Reply with a single valid JSON object only "
110
+ "(fields: thought, tool_calls, finish) — no prose, no fences."
111
+ ),
112
+ })
113
+ continue
114
+ structured_failures = 0
115
+
65
116
  console.print(f"\n[bold]Step {step+1}:[/bold] {action.thought}")
66
-
117
+
118
+ # Record the agent's own turn so subsequent steps see what it already did.
119
+ # Without this the model only sees tool RESULTS, not its prior actions, and
120
+ # tends to repeat itself and never converge within the step budget.
121
+ messages.append({"role": "assistant", "content": action.model_dump_json()})
122
+
67
123
  if action.finish:
68
124
  console.print("[green]Native agent signaled completion.[/green]")
69
- return ExecutionResult(success=True, message="Agent signaled completion")
125
+ return ExecutionResult(success=True, message="Agent signaled completion; pending DevCouncil verification")
126
+
127
+ if not action.tool_calls:
128
+ # No action and not finished — nudge instead of silently burning a step.
129
+ messages.append({"role": "user", "content": (
130
+ "[System] You produced no tool_calls and did not finish. Call a tool "
131
+ "(read_file/list_files/apply_patch/run_command) to make progress, or set "
132
+ "finish=true if the task is complete."
133
+ )})
134
+ continue
70
135
 
71
136
  for tool_call in action.tool_calls:
72
137
  result_summary = ""
@@ -90,8 +155,26 @@ Rules:
90
155
  elif tool_call.tool == "write_file":
91
156
  raise PermissionError("write_file is disabled for the native executor; use apply_patch.")
92
157
  elif tool_call.tool == "apply_patch":
93
- self.task_runner.apply_patch(tool_call.args["patch"], task)
94
- result_summary = "Successfully applied patch."
158
+ if "path" in tool_call.args and "content" in tool_call.args:
159
+ # Fallback for when the model can't produce a valid unified
160
+ # diff. Routes through write_file, which enforces the same
161
+ # planned-files permission check — no widening of scope.
162
+ self.task_runner.write_file(
163
+ tool_call.args["path"], tool_call.args["content"], task
164
+ )
165
+ consecutive_patch_failures = 0
166
+ result_summary = f"Wrote {tool_call.args['path']} via path+content fallback."
167
+ else:
168
+ patch = tool_call.args.get("patch", "")
169
+ if not patch or not patch.strip():
170
+ raise ExecutionError(
171
+ "Empty patch. Provide a unified git diff beginning with "
172
+ "'diff --git a/<path> b/<path>', then '--- a/<path>' (or "
173
+ "'--- /dev/null' for a new file), '+++ b/<path>', and '@@' hunks."
174
+ )
175
+ self.task_runner.apply_patch(patch, task)
176
+ consecutive_patch_failures = 0
177
+ result_summary = "Successfully applied patch."
95
178
  elif tool_call.tool == "run_command":
96
179
  cmd_result = self.task_runner.run_command(tool_call.args["command"], task)
97
180
  result_summary = f"Command finished with exit code {cmd_result.exit_code}."
@@ -101,7 +184,25 @@ Rules:
101
184
  messages.append({"role": "user", "content": f"[Tool Result] '{tool_call.tool}': {result_summary}"})
102
185
  except Exception as e:
103
186
  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}"})
187
+ if tool_call.tool == "apply_patch":
188
+ consecutive_patch_failures += 1
189
+ if consecutive_patch_failures >= MAX_CONSECUTIVE_PATCH_FAILURES:
190
+ return ExecutionResult(
191
+ success=False,
192
+ message=(
193
+ f"Native agent failed to apply a patch "
194
+ f"{consecutive_patch_failures} times in a row."
195
+ ),
196
+ )
197
+ messages.append({"role": "user", "content": (
198
+ f"[Tool Error] 'apply_patch' failed: {e}\n"
199
+ "Re-read the target file, match the existing context lines EXACTLY, "
200
+ "and do NOT resubmit the same patch. If you cannot produce a valid "
201
+ "unified diff, call apply_patch with 'path' and 'content' instead to "
202
+ "write the whole file."
203
+ )})
204
+ else:
205
+ messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
105
206
 
106
207
  console.print("[red]Native agent reached maximum step limit.[/red]")
107
208
  return ExecutionResult(success=False, message="Reached maximum step limit")
@@ -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,53 @@
1
+ import re
1
2
  from typing import List
2
3
  from devcouncil.domain.gap import Gap
3
4
  from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
4
5
 
6
+ # Captures the new-file starting line from a unified-diff hunk header (@@ -a,b +c,d @@).
7
+ _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
8
+
9
+
5
10
  class SecretScanner:
6
11
  """Scans code diffs for potential secrets (API keys, tokens, etc.)."""
7
-
12
+
8
13
  def scan_diff(self, diff_content: str, task_id: str) -> List[Gap]:
9
- gaps = []
10
- lines = diff_content.splitlines()
14
+ gaps: List[Gap] = []
11
15
  current_file = "unknown_file"
12
-
13
- for i, line in enumerate(lines):
16
+ new_line_no = 0 # line number in the new file, tracked across hunks
17
+ counter = 0 # ensures unique gap ids
18
+
19
+ for line in diff_content.splitlines():
14
20
  if line.startswith("+++ b/"):
15
21
  current_file = line[6:]
16
22
  continue
17
-
18
- # Only scan added lines in diff
19
- if not line.startswith("+") or line.startswith("+++"):
23
+ if line.startswith("+++") or line.startswith("---") or line.startswith("diff "):
24
+ continue
25
+ hunk = _HUNK_RE.match(line)
26
+ if hunk:
27
+ new_line_no = int(hunk.group(1))
28
+ continue
29
+ if line.startswith("-"):
30
+ continue # removed line — does not advance the new-file counter
31
+ if line.startswith("+"):
32
+ for key_type, pattern in SECRET_PATTERNS.items():
33
+ if pattern.search(line):
34
+ counter += 1
35
+ gaps.append(Gap(
36
+ id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{new_line_no}-{counter}",
37
+ severity="critical",
38
+ gap_type="security_risk",
39
+ task_id=task_id,
40
+ description=f"Potential {key_type} found in {current_file}:{new_line_no}.",
41
+ evidence=[redact_string(line.strip())],
42
+ recommended_fix="Remove the secret and use environment variables or a secret manager.",
43
+ blocking=True,
44
+ # Populate the routing fields so the security NextAction points
45
+ # the agent straight at the file:line instead of forcing a re-grep.
46
+ file=current_file,
47
+ line=new_line_no,
48
+ ))
49
+ new_line_no += 1
20
50
  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
- ))
51
+ # Context or blank line — advances the new-file counter.
52
+ new_line_no += 1
34
53
  return gaps
@@ -15,6 +15,64 @@ class GateResult(BaseModel):
15
15
  passed: bool
16
16
  gaps: List[Gap]
17
17
 
18
+
19
+ def _find_dependency_cycle(tasks: List[Task]) -> Optional[List[str]]:
20
+ """Return one dependency cycle as an id path (e.g. [A, B, A]), or None. Only edges
21
+ to known task ids are followed; unknown deps are reported separately."""
22
+ ids = {t.id for t in tasks}
23
+ graph = {t.id: [d for d in t.depends_on if d in ids] for t in tasks}
24
+ WHITE, GREY, BLACK = 0, 1, 2
25
+ color = {tid: WHITE for tid in graph}
26
+ stack: List[str] = []
27
+
28
+ def visit(node: str) -> Optional[List[str]]:
29
+ color[node] = GREY
30
+ stack.append(node)
31
+ for nxt in graph.get(node, []):
32
+ if color[nxt] == GREY:
33
+ return stack[stack.index(nxt):] + [nxt]
34
+ if color[nxt] == WHITE:
35
+ found = visit(nxt)
36
+ if found:
37
+ return found
38
+ stack.pop()
39
+ color[node] = BLACK
40
+ return None
41
+
42
+ for tid in graph:
43
+ if color[tid] == WHITE:
44
+ found = visit(tid)
45
+ if found:
46
+ return found
47
+ return None
48
+
49
+
50
+ def topological_order(tasks: List[Task]) -> List[Task]:
51
+ """Order tasks so every task follows the ones it depends on. Stable: preserves the
52
+ given order among independent tasks. Falls back to the original order if a cycle
53
+ makes a full ordering impossible (the plan gate blocks cycles separately)."""
54
+ by_id = {t.id: t for t in tasks}
55
+ indegree = {t.id: 0 for t in tasks}
56
+ dependents: dict[str, List[str]] = {t.id: [] for t in tasks}
57
+ for task in tasks:
58
+ for dep in task.depends_on:
59
+ if dep in by_id:
60
+ indegree[task.id] += 1
61
+ dependents[dep].append(task.id)
62
+ # Kahn's algorithm, seeded in original order for stability.
63
+ ready = [t.id for t in tasks if indegree[t.id] == 0]
64
+ ordered: List[str] = []
65
+ while ready:
66
+ current = ready.pop(0)
67
+ ordered.append(current)
68
+ for child in dependents[current]:
69
+ indegree[child] -= 1
70
+ if indegree[child] == 0:
71
+ ready.append(child)
72
+ if len(ordered) != len(tasks): # cycle — fall back to original order
73
+ return list(tasks)
74
+ return [by_id[tid] for tid in ordered]
75
+
18
76
  class GatePolicy:
19
77
  """Central engine for executing project and task level quality gates."""
20
78
 
@@ -104,6 +162,65 @@ class GatePolicy:
104
162
  blocking=True,
105
163
  ))
106
164
 
165
+ # Surface read-only-only tasks at PLANNING time (advisory): a task that declares
166
+ # planned files but none writable can implement nothing, and was previously only
167
+ # caught at execution by the task-readiness gate.
168
+ for task in tasks:
169
+ if task.planned_files and not any(
170
+ pf.allowed_change in ("create", "modify", "delete") for pf in task.planned_files
171
+ ):
172
+ gaps.append(Gap(
173
+ id=f"GAP-PLAN-{task.id}-READ-ONLY",
174
+ severity="medium",
175
+ gap_type="task_not_implemented",
176
+ task_id=task.id,
177
+ description=(
178
+ f"Task {task.id} declares planned files but none are writable "
179
+ "(all read_only); it cannot implement any change. Expected only if "
180
+ "this is an analysis-only task."
181
+ ),
182
+ recommended_fix=(
183
+ "Grant 'create', 'modify', or 'delete' to at least one planned file, "
184
+ "or confirm the task is intentionally analysis-only."
185
+ ),
186
+ blocking=False,
187
+ ))
188
+
189
+ # Surface overlapping ownership (advisory): when 2+ tasks each declare a
190
+ # writable (create/modify/delete) change to the SAME file, the plan is
191
+ # over-decomposed — the later task tends to duplicate or conflict with the
192
+ # earlier one (e.g. both add the same function), which then fails per-task
193
+ # verification. Consolidating a file's work into one task avoids this.
194
+ writers_by_file: dict[str, list[str]] = {}
195
+ for task in tasks:
196
+ for pf in task.planned_files:
197
+ if pf.allowed_change in ("create", "modify", "delete"):
198
+ path = pf.path.replace("\\", "/")
199
+ writers_by_file.setdefault(path, [])
200
+ if task.id not in writers_by_file[path]:
201
+ writers_by_file[path].append(task.id)
202
+ for path, owners in writers_by_file.items():
203
+ if len(owners) > 1:
204
+ gaps.append(Gap(
205
+ id=f"GAP-PLAN-OVERLAP-{owners[0]}-{path.replace('/', '_')}",
206
+ severity="medium",
207
+ gap_type="task_not_implemented",
208
+ description=(
209
+ f"{len(owners)} tasks ({', '.join(owners)}) each declare writable "
210
+ f"changes to {path}; overlapping ownership over-decomposes the plan "
211
+ "and tends to cause duplicate/conflicting edits at execution."
212
+ ),
213
+ recommended_fix=(
214
+ f"Consolidate the work on {path} into a single task, or scope the "
215
+ "others to read_only."
216
+ ),
217
+ blocking=False,
218
+ ))
219
+
220
+ # Validate the task dependency DAG: unknown depends_on ids and cycles would make
221
+ # execution ordering impossible / stall the run, so block the plan on them.
222
+ gaps.extend(self._validate_task_dependencies(tasks))
223
+
107
224
  for assumption in assumptions or []:
108
225
  if (
109
226
  assumption.impact == "high"
@@ -159,32 +276,63 @@ class GatePolicy:
159
276
  # 2. Check planned files
160
277
  gaps.extend(self.planned_files.check(task))
161
278
 
162
- # 3. Check execution/verification evidence contract
163
- if not task.allowed_commands:
279
+ # 3. Surface a missing execution/verification contract — but do NOT block
280
+ # execution on it. The executor still needs to run to implement the code,
281
+ # and the evidence requirement is genuinely enforced at verify time
282
+ # (acceptance_criteria_unproven / NOAC gaps). Blocking here only prevents
283
+ # implementation and stalls multi-task plans when the planner under-specs a
284
+ # task; these stay advisory so the work can proceed and be judged on output.
285
+ if not task.allowed_commands and not task.expected_tests:
164
286
  gaps.append(Gap(
165
287
  id=f"GAP-{task.id}-NO-COMMANDS",
166
- severity="high",
288
+ severity="medium",
167
289
  gap_type="missing_test",
168
290
  task_id=task.id,
169
291
  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,
292
+ recommended_fix="Add explicit allowed_commands or expected_tests so verification can prove the acceptance criteria.",
293
+ blocking=False,
172
294
  ))
173
295
 
174
296
  if not task.expected_tests:
175
297
  gaps.append(Gap(
176
298
  id=f"GAP-{task.id}-NO-EXPECTED-EVIDENCE",
177
- severity="high",
299
+ severity="medium",
178
300
  gap_type="missing_test",
179
301
  task_id=task.id,
180
302
  description=f"Task {task.id} has no expected verification evidence.",
181
303
  recommended_fix="Add expected_tests or targeted static/manual review commands that prove the acceptance criteria.",
182
- blocking=True,
304
+ blocking=False,
183
305
  ))
184
306
 
185
- # 4. Check for task dependencies (if implemented)
186
-
187
307
  return GateResult(
188
- passed=len([g for g in gaps if g.blocking]) == 0,
308
+ passed=len([g for g in gaps if g.blocking]) == 0,
189
309
  gaps=gaps
190
310
  )
311
+
312
+ def _validate_task_dependencies(self, tasks: List[Task]) -> List[Gap]:
313
+ """Block on a malformed dependency DAG: unknown depends_on ids and cycles."""
314
+ gaps: List[Gap] = []
315
+ ids = {t.id for t in tasks}
316
+ for task in tasks:
317
+ unknown = [dep for dep in task.depends_on if dep not in ids]
318
+ if unknown:
319
+ gaps.append(Gap(
320
+ id=f"GAP-PLAN-{task.id}-UNKNOWN-DEP",
321
+ severity="high",
322
+ gap_type="task_not_implemented",
323
+ task_id=task.id,
324
+ description=f"Task {task.id} depends on unknown task(s): {', '.join(unknown)}.",
325
+ recommended_fix="Reference only task IDs that exist in this plan, or remove the dependency.",
326
+ blocking=True,
327
+ ))
328
+ cycle = _find_dependency_cycle(tasks)
329
+ if cycle:
330
+ gaps.append(Gap(
331
+ id="GAP-PLAN-DEP-CYCLE",
332
+ severity="high",
333
+ gap_type="task_not_implemented",
334
+ description=f"Task dependency cycle detected: {' -> '.join(cycle)}.",
335
+ recommended_fix="Break the cycle so the tasks can be ordered and executed.",
336
+ blocking=True,
337
+ ))
338
+ return gaps
@@ -0,0 +1,184 @@
1
+ """Host hardware detection used to size local (Ollama) models.
2
+
3
+ DevCouncil runs the council roles against whatever LLM provider is configured.
4
+ For the local ``ollama`` provider the model has to fit in host memory. On Apple
5
+ Silicon Macs that memory is *unified* (shared by CPU/GPU/OS), so total RAM is the
6
+ ceiling. On a host with a discrete GPU (NVIDIA), Ollama offloads to VRAM, so the
7
+ *VRAM* is the practical ceiling instead — a 64 GB box with an 8 GB GPU should not be
8
+ told to run a model that only fits in system RAM. This module exposes small, pure
9
+ helpers so ``dev doctor`` and ``dev setup`` can recommend a model that will actually
10
+ run instead of a one-size-fits-all default, on macOS, Linux and Windows.
11
+
12
+ Everything here is best-effort and stdlib-only: detection failures return
13
+ ``None`` rather than raising, so callers degrade to the static default.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import platform
20
+ import shutil
21
+ import subprocess
22
+ from dataclasses import dataclass
23
+
24
+ # Recommended Ollama context window for DevCouncil's large planning prompts.
25
+ # Kept in sync with the value surfaced by `dev doctor`.
26
+ RECOMMENDED_NUM_CTX = 16384
27
+
28
+ # Fallback model when the host is unknown or has little memory. Matches the
29
+ # static defaults in ``llm/model_defaults.yaml``.
30
+ DEFAULT_OLLAMA_MODEL = "qwen2.5-coder:7b"
31
+
32
+ # RAM (GiB) -> recommended qwen2.5-coder size. Highest matching tier wins.
33
+ # Sizes are chosen so the quantized weights plus a 16k context comfortably fit
34
+ # alongside the OS in Apple Silicon unified memory.
35
+ _OLLAMA_MODEL_TIERS: tuple[tuple[float, str], ...] = (
36
+ (48.0, "qwen2.5-coder:32b"),
37
+ (24.0, "qwen2.5-coder:14b"),
38
+ (0.0, "qwen2.5-coder:7b"),
39
+ )
40
+
41
+
42
+ def is_macos() -> bool:
43
+ return platform.system() == "Darwin"
44
+
45
+
46
+ def is_apple_silicon() -> bool:
47
+ """True on Apple-Silicon (arm64) Macs."""
48
+ return is_macos() and platform.machine() == "arm64"
49
+
50
+
51
+ def mac_chip_brand() -> str | None:
52
+ """Marketing CPU string on macOS (e.g. ``Apple M3 Pro``), else None."""
53
+ if not is_macos():
54
+ return None
55
+ try:
56
+ out = subprocess.check_output(
57
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
58
+ text=True,
59
+ timeout=5,
60
+ ).strip()
61
+ return out or None
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ def total_ram_gb() -> float | None:
67
+ """Total physical RAM in GiB, or None if it cannot be determined."""
68
+ try:
69
+ if is_macos():
70
+ out = subprocess.check_output(
71
+ ["sysctl", "-n", "hw.memsize"], text=True, timeout=5
72
+ ).strip()
73
+ return int(out) / (1024**3)
74
+ # POSIX (Linux): pages * page size.
75
+ pages = os.sysconf("SC_PHYS_PAGES")
76
+ page_size = os.sysconf("SC_PAGE_SIZE")
77
+ return (pages * page_size) / (1024**3)
78
+ except (OSError, ValueError, AttributeError, subprocess.SubprocessError):
79
+ return None
80
+
81
+
82
+ def nvidia_vram_gb() -> float | None:
83
+ """Total VRAM (GiB) of the largest NVIDIA GPU via ``nvidia-smi``, else ``None``.
84
+
85
+ On hosts with a discrete NVIDIA GPU this is the real ceiling for Ollama, since the
86
+ model is offloaded to VRAM. Returns ``None`` when ``nvidia-smi`` is absent (no GPU,
87
+ Apple Silicon, or an unsupported vendor), so callers fall back to total RAM."""
88
+ if not shutil.which("nvidia-smi"):
89
+ return None
90
+ try:
91
+ out = subprocess.check_output(
92
+ ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
93
+ text=True,
94
+ timeout=5,
95
+ stderr=subprocess.DEVNULL,
96
+ ).strip()
97
+ except (OSError, subprocess.SubprocessError):
98
+ return None
99
+ sizes_mib: list[float] = []
100
+ for line in out.splitlines():
101
+ line = line.strip()
102
+ if not line:
103
+ continue
104
+ try:
105
+ sizes_mib.append(float(line))
106
+ except ValueError:
107
+ continue
108
+ if not sizes_mib:
109
+ return None
110
+ return max(sizes_mib) / 1024.0 # MiB -> GiB
111
+
112
+
113
+ def recommend_ollama_model(ram_gb: float | None = None, vram_gb: float | None = None) -> str:
114
+ """Largest qwen2.5-coder size expected to run on the host.
115
+
116
+ When a discrete GPU's ``vram_gb`` is known it is the ceiling (Ollama offloads to
117
+ VRAM); otherwise total system RAM is used (correct for unified-memory Macs and
118
+ CPU-only hosts)."""
119
+ if ram_gb is None:
120
+ ram_gb = total_ram_gb()
121
+ effective = vram_gb if vram_gb is not None else ram_gb
122
+ if effective is None:
123
+ return DEFAULT_OLLAMA_MODEL
124
+ for floor, model in _OLLAMA_MODEL_TIERS:
125
+ if effective >= floor:
126
+ return model
127
+ return DEFAULT_OLLAMA_MODEL
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class HostSummary:
132
+ """A snapshot of the host relevant to local-model sizing."""
133
+
134
+ is_macos: bool
135
+ is_apple_silicon: bool
136
+ chip: str | None
137
+ ram_gb: float | None
138
+ recommended_ollama_model: str
139
+ vram_gb: float | None = None
140
+
141
+ @property
142
+ def ram_label(self) -> str:
143
+ return f"{self.ram_gb:.0f} GB" if self.ram_gb is not None else "unknown RAM"
144
+
145
+ @property
146
+ def vram_label(self) -> str | None:
147
+ return f"{self.vram_gb:.0f} GB VRAM" if self.vram_gb is not None else None
148
+
149
+ @property
150
+ def chip_label(self) -> str:
151
+ if self.chip:
152
+ return self.chip
153
+ if self.is_apple_silicon:
154
+ return "Apple Silicon"
155
+ if self.vram_gb is not None:
156
+ return "discrete GPU host"
157
+ return "this host"
158
+
159
+ @property
160
+ def platform_label(self) -> str:
161
+ """Human label for the sizing row across OSes."""
162
+ if self.is_macos:
163
+ return "Apple Silicon" if self.is_apple_silicon else "Mac (Intel)"
164
+ system = platform.system()
165
+ return system or "Host"
166
+
167
+ @property
168
+ def memory_label(self) -> str:
169
+ """Memory ceiling used for the recommendation (VRAM if discrete GPU, else RAM)."""
170
+ vram = self.vram_label
171
+ return f"{vram} (GPU)" if vram else self.ram_label
172
+
173
+
174
+ def describe_host() -> HostSummary:
175
+ ram = total_ram_gb()
176
+ vram = nvidia_vram_gb()
177
+ return HostSummary(
178
+ is_macos=is_macos(),
179
+ is_apple_silicon=is_apple_silicon(),
180
+ chip=mac_chip_brand(),
181
+ ram_gb=ram,
182
+ vram_gb=vram,
183
+ recommended_ollama_model=recommend_ollama_model(ram, vram),
184
+ )