devcouncil 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -0,0 +1,186 @@
1
+ """`dev design` — lint, export, and inspect a project design.md design system.
2
+
3
+ Mirrors the upstream ``@google/design.md`` CLI's ``lint`` and ``export`` subcommands so a
4
+ DevCouncil project can validate its design tokens and convert them to CSS / Tailwind / W3C
5
+ Design Tokens. The same design.md is injected into coding-agent prompts (see
6
+ ``.devcouncil/knowledge/design``) so agents honor the system while they build.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from devcouncil.knowledge.design import export as export_design
18
+ from devcouncil.knowledge.design import lint as lint_design
19
+ from devcouncil.knowledge.design import parse_design_md
20
+ from devcouncil.knowledge.design_conformance import (
21
+ STYLE_EXTENSIONS,
22
+ scan_files,
23
+ )
24
+
25
+ app = typer.Typer(help="Lint, export, and inspect a design.md design system.")
26
+ console = Console()
27
+
28
+ # Where a project's design system is looked for, in order.
29
+ _DEFAULT_PATHS = (
30
+ ".devcouncil/knowledge/design/design.md",
31
+ "DESIGN.md",
32
+ "design.md",
33
+ )
34
+
35
+ # Directories pruned while auto-discovering style files (heavy / generated / vendored).
36
+ _PRUNE_DIRS = frozenset({
37
+ ".git", ".hg", ".svn", "node_modules", ".venv", "venv", "env",
38
+ "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", "coverage",
39
+ "__pycache__", ".mypy_cache", ".pytest_cache", ".cache", "vendor",
40
+ })
41
+ # Cap on auto-discovered files so an enormous repo can't make `check` run unbounded.
42
+ _MAX_DISCOVERED_FILES = 5000
43
+
44
+
45
+ def _discover_style_files(root: Path) -> list[Path]:
46
+ """Walk ``root`` for style-ish files, pruning heavy dirs and bounding the count."""
47
+ found: list[Path] = []
48
+ for dirpath, dirnames, filenames in os.walk(root):
49
+ dirnames[:] = [d for d in dirnames if d not in _PRUNE_DIRS and not d.startswith(".")]
50
+ for name in filenames:
51
+ if Path(name).suffix.lower() in STYLE_EXTENSIONS:
52
+ found.append(Path(dirpath) / name)
53
+ if len(found) >= _MAX_DISCOVERED_FILES:
54
+ return found
55
+ return found
56
+
57
+
58
+ def _resolve_path(explicit: Path | None, project_root: Path) -> Path | None:
59
+ if explicit is not None:
60
+ candidate = explicit.expanduser()
61
+ return candidate if candidate.is_file() else None
62
+ for rel in _DEFAULT_PATHS:
63
+ candidate = project_root / rel
64
+ if candidate.is_file():
65
+ return candidate
66
+ return None
67
+
68
+
69
+ @app.command("lint")
70
+ def lint(
71
+ path: Path = typer.Argument(None, help="Path to a design.md (defaults to the project's design system)."),
72
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root."),
73
+ ):
74
+ """Validate a design system: broken token refs, contrast, ordering, orphans."""
75
+ root = project_root.expanduser().resolve()
76
+ target = _resolve_path(path, root)
77
+ if target is None:
78
+ console.print("[red]No design.md found.[/red] Looked for: " + ", ".join(_DEFAULT_PATHS))
79
+ raise typer.Exit(code=1)
80
+
81
+ findings = lint_design(parse_design_md(target))
82
+ if not findings:
83
+ console.print(f"[green]✓ {target} passed all design.md lint rules.[/green]")
84
+ return
85
+
86
+ errors = [f for f in findings if f.severity == "error"]
87
+ color = {"error": "red", "warning": "yellow", "info": "cyan"}
88
+ console.print(f"[bold]{len(findings)} finding(s) in {target}:[/bold]")
89
+ for f in findings:
90
+ console.print(f" [{color.get(f.severity, 'white')}]{f.format()}[/{color.get(f.severity, 'white')}]")
91
+ if errors:
92
+ raise typer.Exit(code=1)
93
+
94
+
95
+ @app.command("export")
96
+ def export(
97
+ path: Path = typer.Argument(None, help="Path to a design.md (defaults to the project's design system)."),
98
+ fmt: str = typer.Option("css", "--format", "-f", help="Output format: css | tailwind | w3c."),
99
+ output: Path = typer.Option(None, "--output", "-o", help="Write to this file instead of stdout."),
100
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root."),
101
+ ):
102
+ """Export design tokens to CSS custom properties, a Tailwind config, or W3C tokens."""
103
+ root = project_root.expanduser().resolve()
104
+ if fmt not in ("css", "tailwind", "w3c"):
105
+ console.print(f"[red]Unknown format '{fmt}'.[/red] Use one of: css, tailwind, w3c.")
106
+ raise typer.Exit(code=2)
107
+ target = _resolve_path(path, root)
108
+ if target is None:
109
+ console.print("[red]No design.md found.[/red] Looked for: " + ", ".join(_DEFAULT_PATHS))
110
+ raise typer.Exit(code=1)
111
+
112
+ rendered = export_design(parse_design_md(target), fmt) # type: ignore[arg-type]
113
+ if output is not None:
114
+ out = output.expanduser().resolve()
115
+ out.parent.mkdir(parents=True, exist_ok=True)
116
+ out.write_text(rendered, encoding="utf-8")
117
+ console.print(f"[green]Wrote {fmt} tokens to[/green] {out}")
118
+ else:
119
+ typer.echo(rendered, nl=not rendered.endswith("\n"))
120
+
121
+
122
+ @app.command("check")
123
+ def check(
124
+ files: list[Path] = typer.Argument(
125
+ None, help="Files to check (defaults to the repo's style-ish files)."),
126
+ design: Path = typer.Option(
127
+ None, "--design", help="Path to a design.md (defaults to the project's design system)."),
128
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root."),
129
+ ):
130
+ """Flag hardcoded style literals (hex colors, px sizes) that bypass design tokens.
131
+
132
+ Exits non-zero when any violation is found so it can gate CI / a pre-commit hook.
133
+ """
134
+ root = project_root.expanduser().resolve()
135
+ target = _resolve_path(design, root)
136
+ if target is None:
137
+ console.print("[red]No design.md found.[/red] Looked for: " + ", ".join(_DEFAULT_PATHS))
138
+ raise typer.Exit(code=1)
139
+
140
+ ds = parse_design_md(target)
141
+ paths = [f.expanduser() for f in files] if files else _discover_style_files(root)
142
+ violations = scan_files(paths, ds)
143
+
144
+ if not violations:
145
+ scanned = len([p for p in paths if p.suffix.lower() in STYLE_EXTENSIONS])
146
+ console.print(
147
+ f"[green]✓ No design-token violations in {scanned} file(s) "
148
+ f"(tokens from {target}).[/green]")
149
+ return
150
+
151
+ by_file: dict[str, list] = {}
152
+ for v in violations:
153
+ by_file.setdefault(v.file or "<text>", []).append(v)
154
+
155
+ console.print(
156
+ f"[bold red]{len(violations)} design-token violation(s) "
157
+ f"in {len(by_file)} file(s):[/bold red]")
158
+ for fname in sorted(by_file):
159
+ console.print(f"[bold]{fname}[/bold]")
160
+ for v in sorted(by_file[fname], key=lambda x: (x.line, x.kind)):
161
+ console.print(
162
+ f" [yellow]{v.line}[/yellow] [{v.kind}] {v.message}\n"
163
+ f" [dim]{v.snippet}[/dim]")
164
+ raise typer.Exit(code=1)
165
+
166
+
167
+ @app.command("show")
168
+ def show(
169
+ path: Path = typer.Argument(None, help="Path to a design.md (defaults to the project's design system)."),
170
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root."),
171
+ ):
172
+ """Summarize a design system: token counts and document sections."""
173
+ root = project_root.expanduser().resolve()
174
+ target = _resolve_path(path, root)
175
+ if target is None:
176
+ console.print("[red]No design.md found.[/red] Looked for: " + ", ".join(_DEFAULT_PATHS))
177
+ raise typer.Exit(code=1)
178
+
179
+ ds = parse_design_md(target)
180
+ console.print(f"[bold]{ds.name or target.name}[/bold] ({target})")
181
+ console.print(
182
+ f" colors={len(ds.colors)} typography={len(ds.typography)} "
183
+ f"rounded={len(ds.rounded)} spacing={len(ds.spacing)} components={len(ds.components)}"
184
+ )
185
+ if ds.sections:
186
+ console.print(" sections: " + ", ".join(h for h, _ in ds.sections))
@@ -80,7 +80,156 @@ def _ollama_model_present(model: str, pulled: set[str]) -> bool:
80
80
  return any(tag in candidates or tag.split(":", 1)[0] == base and ":" not in model for tag in pulled)
81
81
 
82
82
 
83
+ def _knowledge_dir(project_root: Path, config=None) -> str:
84
+ """Configured knowledge directory (honors ``knowledge.directory``), best-effort.
85
+
86
+ Mirrors ``cli.commands.okf._knowledge_okf_dir`` so doctor inspects the same location
87
+ ingest writes to. Any config failure falls back to the documented default rather than
88
+ raising — doctor must keep running even with a broken config.
89
+
90
+ ``config`` is an optional pre-loaded config (default ``None`` loads as before), so a
91
+ single doctor invocation can reuse one ``load_config`` call across checks.
92
+ """
93
+ directory = ".devcouncil/knowledge"
94
+ try:
95
+ cfg = config if config is not None else load_config(project_root)
96
+ directory = cfg.knowledge.directory
97
+ except Exception:
98
+ pass
99
+ return directory
100
+
101
+
102
+ def check_ingested_knowledge(project_root: Path, config=None) -> list[tuple[str, str, str]]:
103
+ """Best-effort health rows for ingested knowledge under ``<knowledge dir>/{okf,design}``.
104
+
105
+ Returns ``(component, status_markup, notes)`` rows for the doctor table. This is the
106
+ most common "ingested but silently broken" surface: an OKF bundle with a dangling
107
+ cross-link, or a design.md with broken token references, both validate clean to the
108
+ eye but degrade the prompt context. We therefore read+validate every ingested bundle
109
+ and lint the design system, reporting counts and any problems.
110
+
111
+ Never raises: any knowledge-layer failure becomes a ``WARN`` row, and an empty
112
+ knowledge area yields a neutral ``INFO`` row — a project that never ingested knowledge
113
+ is not a misconfiguration.
114
+ """
115
+ ok = "[green]OK[/green]"
116
+ warn = "[yellow]WARN[/yellow]"
117
+ info = "[cyan]INFO[/cyan]"
118
+ rows: list[tuple[str, str, str]] = []
119
+
120
+ directory = _knowledge_dir(project_root, config=config)
121
+ base = project_root / directory
122
+ okf_area = base / "okf"
123
+ design_md = base / "design" / "design.md"
124
+
125
+ # Identify ingested OKF bundles. `dev okf ingest` writes each bundle into its own
126
+ # subfolder under okf/; treat each such subdir as a bundle. If documents sit loose
127
+ # directly under okf/ (and there are no subfolder bundles), treat okf/ as one bundle.
128
+ # Choosing one or the other avoids double-counting documents via rglob.
129
+ bundle_dirs: list[Path] = []
130
+ if okf_area.is_dir():
131
+ try:
132
+ subdir_bundles = [
133
+ child
134
+ for child in sorted(okf_area.iterdir())
135
+ if child.is_dir() and any(child.rglob("*.md"))
136
+ ]
137
+ except Exception:
138
+ subdir_bundles = []
139
+ loose_docs = any(p.is_file() for p in okf_area.glob("*.md"))
140
+ if subdir_bundles:
141
+ bundle_dirs = subdir_bundles
142
+ elif loose_docs:
143
+ bundle_dirs = [okf_area]
144
+
145
+ has_design = design_md.is_file()
146
+
147
+ # Nothing ingested at all → neutral info line, not a failure.
148
+ if not bundle_dirs and not has_design:
149
+ rows.append(
150
+ (
151
+ "Ingested knowledge",
152
+ info,
153
+ f"No ingested knowledge under {directory}/ (okf/, design/). "
154
+ "Add some with 'dev okf ingest <bundle>'.",
155
+ )
156
+ )
157
+ return rows
158
+
159
+ # --- OKF bundles -----------------------------------------------------------------
160
+ if bundle_dirs:
161
+ from devcouncil.knowledge.okf import read_bundle, validate_bundle
162
+
163
+ total_docs = 0
164
+ problems: list[str] = []
165
+ for bdir in bundle_dirs:
166
+ try:
167
+ bundle = read_bundle(bdir)
168
+ total_docs += len(bundle.documents)
169
+ problems.extend(validate_bundle(bundle))
170
+ except Exception as exc: # never let a malformed bundle crash doctor
171
+ problems.append(f"{bdir.name}: failed to read bundle ({exc})")
172
+ summary = f"{len(bundle_dirs)} bundle(s), {total_docs} document(s)"
173
+ if problems:
174
+ preview = "; ".join(problems[:5])
175
+ extra = "" if len(problems) <= 5 else f" (+{len(problems) - 5} more)"
176
+ rows.append(
177
+ (
178
+ "Ingested OKF",
179
+ warn,
180
+ f"{summary}: {len(problems)} validation problem(s): {preview}{extra}.",
181
+ )
182
+ )
183
+ else:
184
+ rows.append(("Ingested OKF", ok, f"{summary}; no validation problems."))
185
+
186
+ # --- Design system ----------------------------------------------------------------
187
+ if has_design:
188
+ from devcouncil.knowledge.design import lint, parse_design_md
189
+
190
+ try:
191
+ findings = lint(parse_design_md(design_md))
192
+ except Exception as exc: # never let a malformed design.md crash doctor
193
+ rows.append(("Ingested design.md", warn, f"present, but lint failed: {exc}."))
194
+ else:
195
+ if findings:
196
+ preview = "; ".join(f.format() for f in findings[:5])
197
+ extra = "" if len(findings) <= 5 else f" (+{len(findings) - 5} more)"
198
+ rows.append(
199
+ (
200
+ "Ingested design.md",
201
+ warn,
202
+ f"present; {len(findings)} lint finding(s): {preview}{extra}.",
203
+ )
204
+ )
205
+ else:
206
+ rows.append(("Ingested design.md", ok, "present; 0 lint findings."))
207
+
208
+ return rows
209
+
210
+
211
+ def _add_logging_row(table, project_root: Path) -> None:
212
+ """Append a logging-health row: where the durable run log lives and how big it
213
+ is, so a user chasing a recurring failure knows exactly where to look."""
214
+ from devcouncil.telemetry.logging_setup import LOG_RELATIVE_PATH
215
+
216
+ log_path = project_root / LOG_RELATIVE_PATH
217
+ if log_path.exists():
218
+ size_kb = log_path.stat().st_size / 1024
219
+ detail = f"{log_path} ({size_kb:.0f} KB). View: dev logs tail"
220
+ else:
221
+ detail = f"Will write to {log_path} on first command. View: dev logs tail"
222
+ table.add_row("logging", "[green]OK[/green]", detail)
223
+
224
+
83
225
  def render_doctor_check(project_root: Path = Path(".")):
226
+ # Load config once for the whole invocation; the diagnostic checks below reuse
227
+ # this instead of re-reading config.yaml. None falls back to per-check loading.
228
+ try:
229
+ config = load_config(project_root)
230
+ except Exception:
231
+ config = None
232
+
84
233
  def _command_version(command: list[str]) -> str | None:
85
234
  executable = shutil.which(command[0])
86
235
  if not executable:
@@ -169,8 +318,13 @@ def render_doctor_check(project_root: Path = Path(".")):
169
318
  "No built-in coding CLI on PATH. Run dev integrate recommend after installing one.",
170
319
  )
171
320
 
321
+ # Ingested-knowledge health (added before the provider branch so it appears on every
322
+ # code path, including the early returns for ollama / unsupported providers).
323
+ for component, status, notes in check_ingested_knowledge(project_root, config=config):
324
+ table.add_row(component, status, notes)
325
+
172
326
  try:
173
- provider = load_config(project_root).models.provider
327
+ provider = config.models.provider if config is not None else "openrouter"
174
328
  except Exception:
175
329
  provider = "openrouter"
176
330
  try:
@@ -182,6 +336,7 @@ def render_doctor_check(project_root: Path = Path(".")):
182
336
  "[red]Unsupported[/red]",
183
337
  f"{provider} is configured, but this runtime supports: {supported}.",
184
338
  )
339
+ _add_logging_row(table, project_root)
185
340
  console.print(table)
186
341
  return
187
342
  if provider == "ollama":
@@ -218,10 +373,10 @@ def render_doctor_check(project_root: Path = Path(".")):
218
373
 
219
374
  # A reachable server with the configured model NOT pulled is the most common
220
375
  # "all-green doctor, 404 on first call" trap. Verify the role models exist locally.
221
- if reachable:
376
+ if reachable and config is not None:
222
377
  try:
223
378
  configured_models = sorted(
224
- {role.model for role in load_config(project_root).models.roles.values() if role.model}
379
+ {role.model for role in config.models.roles.values() if role.model}
225
380
  )
226
381
  except Exception:
227
382
  configured_models = []
@@ -285,6 +440,7 @@ def render_doctor_check(project_root: Path = Path(".")):
285
440
  f"(dev setup --provider ollama --model {host.recommended_ollama_model}).",
286
441
  )
287
442
 
443
+ _add_logging_row(table, project_root)
288
444
  console.print(table)
289
445
  return
290
446
  env_var = provider_api_key_env_var(provider)
@@ -319,6 +475,7 @@ def render_doctor_check(project_root: Path = Path(".")):
319
475
  location = os.environ.get("VERTEXAI_LOCATION") or local_secrets.get("VERTEXAI_LOCATION", "global")
320
476
  table.add_row("VERTEXAI_LOCATION", "[green]OK[/green]", location)
321
477
 
478
+ _add_logging_row(table, project_root)
322
479
  console.print(table)
323
480
 
324
481
 
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import hashlib
3
+ import logging
3
4
  import subprocess
4
5
  from pathlib import Path
5
6
  from types import SimpleNamespace
@@ -7,6 +8,9 @@ from types import SimpleNamespace
7
8
  import typer
8
9
  from rich.console import Console
9
10
 
11
+ from devcouncil.telemetry.stages import log_stage, log_step
12
+ from devcouncil.telemetry.logging_setup import set_log_dir
13
+
10
14
  from devcouncil.app.config import load_config
11
15
  from devcouncil.cli.commands import plan as plan_command
12
16
  from devcouncil.cli.commands import report as report_command
@@ -31,6 +35,7 @@ from devcouncil.reporting.report_builder import ReportBuilder
31
35
 
32
36
 
33
37
  console = Console()
38
+ logger = logging.getLogger(__name__)
34
39
 
35
40
  SUPPORTED_EXECUTORS = {
36
41
  *BUILTIN_CODING_EXECUTOR_NAMES,
@@ -104,7 +109,22 @@ def _blocking_gap_signature(root: Path, task_id: str) -> str:
104
109
  if not db:
105
110
  return ""
106
111
  with db.get_session() as session:
107
- gaps = [g for g in GapRepository(session).get_all() if g.task_id == task_id and g.blocking]
112
+ gaps = GapRepository(session).get_blocking_for_task(task_id)
113
+ key = "\n".join(sorted(f"{g.gap_type}:{g.description}" for g in gaps))
114
+ return hashlib.sha1(key.encode("utf-8")).hexdigest() if key else ""
115
+
116
+
117
+ def _remediable_incomplete_signature(root: Path, task_id: str) -> str:
118
+ """Fingerprint of a task's remediable "incomplete" gaps (unproven acceptance criteria
119
+ the executor could still prove), for driving and no-progress-checking the repair loop
120
+ when the task verified without a hard block but isn't actually done."""
121
+ from devcouncil.planning.correction_manifest import remediable_incomplete_gaps
122
+
123
+ db = get_db(root)
124
+ if not db:
125
+ return ""
126
+ with db.get_session() as session:
127
+ gaps = remediable_incomplete_gaps(GapRepository(session).get_for_task(task_id))
108
128
  key = "\n".join(sorted(f"{g.gap_type}:{g.description}" for g in gaps))
109
129
  return hashlib.sha1(key.encode("utf-8")).hexdigest() if key else ""
110
130
 
@@ -122,7 +142,7 @@ def _build_repair_service(root: Path):
122
142
  config = load_config(root)
123
143
  validate_model_provider(config.models.provider)
124
144
  api_key = get_api_key(config.models.provider, root)
125
- provider = create_provider(config.models.provider, api_key, project_root=root)
145
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
126
146
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
127
147
  return RepairService(ModelRouter(provider, role_config, project_root=root))
128
148
  except Exception:
@@ -138,6 +158,7 @@ def _execute_task_with_repair(
138
158
  stream: bool,
139
159
  max_repairs: int,
140
160
  repair_service,
161
+ config=None,
141
162
  ) -> tuple[str, int]:
142
163
  """Run a task, then self-repair in a bounded loop until it verifies or the budget
143
164
  is exhausted. Returns ``(final_status, repair_attempts_used)``.
@@ -174,16 +195,26 @@ def _execute_task_with_repair(
174
195
 
175
196
  _run_once()
176
197
  status = _task_status(root, task.id)
198
+ logger.info("Initial run of %s finished as %s (max_repairs=%d)", task.id, status, max_repairs)
177
199
 
178
200
  attempt = 0
179
201
  last_signature: str | None = None
180
- while status not in {"verified", "done"} and attempt < max_repairs:
181
- signature = _blocking_gap_signature(root, task.id)
202
+ while attempt < max_repairs:
203
+ blocked = status not in {"verified", "done"}
204
+ # When the task is blocked, repair against its blocking gaps. When it "verified"
205
+ # without a hard block but is still INCOMPLETE (an acceptance criterion the
206
+ # executor could prove has no passing evidence), keep repairing too — otherwise
207
+ # arm B stalls one proof short of done (the eval_rpn 5/7-incomplete case).
208
+ signature = (
209
+ _blocking_gap_signature(root, task.id) if blocked
210
+ else _remediable_incomplete_signature(root, task.id)
211
+ )
182
212
  if not signature:
183
- # Blocked without recorded blocking gaps (e.g. the executor failed to
184
- # start) there is nothing concrete to repair against, so stop.
213
+ # Nothing concrete to repair: truly done, or blocked with no recorded gaps
214
+ # (e.g. the executor failed to start).
185
215
  break
186
216
  if signature == last_signature:
217
+ logger.warning("%s: repair made no progress (identical gaps) after attempt %d; stopping loop", task.id, attempt)
187
218
  console.print(
188
219
  f"[yellow]{task.id}: repair made no progress (identical blocking gaps); "
189
220
  "stopping the self-repair loop.[/yellow]"
@@ -202,18 +233,23 @@ def _execute_task_with_repair(
202
233
  if _commit_task_changes(root, task.id, status):
203
234
  intermediate_commits += 1
204
235
 
205
- manifest_path = write_correction_manifest(root, task.id, repair_service=repair_service)
236
+ manifest_path = write_correction_manifest(
237
+ root, task.id, repair_service=repair_service, config=config, include_incomplete=True
238
+ )
206
239
  if manifest_path is None:
207
240
  break
208
241
  attempt += 1
242
+ logger.info("Self-repair attempt %d/%d for %s (was %s); manifest=%s", attempt, max_repairs, task.id, status, manifest_path)
209
243
  console.print(
210
244
  f"\n[bold]Self-repair attempt {attempt}/{max_repairs}[/bold] for "
211
245
  f"[bold]{task.id}[/bold] (was {status})..."
212
246
  )
213
247
  _run_once()
214
248
  status = _task_status(root, task.id)
249
+ logger.info("After repair attempt %d, %s is now %s", attempt, task.id, status)
215
250
 
216
251
  if status not in {"verified", "done"} and attempt >= max_repairs and max_repairs > 0:
252
+ logger.warning("%s: gave up after %d repair attempt(s); still %s", task.id, attempt, status)
217
253
  console.print(
218
254
  f"[yellow]{task.id}: gave up after {attempt} repair attempt(s); still {status}.[/yellow]"
219
255
  )
@@ -224,6 +260,7 @@ def _execute_task_with_repair(
224
260
  # isn't lost and the final reconciliation pass still sees committed changes.
225
261
  if status in {"verified", "done"} and squash_base and intermediate_commits:
226
262
  if _squash_repair_commits(root, task.id, squash_base, status):
263
+ logger.info("Squashed %d blocked attempt commit(s) for %s into one verified commit", intermediate_commits, task.id)
227
264
  console.print(
228
265
  f"[dim]Squashed {intermediate_commits} blocked attempt commit(s) for "
229
266
  f"{task.id} into one verified commit.[/dim]"
@@ -463,6 +500,11 @@ def go(
463
500
  Run the full DevCouncil loop in one command.
464
501
  """
465
502
  root = project_root.expanduser().resolve()
503
+ set_log_dir(root)
504
+ logger.info(
505
+ "dev go starting: goal=%r executor=%s quick=%s force=%s root=%s",
506
+ goal, executor, quick, force, root,
507
+ )
466
508
  initialize_project(root, quiet=True)
467
509
 
468
510
  # A goal like "#142" or a GitHub issue/PR URL is a reference, not a spec —
@@ -500,7 +542,8 @@ def go(
500
542
 
501
543
  console.print(f"[bold]Planning goal:[/bold] {goal}")
502
544
  try:
503
- planned_task_ids = asyncio.run(plan_command.run_plan_flow(goal, dry_run=dry_run, persist=True, project_root=root, quick=quick))
545
+ with log_stage("plan", project_root=root, quick=quick, dry_run=dry_run):
546
+ planned_task_ids = asyncio.run(plan_command.run_plan_flow(goal, dry_run=dry_run, persist=True, project_root=root, quick=quick))
504
547
  except (ProviderRequestError, StructuredOutputError) as exc:
505
548
  plan_command.print_planning_error(exc)
506
549
  raise typer.Exit(code=1)
@@ -513,6 +556,7 @@ def go(
513
556
  # anyway and proceed — verification still gates each task's actual diff.
514
557
  if not task_ids:
515
558
  if force:
559
+ logger.info("No auto-approved tasks; force-approving generated plan past planning gaps")
516
560
  try:
517
561
  plan_command.approve(run_id=None, force=True, project_root=root)
518
562
  except SystemExit:
@@ -526,6 +570,7 @@ def go(
526
570
  else:
527
571
  tasks = []
528
572
  if not tasks:
573
+ logger.warning("Planning produced no approved tasks; aborting run")
529
574
  console.print("[red]Planning did not produce any approved tasks.[/red]")
530
575
  console.print(
531
576
  "Review gaps with [bold]dev status[/bold], then run [bold]dev approve[/bold] "
@@ -548,11 +593,21 @@ def go(
548
593
  # the edits), so the repair loop only applies to automated runs.
549
594
  max_repairs = _max_repair_attempts(root) if normalized_executor != "manual" else 0
550
595
  repair_service = _build_repair_service(root) if max_repairs else None
596
+ # Load config once for the repair loop so the correction-manifest builder doesn't
597
+ # reload it from disk on every repair attempt. Only needed when the loop is active;
598
+ # the builder falls back to loading config itself if this is None.
599
+ repair_config = load_config(root) if max_repairs else None
551
600
  # Run tasks in dependency order so a task never executes before the tasks it needs.
552
601
  tasks = topological_order(tasks)
602
+ log_step(
603
+ f"execution plan: {len(tasks)} task(s) in dependency order",
604
+ project_root=root,
605
+ order=[t.id for t in tasks],
606
+ )
553
607
  completed_ids = {task.id for task in tasks if task.status in {"verified", "done"}}
554
608
  for task in tasks:
555
609
  if task.status in {"verified", "done"}:
610
+ logger.info("Skipping %s; already %s", task.id, task.status)
556
611
  console.print(f"[green]Skipping {task.id}; already {task.status}.[/green]")
557
612
  completed_ids.add(task.id)
558
613
  continue
@@ -562,6 +617,7 @@ def go(
562
617
  # unsatisfiable precondition. Skip it and surface why.
563
618
  unmet = [dep for dep in task.depends_on if dep not in completed_ids]
564
619
  if unmet:
620
+ logger.warning("Skipping %s: upstream %s not completed", task.id, ", ".join(unmet))
565
621
  console.print(f"[yellow]Skipping {task.id}: upstream {', '.join(unmet)} not completed.[/yellow]")
566
622
  failed.append(f"{task.id} (skipped: upstream {', '.join(unmet)} unsatisfied)")
567
623
  continue
@@ -570,14 +626,29 @@ def go(
570
626
  executed_task_ids.append(task.id)
571
627
  # Run, then self-repair in a bounded loop (closes the autonomous loop: the
572
628
  # one-shot executor no longer needs a human to run `dev repair` and re-run).
573
- latest_status, repairs_used = _execute_task_with_repair(
574
- root,
575
- task,
629
+ with log_stage(
630
+ "execute_task",
631
+ project_root=root,
632
+ task_id=task.id,
576
633
  executor=normalized_executor,
577
- profile=profile,
578
- stream=stream,
579
634
  max_repairs=max_repairs,
580
- repair_service=repair_service,
635
+ ):
636
+ latest_status, repairs_used = _execute_task_with_repair(
637
+ root,
638
+ task,
639
+ executor=normalized_executor,
640
+ profile=profile,
641
+ stream=stream,
642
+ max_repairs=max_repairs,
643
+ repair_service=repair_service,
644
+ config=repair_config,
645
+ )
646
+ log_step(
647
+ f"task {task.id} finished as {latest_status}",
648
+ project_root=root,
649
+ task_id=task.id,
650
+ repairs_used=repairs_used,
651
+ trace=True,
581
652
  )
582
653
 
583
654
  # Commit whatever this task produced so the next task in the plan starts
@@ -585,6 +656,7 @@ def go(
585
656
  # tree and the whole multi-task plan stalls after task one.
586
657
  if _commit_task_changes(root, task.id, latest_status):
587
658
  note = f" after {repairs_used} repair attempt(s)" if repairs_used else ""
659
+ logger.info("Committed %s changes (%s)%s", task.id, latest_status, note)
588
660
  console.print(f"[dim]Committed {task.id} changes ({latest_status}){note}.[/dim]")
589
661
 
590
662
  if latest_status in {"verified", "done"}:
@@ -595,8 +667,10 @@ def go(
595
667
  # don't let one task that blocked or could not start halt the rest. The
596
668
  # final reconciliation pass judges the integrated result fairly.
597
669
  if not continue_on_blocked:
670
+ logger.warning("Stopping run: %s ended as %s (no --continue-on-blocked)", task.id, latest_status)
598
671
  console.print(f"[red]Stopping because {task.id} ended as {latest_status}.[/red]")
599
672
  break
673
+ logger.info("%s ended as %s; continuing to next task (--continue-on-blocked)", task.id, latest_status)
600
674
  console.print(f"[yellow]{task.id} ended as {latest_status}; continuing to the next task.[/yellow]")
601
675
 
602
676
  if not executed_task_ids:
@@ -605,10 +679,13 @@ def go(
605
679
  # Final reconciliation: re-verify every task against the fully integrated,
606
680
  # committed state. Earlier tasks are verified before later tasks create shared
607
681
  # test files, so their gates can pass now even though they blocked mid-run.
608
- # The LLM review is diff-gated and the tree is clean here, so this costs no
609
- # model calls it just refreshes statuses/gaps so the final report is honest.
682
+ # The tree is clean here, so verification uses each task's committed checkpoint
683
+ # diff to prove its acceptance criteria (rather than skipping on an empty diff and
684
+ # wrongly blocking). Re-running the same diff is largely an LLM-cache hit, so this
685
+ # refreshes statuses/gaps cheaply for an honest final report.
610
686
  if executed_task_ids and _is_git_repo(root):
611
687
  console.print("\n[bold]Reconciling verification against the final integrated state...[/bold]")
688
+ log_step("reconcile: re-verifying against integrated state", project_root=root)
612
689
  try:
613
690
  verify_command.verify(task_id=None, sandbox="local", json_format=True, project_root=root)
614
691
  except typer.Exit:
@@ -632,10 +709,13 @@ def go(
632
709
  failed.append(f"{planned.id} ({status})")
633
710
 
634
711
  if not failed:
712
+ logger.info("dev go complete: all tasks finished")
635
713
  _record_project_done(root)
636
714
  else:
715
+ logger.warning("dev go finished with %d unfinished task(s): %s", len(failed), ", ".join(failed))
637
716
  _record_project_blocked(root)
638
717
 
718
+ log_step("generating final report", project_root=root)
639
719
  console.print("\n[bold]Final DevCouncil report[/bold]")
640
720
  report_command.report(
641
721
  SimpleNamespace(invoked_subcommand=None), # type: ignore[arg-type]