devcouncil 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,4 +1,5 @@
1
1
  from pathlib import Path
2
+ import webbrowser
2
3
 
3
4
  import typer
4
5
  from rich.console import Console
@@ -15,6 +16,7 @@ def dashboard(
15
16
  ctx: typer.Context,
16
17
  host: str = typer.Option("127.0.0.1", "--host", help="Dashboard bind host."),
17
18
  port: int = typer.Option(8765, "--port", help="Dashboard bind port."),
19
+ open_browser: bool = typer.Option(False, "--open", help="Open the dashboard URL in the default browser before serving."),
18
20
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
19
21
  ):
20
22
  """Serve a local live dashboard with project status, tasks, coverage, and traces."""
@@ -22,5 +24,8 @@ def dashboard(
22
24
  return
23
25
  root = project_root.expanduser().resolve()
24
26
  initialize_project(root, quiet=True)
25
- console.print(f"Serving DevCouncil dashboard at http://{host}:{port}")
27
+ url = f"http://{host}:{port}"
28
+ console.print(f"Serving DevCouncil dashboard at {url}")
29
+ if open_browser:
30
+ webbrowser.open(url)
26
31
  run_dashboard(root, host=host, port=port)
@@ -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))
@@ -6,13 +6,230 @@ from pathlib import Path
6
6
  from rich.console import Console
7
7
  from rich.table import Table
8
8
 
9
- from devcouncil.app.config import load_config, load_local_secrets, provider_api_key_env_var
9
+ from devcouncil.app.config import get_gcloud_access_token, load_config, load_local_secrets, provider_api_key_env_var
10
+ from devcouncil.executors.agent_registry import (
11
+ CODING_CLI_INTEGRATION_INFO,
12
+ CODING_CLI_PROBE_ORDER,
13
+ CODING_CLI_VERSION_COMMANDS,
14
+ detect_available_coding_cli,
15
+ resolve_automated_executor,
16
+ )
10
17
  from devcouncil.llm.provider import SUPPORTED_MODEL_PROVIDERS, validate_model_provider
11
18
 
12
19
  app = typer.Typer()
13
20
  console = Console()
14
21
 
22
+
23
+ def _probe_ollama(base_url: str) -> tuple[bool, str]:
24
+ """Best-effort reachability check for a local Ollama server.
25
+
26
+ ``base_url`` carries the OpenAI-compatible ``/v1`` suffix; the native
27
+ ``/api/version`` endpoint lives one level up. Returns (reachable, detail);
28
+ any failure is reported, never raised.
29
+ """
30
+ root = base_url.rstrip("/")
31
+ if root.endswith("/v1"):
32
+ root = root[: -len("/v1")].rstrip("/")
33
+ try:
34
+ import httpx
35
+
36
+ resp = httpx.get(f"{root}/api/version", timeout=3.0)
37
+ if resp.status_code < 400:
38
+ version = ""
39
+ try:
40
+ version = (resp.json() or {}).get("version", "")
41
+ except Exception:
42
+ version = ""
43
+ return True, f"Reachable at {root}" + (f" (v{version})." if version else ".")
44
+ return False, f"Server at {root} returned HTTP {resp.status_code}."
45
+ except Exception:
46
+ return False, f"No Ollama server reachable at {root}."
47
+
48
+
49
+ def _probe_ollama_models(base_url: str) -> tuple[bool, set[str]]:
50
+ """Best-effort list of locally-pulled Ollama model tags via native ``/api/tags``.
51
+
52
+ Returns (queried_ok, names). ``names`` holds the reported tags (e.g.
53
+ ``qwen2.5-coder:7b``). Any failure returns (False, set()) and is never raised — a
54
+ green liveness probe with no pulled model is the most common "configured but doesn't
55
+ work" trap, so this turns it into an actionable row."""
56
+ root = base_url.rstrip("/")
57
+ if root.endswith("/v1"):
58
+ root = root[: -len("/v1")].rstrip("/")
59
+ try:
60
+ import httpx
61
+
62
+ resp = httpx.get(f"{root}/api/tags", timeout=3.0)
63
+ if resp.status_code >= 400:
64
+ return False, set()
65
+ models = (resp.json() or {}).get("models", []) or []
66
+ names = {str(m.get("name", "")).strip() for m in models if m.get("name")}
67
+ return True, {n for n in names if n}
68
+ except Exception:
69
+ return False, set()
70
+
71
+
72
+ def _ollama_model_present(model: str, pulled: set[str]) -> bool:
73
+ """Whether ``model`` is among the pulled tags, tolerant of the implicit ``:latest``
74
+ tag Ollama adds to untagged models."""
75
+ if model in pulled:
76
+ return True
77
+ base = model.split(":", 1)[0]
78
+ # configured "qwen2.5-coder" matches a pulled "qwen2.5-coder:latest", and vice versa.
79
+ candidates = {model, f"{model}:latest", base, f"{base}:latest"}
80
+ return any(tag in candidates or tag.split(":", 1)[0] == base and ":" not in model for tag in pulled)
81
+
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
+
15
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
+
16
233
  def _command_version(command: list[str]) -> str | None:
17
234
  executable = shutil.which(command[0])
18
235
  if not executable:
@@ -66,37 +283,48 @@ def render_doctor_check(project_root: Path = Path(".")):
66
283
  else:
67
284
  table.add_row("ripgrep (rg)", "[yellow]Missing[/yellow]", "ripgrep is highly recommended for fast repo mapping.")
68
285
 
69
- # Check supported coding CLIs
70
- codex_ver = _command_version(["codex", "--version"])
71
- if codex_ver:
72
- table.add_row(
73
- "Codex CLI",
74
- "[green]OK[/green]",
75
- f"{codex_ver}. Setup: dev integrate codex --apply (or dev setup --integrate --apply).",
76
- )
77
- else:
78
- table.add_row(
79
- "Codex CLI",
80
- "[yellow]Missing[/yellow]",
81
- "Optional. Install Codex, then run 'dev integrate codex --apply' (or 'dev setup --integrate --apply').",
82
- )
286
+ # Check supported coding CLIs (driven by the agent registry, so new
287
+ # built-in agents show up here without doctor edits).
288
+ for name in CODING_CLI_PROBE_ORDER:
289
+ info = CODING_CLI_INTEGRATION_INFO.get(name)
290
+ if info is None:
291
+ continue
292
+ version = None
293
+ for probe in CODING_CLI_VERSION_COMMANDS.get(name, ()):
294
+ version = _command_version(list(probe))
295
+ if version:
296
+ break
297
+ if version:
298
+ table.add_row(info.label, "[green]OK[/green]", f"{version}. Setup: {info.notes}.")
299
+ else:
300
+ table.add_row(
301
+ info.label,
302
+ "[yellow]Missing[/yellow]",
303
+ f"Optional. Install {info.label}, then use: {info.notes}.",
304
+ )
83
305
 
84
- gemini_ver = _command_version(["gemini", "--version"])
85
- if gemini_ver:
306
+ detected = detect_available_coding_cli(project_root)
307
+ resolved = resolve_automated_executor(project_root, None)
308
+ if detected:
86
309
  table.add_row(
87
- "Gemini CLI",
310
+ "Recommended coding CLI",
88
311
  "[green]OK[/green]",
89
- f"{gemini_ver}. Setup: dev integrate gemini --apply (or dev setup --integrate --apply).",
312
+ f"Use --executor {resolved} for dev go / dev run (detected on PATH).",
90
313
  )
91
314
  else:
92
315
  table.add_row(
93
- "Gemini CLI",
316
+ "Recommended coding CLI",
94
317
  "[yellow]Missing[/yellow]",
95
- "Optional. Install Gemini CLI, then run 'dev integrate gemini --apply' (or 'dev setup --integrate --apply').",
318
+ "No built-in coding CLI on PATH. Run dev integrate recommend after installing one.",
96
319
  )
97
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
+
98
326
  try:
99
- provider = load_config(project_root).models.provider
327
+ provider = config.models.provider if config is not None else "openrouter"
100
328
  except Exception:
101
329
  provider = "openrouter"
102
330
  try:
@@ -108,6 +336,111 @@ def render_doctor_check(project_root: Path = Path(".")):
108
336
  "[red]Unsupported[/red]",
109
337
  f"{provider} is configured, but this runtime supports: {supported}.",
110
338
  )
339
+ _add_logging_row(table, project_root)
340
+ console.print(table)
341
+ return
342
+ if provider == "ollama":
343
+ # Use the provider's own resolver so the displayed URL reflects OLLAMA_HOST
344
+ # (with scheme/-/v1 normalization), not just OLLAMA_BASE_URL.
345
+ from devcouncil.execution.prompt_builder import MAX_PROMPT_CHARS
346
+ from devcouncil.llm.provider import OllamaProvider
347
+
348
+ base_url = OllamaProvider._resolve_base_url()
349
+ num_ctx = OllamaProvider._resolve_num_ctx()
350
+ # DevCouncil's planning prompts reach ~MAX_PROMPT_CHARS chars (~4 chars/token);
351
+ # recommend a context window that covers the prompt plus headroom for output.
352
+ recommended_ctx = 16384
353
+ min_ctx = max(8192, (MAX_PROMPT_CHARS // 4))
354
+ table.add_row(
355
+ "OLLAMA",
356
+ "[green]OK[/green]",
357
+ f"Local provider; no API key required (server: {base_url}).",
358
+ )
359
+
360
+ # Is the local Ollama server actually up? A native /api/version probe is
361
+ # cheap and turns the most common failure ("provider configured but
362
+ # `ollama serve` not running") into an actionable row instead of a
363
+ # connection traceback on the first model call.
364
+ reachable, detail = _probe_ollama(base_url)
365
+ if reachable:
366
+ table.add_row("OLLAMA server", "[green]OK[/green]", detail)
367
+ else:
368
+ table.add_row(
369
+ "OLLAMA server",
370
+ "[yellow]WARN[/yellow]",
371
+ f"{detail} Start it with 'ollama serve' (or 'brew services start ollama').",
372
+ )
373
+
374
+ # A reachable server with the configured model NOT pulled is the most common
375
+ # "all-green doctor, 404 on first call" trap. Verify the role models exist locally.
376
+ if reachable and config is not None:
377
+ try:
378
+ configured_models = sorted(
379
+ {role.model for role in config.models.roles.values() if role.model}
380
+ )
381
+ except Exception:
382
+ configured_models = []
383
+ queried_ok, pulled = _probe_ollama_models(base_url)
384
+ if not queried_ok:
385
+ table.add_row(
386
+ "OLLAMA models",
387
+ "[yellow]WARN[/yellow]",
388
+ "Could not list pulled models (/api/tags). Ensure each configured model is pulled.",
389
+ )
390
+ elif not configured_models:
391
+ table.add_row(
392
+ "OLLAMA models",
393
+ "[yellow]WARN[/yellow]",
394
+ "No role models configured; run 'dev setup --provider ollama --model <model>'.",
395
+ )
396
+ else:
397
+ missing = [m for m in configured_models if not _ollama_model_present(m, pulled)]
398
+ if missing:
399
+ pulls = "; ".join(f"ollama pull {m}" for m in missing)
400
+ table.add_row(
401
+ "OLLAMA models",
402
+ "[yellow]WARN[/yellow]",
403
+ f"Configured model(s) not pulled: {', '.join(missing)}. Pull first: {pulls}.",
404
+ )
405
+ else:
406
+ table.add_row(
407
+ "OLLAMA models",
408
+ "[green]OK[/green]",
409
+ f"All configured models present locally ({', '.join(configured_models)}).",
410
+ )
411
+
412
+ if num_ctx is None:
413
+ table.add_row(
414
+ "OLLAMA num_ctx",
415
+ "[yellow]WARN[/yellow]",
416
+ f"OLLAMA_NUM_CTX not set — Ollama's small default (~2048-4096) will "
417
+ f"truncate DevCouncil's large planning prompts. Set OLLAMA_NUM_CTX={recommended_ctx}.",
418
+ )
419
+ elif num_ctx < min_ctx:
420
+ table.add_row(
421
+ "OLLAMA num_ctx",
422
+ "[yellow]WARN[/yellow]",
423
+ f"OLLAMA_NUM_CTX={num_ctx} may be too small for planning prompts "
424
+ f"(~{MAX_PROMPT_CHARS // 4} tokens); recommend >= {recommended_ctx}.",
425
+ )
426
+ else:
427
+ table.add_row("OLLAMA num_ctx", "[green]OK[/green]", f"context window = {num_ctx} tokens.")
428
+
429
+ # Local model size is bounded by host memory — unified RAM on Apple Silicon,
430
+ # VRAM on a discrete-GPU box, system RAM otherwise. Surface a model that will
431
+ # actually fit on this host (any OS), not a one-size default.
432
+ from devcouncil import hardware
433
+
434
+ host = hardware.describe_host()
435
+ table.add_row(
436
+ host.platform_label,
437
+ "[green]OK[/green]",
438
+ f"{host.chip_label}, {host.memory_label}. "
439
+ f"Recommended local model: {host.recommended_ollama_model} "
440
+ f"(dev setup --provider ollama --model {host.recommended_ollama_model}).",
441
+ )
442
+
443
+ _add_logging_row(table, project_root)
111
444
  console.print(table)
112
445
  return
113
446
  env_var = provider_api_key_env_var(provider)
@@ -116,9 +449,33 @@ def render_doctor_check(project_root: Path = Path(".")):
116
449
  table.add_row(env_var, "[green]OK[/green]", f"Found in environment for {provider}.")
117
450
  elif local_secrets.get(env_var):
118
451
  table.add_row(env_var, "[green]OK[/green]", f"Found in .devcouncil/secrets.env for {provider}.")
452
+ elif provider == "vertexai" and get_gcloud_access_token():
453
+ table.add_row(env_var, "[green]OK[/green]", "Resolvable via gcloud auth print-access-token.")
454
+ table.caption = "Resolvable via gcloud auth print-access-token."
119
455
  else:
120
456
  table.add_row(env_var, "[yellow]Missing[/yellow]", f"Required if using {provider} provider. Run 'dev setup'.")
121
457
 
458
+ if provider == "vertexai":
459
+ project = (
460
+ os.environ.get("VERTEXAI_PROJECT")
461
+ or os.environ.get("GOOGLE_CLOUD_PROJECT")
462
+ or local_secrets.get("VERTEXAI_PROJECT")
463
+ or local_secrets.get("GOOGLE_CLOUD_PROJECT")
464
+ )
465
+ if project:
466
+ source = "environment" if os.environ.get("VERTEXAI_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT") else ".devcouncil/secrets.env"
467
+ table.add_row("VERTEXAI_PROJECT", "[green]OK[/green]", f"Found in {source}.")
468
+ else:
469
+ table.add_row(
470
+ "VERTEXAI_PROJECT",
471
+ "[yellow]Missing[/yellow]",
472
+ "Required for vertexai. Run 'dev setup --provider vertexai --vertex-project PROJECT_ID'.",
473
+ )
474
+
475
+ location = os.environ.get("VERTEXAI_LOCATION") or local_secrets.get("VERTEXAI_LOCATION", "global")
476
+ table.add_row("VERTEXAI_LOCATION", "[green]OK[/green]", location)
477
+
478
+ _add_logging_row(table, project_root)
122
479
  console.print(table)
123
480
 
124
481