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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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 =
|
|
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
|
|
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 =
|
|
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
|
|
181
|
-
|
|
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
|
-
#
|
|
184
|
-
#
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
|
609
|
-
#
|
|
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]
|