devcouncil 0.2.0 → 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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  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 +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  import os
6
7
  import shlex
7
8
  import shutil
@@ -20,6 +21,8 @@ from devcouncil.storage.native import ShellCommandRepository, ShellSessionReposi
20
21
  from devcouncil.storage.repositories import EvidenceRepository, TaskRepository
21
22
  from devcouncil.telemetry.traces import TraceLogger
22
23
 
24
+ logger = logging.getLogger(__name__)
25
+
23
26
 
24
27
  class ShellBackend:
25
28
  def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
@@ -137,6 +140,7 @@ class GuardedShellSession:
137
140
  stderr_path = self.log_dir / f"{self.task.id}-{log_id}.stderr.log"
138
141
 
139
142
  if decision.action == "deny":
143
+ logger.warning("Shell command DENIED for %s: %s (%s)", self.task.id, normalized, decision.reason)
140
144
  self._record_command(normalized, "denied", reason=decision.reason)
141
145
  TraceLogger(self.project_root).log_event(
142
146
  "shell_command_denied",
@@ -152,9 +156,11 @@ class GuardedShellSession:
152
156
  )
153
157
  return 1
154
158
 
159
+ logger.info("Shell command for %s: %s", self.task.id, normalized)
155
160
  try:
156
161
  result = self.backend.run_command(normalized, self.project_root)
157
162
  except (NotImplementedError, FileNotFoundError, OSError) as exc:
163
+ logger.warning("Shell command could not run for %s: %s (%s)", self.task.id, normalized, exc)
158
164
  self._record_command(normalized, "denied", reason=str(exc))
159
165
  console.print(f"[red]Could not run '{normalized}':[/red] {exc}")
160
166
  return 1
@@ -5,8 +5,11 @@ import logging
5
5
  import re
6
6
  import shlex
7
7
  from pathlib import Path
8
- from typing import Literal, Optional
8
+ from typing import Literal, Optional, TYPE_CHECKING
9
9
  from devcouncil.domain.task import Task
10
+
11
+ if TYPE_CHECKING:
12
+ from devcouncil.app.config import DevCouncilConfig
10
13
  from devcouncil.execution.permissions import PermissionManager
11
14
  from devcouncil.domain.evidence import CommandResult
12
15
  from devcouncil.app.errors import ExecutionError
@@ -25,6 +28,16 @@ class TaskRunner:
25
28
  self.project_root = project_root
26
29
  self.permissions = permission_manager
27
30
  self.patch_engine = PatchEngine(project_root)
31
+ # Load config and build the trace logger once: run_command previously
32
+ # re-parsed config.yaml on every command, and each operation rebuilt a
33
+ # TraceLogger. Both are reusable for the lifetime of the runner.
34
+ self.config: "Optional[DevCouncilConfig]"
35
+ try:
36
+ from devcouncil.app.config import load_config
37
+ self.config = load_config(project_root)
38
+ except Exception:
39
+ self.config = None
40
+ self.tracer = TraceLogger(project_root)
28
41
 
29
42
  def _validate_path_within_root(self, path: str) -> None:
30
43
  """Ensure a path resolves to a location within the project root."""
@@ -37,7 +50,7 @@ class TaskRunner:
37
50
  self._validate_path_within_root(path)
38
51
  self.permissions.validate_action("file_write", path, task, operation=operation)
39
52
  applied = self.patch_engine.apply_patch(patch)
40
- TraceLogger(self.project_root).log_event(
53
+ self.tracer.log_event(
41
54
  "tool_patch_applied",
42
55
  {"paths": sorted(changes), "success": applied},
43
56
  task_id=task.id,
@@ -107,9 +120,7 @@ class TaskRunner:
107
120
  logger.info(f"Executing authorized command: {command}")
108
121
 
109
122
  try:
110
- from devcouncil.app.config import load_config
111
- config = load_config(self.project_root)
112
- timeout = config.execution.command_timeout
123
+ timeout = self.config.execution.command_timeout if self.config is not None else 300
113
124
  except Exception:
114
125
  timeout = 300
115
126
 
@@ -133,7 +144,7 @@ class TaskRunner:
133
144
  stderr_path = self._save_command_log(task.id, command, "stderr", stderr)
134
145
  stdout_summary = redact_string(stdout[-500:])
135
146
  stderr_summary = redact_string(stderr[-500:])
136
- TraceLogger(self.project_root).log_event(
147
+ self.tracer.log_event(
137
148
  "command_executed",
138
149
  {"command": command, "exit_code": result.returncode},
139
150
  task_id=task.id,
@@ -160,7 +171,7 @@ class TaskRunner:
160
171
  try:
161
172
  full_path.parent.mkdir(parents=True, exist_ok=True)
162
173
  full_path.write_text(content, encoding="utf-8")
163
- TraceLogger(self.project_root).log_event(
174
+ self.tracer.log_event(
164
175
  "file_written",
165
176
  {"path": path},
166
177
  task_id=task.id,
@@ -95,6 +95,11 @@ def normalize_agent_name(name: str) -> str:
95
95
 
96
96
 
97
97
  def resolve_cursor_agent_executable() -> str | None:
98
+ # NOTE: intentionally NOT lru_cached. It takes no project_root key, so a
99
+ # process-wide cache would leak one test's PATH probe (which the suite
100
+ # monkeypatches shutil.which to control) into the next, breaking isolation.
101
+ # The PATH search is two cheap shutil.which() calls; the dominant repeated
102
+ # cost (load_cli_agent_specs / config parsing) is cached instead.
98
103
  for candidate in ("cursor-agent", "agent"):
99
104
  if shutil.which(candidate):
100
105
  return candidate
@@ -246,8 +251,19 @@ def detect_available_coding_cli(
246
251
  project_root: Path,
247
252
  probe_order: tuple[str, ...] | None = None,
248
253
  ) -> str | None:
254
+ # Load specs once for the whole probe rather than per client (each
255
+ # resolve_coding_cli_executable() call would otherwise rebuild the spec
256
+ # table and re-parse config.yaml). Resolution below mirrors
257
+ # resolve_coding_cli_executable() exactly, just against the prebuilt table.
258
+ specs = load_cli_agent_specs(project_root)
249
259
  for client in probe_order or resolve_coding_cli_probe_order(project_root):
250
- if resolve_coding_cli_executable(project_root, client):
260
+ normalized = normalize_agent_name(client)
261
+ if normalized == "cursor":
262
+ if resolve_cursor_agent_executable():
263
+ return client
264
+ continue
265
+ spec = specs.get(normalized)
266
+ if spec and shutil.which(spec.executable):
251
267
  return client
252
268
  return None
253
269
 
@@ -453,6 +469,11 @@ def builtin_agent_specs(project_root: Path) -> dict[str, CliAgentSpec]:
453
469
 
454
470
 
455
471
  def load_cli_agent_specs(project_root: Path) -> dict[str, CliAgentSpec]:
472
+ # NOTE: intentionally NOT cached. Callers (e.g. `setup` then `integrate
473
+ # doctor`) rewrite config.yaml for the same project_root within a single
474
+ # process and expect the next load to reflect it, so this must stay a live
475
+ # read. The repeated-reload hot path (the CLI probe loop) instead hoists a
476
+ # single load — see detect_available_coding_cli.
456
477
  specs = builtin_agent_specs(project_root)
457
478
  try:
458
479
  configured = load_config(project_root).integrations.cli_agents.agents
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  import os
3
4
  import queue
4
5
  import shutil
@@ -9,12 +10,13 @@ import time
9
10
  import uuid
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
13
+ from typing import Optional
12
14
 
13
15
  from rich.console import Console
14
16
 
15
17
  from devcouncil.domain.requirement import Requirement
16
18
  from devcouncil.domain.task import Task
17
- from devcouncil.app.config import load_config
19
+ from devcouncil.app.config import DevCouncilConfig, load_config
18
20
  from devcouncil.execution.executor import Executor, ExecutionResult
19
21
  from devcouncil.execution.prompt_builder import PromptBuilder
20
22
  from devcouncil.executors.agent_registry import (
@@ -27,9 +29,17 @@ from devcouncil.executors.agent_registry import (
27
29
  )
28
30
  from devcouncil.repo.gitignore import ensure_gitignore
29
31
  from devcouncil.telemetry.traces import TraceLogger
32
+ from devcouncil.telemetry.logging_setup import run_log
30
33
  from devcouncil.utils.redaction import redact_text
31
34
 
32
35
  console = Console()
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # DevCouncil-managed scaffolding `dev` writes into a workspace itself (agent guides, the
39
+ # managed .gitignore). The pre-verify scope gate must never revert these — they are not
40
+ # task work, and reverting them would undo `dev`'s own setup. .devcouncil/* is handled
41
+ # by a prefix check at the call site.
42
+ _SCAFFOLDING_PATHS = frozenset({"AGENTS.md", "AGENTS.json", "CLAUDE.md", ".gitignore"})
33
43
 
34
44
 
35
45
  class CodingCliExecutor(Executor):
@@ -44,6 +54,14 @@ class CodingCliExecutor(Executor):
44
54
  stream_output: bool | None = None,
45
55
  ):
46
56
  self.project_root = project_root
57
+ # Load the project config once per executor instance. The same handle is
58
+ # reused by _resolve_stream_output, _cursor_resume_mode and the Warp
59
+ # command builder, which would otherwise each re-parse config.yaml.
60
+ self._config: Optional[DevCouncilConfig]
61
+ try:
62
+ self._config = load_config(project_root)
63
+ except Exception:
64
+ self._config = None
47
65
  self.client = self._normalize_client(client)
48
66
  self.timeout_seconds = timeout_seconds
49
67
  self.spec = self._resolve_spec()
@@ -66,7 +84,9 @@ class CodingCliExecutor(Executor):
66
84
  if stream_output is not None:
67
85
  return stream_output
68
86
  try:
69
- return bool(load_config(self.project_root).execution.stream_cli_output)
87
+ if self._config is None:
88
+ return False
89
+ return bool(self._config.execution.stream_cli_output)
70
90
  except Exception:
71
91
  return False
72
92
 
@@ -193,8 +213,10 @@ class CodingCliExecutor(Executor):
193
213
 
194
214
  def _load_warp_config(self) -> dict:
195
215
  try:
196
- warp = load_config(self.project_root).integrations.warp
197
- data = warp.model_dump()
216
+ if self._config is None:
217
+ data = {}
218
+ else:
219
+ data = self._config.integrations.warp.model_dump()
198
220
  except Exception:
199
221
  data = {}
200
222
  if command := os.environ.get("DEVCOUNCIL_WARP_COMMAND"):
@@ -210,12 +232,15 @@ class CodingCliExecutor(Executor):
210
232
  return data
211
233
 
212
234
  def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
235
+ logger.info("coding_cli.run_task: client=%s profile=%s task=%s", self.client, self.profile_name, task.id)
213
236
  if self.profile is None:
237
+ logger.error("Unknown agent profile %r for %s; cannot start.", self.profile_name, self.client)
214
238
  return ExecutionResult(
215
239
  success=False,
216
240
  message=f"Unknown agent profile '{self.profile_name}' for {self.client}.",
217
241
  )
218
242
  if self.spec.input_mode not in VALID_INPUT_MODES:
243
+ logger.error("Invalid input_mode %r for %s; cannot start.", self.spec.input_mode, self.client)
219
244
  return ExecutionResult(
220
245
  success=False,
221
246
  message=(
@@ -229,10 +254,12 @@ class CodingCliExecutor(Executor):
229
254
  try:
230
255
  command = self._command(task.id)
231
256
  except ValueError as exc:
257
+ logger.error("Failed to build %s command for %s: %s", self.client, task.id, exc)
232
258
  return ExecutionResult(success=False, message=str(exc))
233
259
 
234
260
  executable = command[0]
235
261
  if not shutil.which(executable):
262
+ logger.error("%s CLI executable %r not found on PATH.", self.client, executable)
236
263
  return ExecutionResult(
237
264
  success=False,
238
265
  message=f"{self.client} CLI is not installed or not on PATH.",
@@ -263,6 +290,12 @@ class CodingCliExecutor(Executor):
263
290
  console.print(f"Starting [bold]{self.client.upper()}[/bold] for task [bold]{task.id}[/bold]...")
264
291
  console.print(f"Task prompt: [dim]{instruction_file}[/dim]")
265
292
 
293
+ # Isolate this run's full DEBUG trail in its own run-dir log, on top of the
294
+ # always-on shared devcouncil.log, so an agent run can be inspected end-to-end
295
+ # without grepping across unrelated activity / log rotations.
296
+ run_log_cm = run_log(self.project_root / ".devcouncil" / "runs" / run_id / "run.log")
297
+ run_log_cm.__enter__()
298
+
266
299
  started = time.monotonic()
267
300
  try:
268
301
  invocation, input_text = self._invocation(command, prompt, instruction_file)
@@ -296,8 +329,10 @@ class CodingCliExecutor(Executor):
296
329
  else None
297
330
  )
298
331
  started = time.monotonic()
332
+ logger.info("Launching %s subprocess for %s (timeout=%ss)", self.client, task.id, self._effective_timeout())
299
333
  result = self._run_subprocess(invocation, input_text, env, transcript_path=transcript_path)
300
334
  duration = round(time.monotonic() - started, 3)
335
+ logger.info("%s subprocess for %s exited %s in %.2fs", self.client, task.id, result.returncode, duration)
301
336
  finished_at = datetime.now(timezone.utc).isoformat()
302
337
  self._write_log(log_prefix, result)
303
338
  if transcript_path and transcript_path.exists():
@@ -316,6 +351,7 @@ class CodingCliExecutor(Executor):
316
351
  )
317
352
  stderr_preview = (result.stderr or result.stdout or "").strip().splitlines()[:5]
318
353
  detail = redact_text(stderr_preview[0]) if stderr_preview else "No diagnostics were produced."
354
+ logger.error("%s exited %s for %s: %s", self.client, result.returncode, task.id, detail)
319
355
  TraceLogger(self.project_root).log_event(
320
356
  "agent_run_failed",
321
357
  {"agent": self.client, "profile": self.profile_name, "returncode": result.returncode, "detail": detail},
@@ -343,8 +379,31 @@ class CodingCliExecutor(Executor):
343
379
  task_id=task.id,
344
380
  summary=f"{self.client} finished for {task.id}",
345
381
  )
382
+ # Opt-in pre-verify scope gate: this CLI subprocess wrote directly to disk with
383
+ # no per-write hook, so revert any out-of-scope change now (before it reaches the
384
+ # verify gate or a commit) rather than only flagging it as orphan_diff post-verify.
385
+ if self._scope_enforcement_enabled():
386
+ reverted = self._enforce_file_scope(task)
387
+ if reverted:
388
+ files = ", ".join(path for path, _ in reverted)
389
+ TraceLogger(self.project_root).log_event(
390
+ "agent_scope_violation_reverted",
391
+ {"agent": self.client, "task_id": task.id,
392
+ "reverted": [{"path": p, "reason": r} for p, r in reverted]},
393
+ run_id=run_id,
394
+ task_id=task.id,
395
+ summary=f"Reverted {len(reverted)} out-of-scope change(s) by {self.client}",
396
+ )
397
+ return ExecutionResult(
398
+ success=False,
399
+ message=(
400
+ f"Reverted {len(reverted)} out-of-scope file change(s) the task did not "
401
+ f"authorize: {files}. Re-run keeping edits within the task's allowed files."
402
+ ),
403
+ )
346
404
  return ExecutionResult(success=True, message=f"{self.client} execution finished.")
347
405
  except subprocess.TimeoutExpired:
406
+ logger.error("%s timed out after %ss for %s", self.client, self._effective_timeout(), task.id)
348
407
  self._update_run_manifest(
349
408
  run_id,
350
409
  status="timeout",
@@ -363,6 +422,7 @@ class CodingCliExecutor(Executor):
363
422
  message=f"{self.client} execution timed out after {self._effective_timeout()}s.",
364
423
  )
365
424
  except Exception as exc:
425
+ logger.exception("%s execution raised for %s: %s", self.client, task.id, exc)
366
426
  self._update_run_manifest(
367
427
  run_id,
368
428
  status="failed",
@@ -372,6 +432,71 @@ class CodingCliExecutor(Executor):
372
432
  duration_seconds=round(time.monotonic() - started, 3),
373
433
  )
374
434
  return ExecutionResult(success=False, message=str(exc))
435
+ finally:
436
+ run_log_cm.__exit__(None, None, None)
437
+
438
+ def _scope_enforcement_enabled(self) -> bool:
439
+ try:
440
+ from devcouncil.app.config import load_config
441
+ return bool(load_config(self.project_root).execution.enforce_file_scope_pre_verify)
442
+ except Exception:
443
+ return False
444
+
445
+ def _enforce_file_scope(self, task: Task) -> list[tuple[str, str]]:
446
+ """Revert any file this task's subprocess changed that the task does not authorize.
447
+
448
+ Uses the task's net changed files (baseline/snapshot subtracted, DevCouncil-managed
449
+ paths already filtered) and the same policy the hook path enforces. Returns the list
450
+ of ``(path, reason)`` reverted; empty when every change was in scope."""
451
+ try:
452
+ from devcouncil.execution.policy_engine import TaskPolicyEngine
453
+ from devcouncil.verification.verifier import Verifier
454
+
455
+ changed = Verifier(self.project_root).get_task_changed_files(task.id)
456
+ except Exception:
457
+ return []
458
+ engine = TaskPolicyEngine(self.project_root)
459
+ reverted: list[tuple[str, str]] = []
460
+ for path in changed:
461
+ # Never touch DevCouncil-managed scaffolding even if it surfaces in the diff
462
+ # (e.g. baseline snapshots failed to load): `dev` owns these files, not the task.
463
+ if path in _SCAFFOLDING_PATHS or path.startswith(".devcouncil/"):
464
+ continue
465
+ try:
466
+ decision = engine.evaluate_file_change(path, task, "write")
467
+ except Exception:
468
+ continue
469
+ if decision.action == "deny" and self._revert_path(path):
470
+ reverted.append((path, decision.reason))
471
+ return reverted
472
+
473
+ def _revert_path(self, rel_path: str) -> bool:
474
+ """Undo an out-of-scope change. A file that exists in HEAD is restored to HEAD; a
475
+ file the task newly added (absent from HEAD, or no HEAD at all) is unstaged and
476
+ deleted. Best-effort — a failed revert returns False so the path is not reported as
477
+ cleanly gated (and the caller does not claim it was reverted)."""
478
+ try:
479
+ in_head = subprocess.run(
480
+ ["git", "cat-file", "-e", f"HEAD:{rel_path}"],
481
+ cwd=self.project_root, capture_output=True, text=True,
482
+ ).returncode == 0
483
+ if in_head:
484
+ return subprocess.run(
485
+ ["git", "checkout", "HEAD", "--", rel_path],
486
+ cwd=self.project_root, capture_output=True, text=True,
487
+ ).returncode == 0
488
+ # New file (incl. the no-HEAD case): unstage if staged, then remove the working
489
+ # copy so it cannot be committed by the next repair attempt.
490
+ subprocess.run(
491
+ ["git", "rm", "-f", "--cached", "--ignore-unmatch", rel_path],
492
+ cwd=self.project_root, capture_output=True, text=True,
493
+ )
494
+ full = self.project_root / rel_path
495
+ if full.is_file():
496
+ full.unlink()
497
+ return True
498
+ except Exception:
499
+ return False
375
500
 
376
501
  def _resolve_invocation(self, invocation: list[str], env: dict[str, str]) -> list[str]:
377
502
  """Route Windows batch shims through the command interpreter.
@@ -520,7 +645,10 @@ class CodingCliExecutor(Executor):
520
645
 
521
646
  def _cursor_resume_mode(self) -> str:
522
647
  try:
523
- mode = (load_config(self.project_root).execution.cursor_resume_mode or "off").strip().lower()
648
+ if self._config is None:
649
+ mode = "off"
650
+ else:
651
+ mode = (self._config.execution.cursor_resume_mode or "off").strip().lower()
524
652
  except Exception:
525
653
  mode = "off"
526
654
  if mode not in {"off", "project", "task"}:
@@ -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,5 +1,6 @@
1
1
  from typing import List, Dict, Any
2
2
  import asyncio
3
+ import logging
3
4
  from rich.console import Console
4
5
  from pydantic import BaseModel
5
6
  from devcouncil.domain.task import Task
@@ -13,6 +14,7 @@ from devcouncil.execution.paths import resolve_project_path
13
14
  from devcouncil.app.errors import ExecutionError
14
15
 
15
16
  console = Console()
17
+ logger = logging.getLogger(__name__)
16
18
 
17
19
  # Resilience bounds for the preview native loop.
18
20
  MAX_AGENT_STEPS = 10
@@ -44,6 +46,7 @@ class NativeAgent(Executor):
44
46
  return asyncio.run(self._run_task_async(task, requirements))
45
47
 
46
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)
47
50
  console.print(f"Starting [bold]Native Executor[/bold] for task {task.id}...")
48
51
  console.print("[yellow]Native executor is preview quality; DevCouncil verification remains the completion gate.[/yellow]")
49
52
 
@@ -96,8 +99,10 @@ Rules:
96
99
  # native_agent has no fallback by design, so handle it here rather than
97
100
  # letting it propagate and abort the entire `dev go` run.
98
101
  structured_failures += 1
102
+ logger.warning("Native agent step %d: unparseable action (%d/%d): %s", step + 1, structured_failures, MAX_STRUCTURED_FAILURES, exc)
99
103
  console.print(f"[red]Native agent could not parse a valid action: {exc}[/red]")
100
104
  if structured_failures >= MAX_STRUCTURED_FAILURES:
105
+ logger.error("Native agent giving up on %s after %d unparseable responses", task.id, structured_failures)
101
106
  return ExecutionResult(
102
107
  success=False,
103
108
  message=f"Native agent gave up after {structured_failures} unparseable responses.",
@@ -113,6 +118,11 @@ Rules:
113
118
  continue
114
119
  structured_failures = 0
115
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 "",
125
+ )
116
126
  console.print(f"\n[bold]Step {step+1}:[/bold] {action.thought}")
117
127
 
118
128
  # Record the agent's own turn so subsequent steps see what it already did.
@@ -121,6 +131,7 @@ Rules:
121
131
  messages.append({"role": "assistant", "content": action.model_dump_json()})
122
132
 
123
133
  if action.finish:
134
+ logger.info("Native agent signaled completion for %s at step %d", task.id, step + 1)
124
135
  console.print("[green]Native agent signaled completion.[/green]")
125
136
  return ExecutionResult(success=True, message="Agent signaled completion; pending DevCouncil verification")
126
137
 
@@ -135,6 +146,7 @@ Rules:
135
146
 
136
147
  for tool_call in action.tool_calls:
137
148
  result_summary = ""
149
+ logger.debug("Native agent tool call: %s args=%s", tool_call.tool, list(tool_call.args))
138
150
  try:
139
151
  if tool_call.tool == "read_file":
140
152
  path = tool_call.args["path"]
@@ -183,10 +195,12 @@ Rules:
183
195
 
184
196
  messages.append({"role": "user", "content": f"[Tool Result] '{tool_call.tool}': {result_summary}"})
185
197
  except Exception as e:
198
+ logger.warning("Native agent tool %s failed for %s: %s", tool_call.tool, task.id, e)
186
199
  console.print(f"[red]Error executing tool {tool_call.tool}: {e}[/red]")
187
200
  if tool_call.tool == "apply_patch":
188
201
  consecutive_patch_failures += 1
189
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)
190
204
  return ExecutionResult(
191
205
  success=False,
192
206
  message=(
@@ -204,5 +218,6 @@ Rules:
204
218
  else:
205
219
  messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
206
220
 
221
+ logger.warning("Native agent reached max step limit (%d) for %s", MAX_AGENT_STEPS, task.id)
207
222
  console.print("[red]Native agent reached maximum step limit.[/red]")
208
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
 
@@ -1,8 +1,11 @@
1
+ import logging
1
2
  import re
2
3
  from typing import List
3
4
  from devcouncil.domain.gap import Gap
4
5
  from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
5
6
 
7
+ logger = logging.getLogger(__name__)
8
+
6
9
  # Captures the new-file starting line from a unified-diff hunk header (@@ -a,b +c,d @@).
7
10
  _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
8
11
 
@@ -32,6 +35,10 @@ class SecretScanner:
32
35
  for key_type, pattern in SECRET_PATTERNS.items():
33
36
  if pattern.search(line):
34
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
+ )
35
42
  gaps.append(Gap(
36
43
  id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{new_line_no}-{counter}",
37
44
  severity="critical",
@@ -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,6 +13,32 @@ 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]
@@ -60,10 +88,10 @@ def topological_order(tasks: List[Task]) -> List[Task]:
60
88
  indegree[task.id] += 1
61
89
  dependents[dep].append(task.id)
62
90
  # Kahn's algorithm, seeded in original order for stability.
63
- ready = [t.id for t in tasks if indegree[t.id] == 0]
91
+ ready = deque(t.id for t in tasks if indegree[t.id] == 0)
64
92
  ordered: List[str] = []
65
93
  while ready:
66
- current = ready.pop(0)
94
+ current = ready.popleft()
67
95
  ordered.append(current)
68
96
  for child in dependents[current]:
69
97
  indegree[child] -= 1
@@ -192,13 +220,16 @@ class GatePolicy:
192
220
  # earlier one (e.g. both add the same function), which then fails per-task
193
221
  # verification. Consolidating a file's work into one task avoids this.
194
222
  writers_by_file: dict[str, list[str]] = {}
223
+ writer_sets: dict[str, set[str]] = {}
195
224
  for task in tasks:
196
225
  for pf in task.planned_files:
197
226
  if pf.allowed_change in ("create", "modify", "delete"):
198
227
  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)
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)
202
233
  for path, owners in writers_by_file.items():
203
234
  if len(owners) > 1:
204
235
  gaps.append(Gap(
@@ -262,7 +293,7 @@ class GatePolicy:
262
293
  ))
263
294
 
264
295
  return GateResult(
265
- passed=len([g for g in gaps if g.blocking]) == 0,
296
+ passed=_log_gate("plan_approval", gaps),
266
297
  gaps=gaps
267
298
  )
268
299
 
@@ -305,7 +336,7 @@ class GatePolicy:
305
336
  ))
306
337
 
307
338
  return GateResult(
308
- passed=len([g for g in gaps if g.blocking]) == 0,
339
+ passed=_log_gate("task_ready", gaps, routine=True, task_id=task.id),
309
340
  gaps=gaps
310
341
  )
311
342