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,106 @@
1
+ """`dev logs` — find and read DevCouncil's runtime logs.
2
+
3
+ Comprehensive logging only helps if the logs are easy to reach when something
4
+ breaks. This command surfaces the always-on shared log
5
+ (``.devcouncil/logs/devcouncil.log``) and the per-run logs
6
+ (``.devcouncil/runs/<run_id>/run.log``) without the user needing to remember
7
+ paths or hand-roll ``tail``/``grep``.
8
+ """
9
+
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from devcouncil.telemetry.logging_setup import LOG_RELATIVE_PATH
18
+
19
+ app = typer.Typer(help="View DevCouncil runtime logs (shared and per-run).")
20
+ console = Console()
21
+
22
+
23
+ def _shared_log(root: Path) -> Path:
24
+ return root / LOG_RELATIVE_PATH
25
+
26
+
27
+ def _runs_dir(root: Path) -> Path:
28
+ return root / ".devcouncil" / "runs"
29
+
30
+
31
+ def _print_tail(path: Path, limit: int, grep: Optional[str]) -> None:
32
+ if not path.exists():
33
+ console.print(f"[yellow]No log at {path}. Run a command first (or pass --project-root).[/yellow]")
34
+ raise typer.Exit(code=0)
35
+ # Read only the tail rather than the whole (potentially large, rotated) file.
36
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
37
+ if grep:
38
+ lines = [line for line in lines if grep.lower() in line.lower()]
39
+ for line in lines[-limit:]:
40
+ console.print(line, markup=False, highlight=False)
41
+
42
+
43
+ @app.command("tail")
44
+ def tail(
45
+ limit: int = typer.Option(50, "--limit", "-n", help="Number of trailing lines to show."),
46
+ follow: bool = typer.Option(False, "--follow", "-f", help="Keep printing new lines as they are written."),
47
+ grep: Optional[str] = typer.Option(None, "--grep", "-g", help="Only show lines containing this substring (case-insensitive)."),
48
+ run: Optional[str] = typer.Option(None, "--run", help="Show a specific run's log (.devcouncil/runs/<run>/run.log) instead of the shared log."),
49
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
50
+ ):
51
+ """Print the tail of the shared log (or a per-run log with --run)."""
52
+ root = project_root.expanduser().resolve()
53
+ path = (_runs_dir(root) / run / "run.log") if run else _shared_log(root)
54
+
55
+ _print_tail(path, limit, grep)
56
+ if not follow:
57
+ return
58
+
59
+ # Follow mode: poll for appended bytes (the file is append-only between rotations).
60
+ console.print(f"[dim]— following {path} (Ctrl-C to stop) —[/dim]")
61
+ try:
62
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
63
+ handle.seek(0, 2) # jump to EOF; we already printed the tail
64
+ while True:
65
+ line = handle.readline()
66
+ if line:
67
+ rendered = line.rstrip("\n")
68
+ if not grep or grep.lower() in rendered.lower():
69
+ console.print(rendered, markup=False, highlight=False)
70
+ else:
71
+ time.sleep(0.4)
72
+ except KeyboardInterrupt: # pragma: no cover - interactive
73
+ pass
74
+
75
+
76
+ @app.command("path")
77
+ def path(
78
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
79
+ ):
80
+ """Print the shared log file path (and whether it exists)."""
81
+ root = project_root.expanduser().resolve()
82
+ log = _shared_log(root)
83
+ marker = "" if log.exists() else " [yellow](not created yet)[/yellow]"
84
+ console.print(f"{log}{marker}")
85
+
86
+
87
+ @app.command("runs")
88
+ def runs(
89
+ limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of recent run logs to list."),
90
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
91
+ ):
92
+ """List per-run logs, newest first, with their paths."""
93
+ root = project_root.expanduser().resolve()
94
+ runs_dir = _runs_dir(root)
95
+ if not runs_dir.exists():
96
+ console.print("[yellow]No runs yet.[/yellow]")
97
+ return
98
+ run_logs = [d / "run.log" for d in runs_dir.iterdir() if (d / "run.log").exists()]
99
+ if not run_logs:
100
+ console.print("[yellow]No per-run logs yet (run.log appears once an executor runs).[/yellow]")
101
+ return
102
+ run_logs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
103
+ for log in run_logs[:limit]:
104
+ size_kb = log.stat().st_size / 1024
105
+ console.print(f"[bold]{log.parent.name}[/bold] [dim]{log} ({size_kb:.1f} KB)[/dim]")
106
+ console.print("\n[dim]View one with:[/dim] dev logs tail --run <run-id>")
@@ -0,0 +1,245 @@
1
+ """`dev okf` — export, ingest, and validate Open Knowledge Format bundles.
2
+
3
+ * ``dev okf export`` renders DevCouncil's artifact graph as a portable OKF bundle.
4
+ * ``dev okf ingest`` imports an external OKF bundle as planning/coding context under
5
+ ``.devcouncil/knowledge/okf/`` (selected into prompts like a domain skill).
6
+ * ``dev okf validate`` checks a bundle for the OKF invariants (typed docs, resolved links).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from devcouncil.cli.commands.init import initialize_project
19
+ from devcouncil.knowledge.okf import read_bundle, validate_bundle
20
+ from devcouncil.storage.db import get_db
21
+ from devcouncil.storage.repositories import ArtifactGraphRepository
22
+
23
+ app = typer.Typer(help="Export, ingest, and validate Open Knowledge Format bundles.")
24
+ console = Console()
25
+
26
+
27
+ def _knowledge_okf_dir(root: Path) -> Path:
28
+ from devcouncil.app.config import load_config
29
+
30
+ directory = ".devcouncil/knowledge"
31
+ try:
32
+ directory = load_config(root).knowledge.directory
33
+ except Exception:
34
+ pass
35
+ return root / directory / "okf"
36
+
37
+
38
+ def _knowledge_design_md(root: Path) -> Path | None:
39
+ """Locate the project's design.md under the configured knowledge dir's design/ subdir.
40
+
41
+ Honors the same config dir resolution as :func:`_knowledge_okf_dir`, falling back to the
42
+ default ``.devcouncil/knowledge/design/design.md``. Returns ``None`` if no design.md exists.
43
+ """
44
+ from devcouncil.app.config import load_config
45
+
46
+ directory = ".devcouncil/knowledge"
47
+ try:
48
+ directory = load_config(root).knowledge.directory
49
+ except Exception:
50
+ pass
51
+ candidates = [
52
+ root / directory / "design" / "design.md",
53
+ root / ".devcouncil" / "knowledge" / "design" / "design.md",
54
+ ]
55
+ for candidate in candidates:
56
+ if candidate.is_file():
57
+ return candidate
58
+ return None
59
+
60
+
61
+ @app.command("export")
62
+ def export(
63
+ output: Path = typer.Option(Path("okf_bundle"), "--output", "-o", help="Directory to write the OKF bundle into."),
64
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
65
+ skills: bool = typer.Option(
66
+ True,
67
+ "--skills/--no-skills",
68
+ help="Include the engineering skills library as OKF documents in the bundle.",
69
+ ),
70
+ design: bool = typer.Option(
71
+ True,
72
+ "--design/--no-design",
73
+ help="Include the project's design.md (if present) as an OKF document in the bundle.",
74
+ ),
75
+ ):
76
+ """Export the DevCouncil artifact graph as an OKF bundle."""
77
+ root = project_root.expanduser().resolve()
78
+ initialize_project(root, quiet=True)
79
+ db = get_db(root)
80
+ if not db:
81
+ console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
82
+ raise typer.Exit(code=1)
83
+
84
+ out_dir = output.expanduser().resolve()
85
+ with db.get_session() as session:
86
+ graph = ArtifactGraphRepository(session).load_graph()
87
+
88
+ project_name = root.name or "DevCouncil Project"
89
+ try:
90
+ from devcouncil.app.config import load_config
91
+
92
+ project_name = load_config(root).project.name or project_name
93
+ except Exception:
94
+ pass
95
+
96
+ # Load the FULL skill set (packaged library + this repo's own skills) so the export is
97
+ # complete; goal-driven selection would only emit the few skills that match a goal.
98
+ skill_list: list = []
99
+ if skills:
100
+ from devcouncil.skills.registry import load_skills
101
+
102
+ skill_list = load_skills(project_root=root)
103
+
104
+ # Look for the project's design.md under the configured knowledge dir; silently skip if
105
+ # absent so export stays useful for projects without a design system.
106
+ design_obj = None
107
+ if design:
108
+ design_md = _knowledge_design_md(root)
109
+ if design_md is not None:
110
+ from devcouncil.knowledge.design import parse_design_md
111
+
112
+ try:
113
+ design_obj = parse_design_md(design_md)
114
+ except Exception:
115
+ design_obj = None
116
+
117
+ timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
118
+ from devcouncil.reporting.okf_bundle_writer import OKFBundleWriter
119
+
120
+ written = OKFBundleWriter.generate(
121
+ graph,
122
+ out_dir,
123
+ project_name=project_name,
124
+ timestamp=timestamp,
125
+ include_skills=bool(skill_list),
126
+ skills=skill_list,
127
+ include_design=design_obj is not None,
128
+ design=design_obj,
129
+ )
130
+ console.print(
131
+ f"[green]Exported {len(written)} OKF documents to[/green] {out_dir} "
132
+ f"([cyan]{out_dir / 'index.md'}[/cyan])."
133
+ )
134
+ if skill_list:
135
+ console.print(f"[green]Included {len(skill_list)} engineering skill document(s).[/green]")
136
+ if design_obj is not None:
137
+ console.print("[green]Included 1 design system document(s).[/green]")
138
+
139
+
140
+ @app.command("ingest")
141
+ def ingest(
142
+ bundle: str = typer.Argument(
143
+ ...,
144
+ help="OKF bundle source: a local directory, a .tar.gz/.tgz/.zip archive, or a git URL.",
145
+ ),
146
+ name: str = typer.Option("", "--name", help="Subfolder name under knowledge/okf (defaults to the bundle name)."),
147
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
148
+ ):
149
+ """Ingest an external OKF bundle as durable planning/coding context.
150
+
151
+ The bundle may be a local directory, a local archive (``.tar.gz``/``.tgz``/``.zip``,
152
+ extracted behind a path-traversal guard), or a git URL (shallow-cloned). After the
153
+ source is materialized to a local directory, the existing read/validate/copy logic runs
154
+ unchanged; any temp directory is removed afterwards.
155
+ """
156
+ root = project_root.expanduser().resolve()
157
+
158
+ from devcouncil.knowledge.fetch import fetch_bundle
159
+
160
+ try:
161
+ fetched = fetch_bundle(bundle)
162
+ except Exception as exc:
163
+ console.print(f"[red]Could not fetch bundle[/red] {bundle!r}: {exc}")
164
+ raise typer.Exit(code=1)
165
+
166
+ try:
167
+ src = fetched.directory
168
+ if not src.is_dir():
169
+ console.print(f"[red]Not a directory:[/red] {src}")
170
+ raise typer.Exit(code=1)
171
+
172
+ initialize_project(root, quiet=True)
173
+ parsed = read_bundle(src)
174
+ if not parsed.documents:
175
+ console.print(f"[yellow]No OKF documents (*.md) found in[/yellow] {bundle}")
176
+ raise typer.Exit(code=1)
177
+
178
+ problems = validate_bundle(parsed)
179
+ if problems:
180
+ console.print(f"[yellow]Ingesting a bundle with {len(problems)} validation issue(s):[/yellow]")
181
+ for p in problems[:10]:
182
+ console.print(f" - {p}")
183
+
184
+ dest = _knowledge_okf_dir(root) / (name or fetched.suggested_name or "bundle")
185
+ dest.mkdir(parents=True, exist_ok=True)
186
+ count = 0
187
+ for md in src.rglob("*.md"):
188
+ rel = md.relative_to(src)
189
+ target = dest / rel
190
+ target.parent.mkdir(parents=True, exist_ok=True)
191
+ shutil.copyfile(md, target)
192
+ count += 1
193
+ console.print(
194
+ f"[green]Ingested {count} OKF document(s) into[/green] {dest}. "
195
+ "They are now available as planning/coding context."
196
+ )
197
+ finally:
198
+ fetched.cleanup()
199
+
200
+
201
+ @app.command("validate")
202
+ def validate(
203
+ bundle: Path = typer.Argument(..., help="Path to an OKF bundle directory to validate."),
204
+ ):
205
+ """Validate an OKF bundle (every doc typed; every intra-bundle link resolves)."""
206
+ src = bundle.expanduser().resolve()
207
+ if not src.is_dir():
208
+ console.print(f"[red]Not a directory:[/red] {src}")
209
+ raise typer.Exit(code=1)
210
+
211
+ parsed = read_bundle(src)
212
+ problems = validate_bundle(parsed)
213
+ if not problems:
214
+ console.print(f"[green]✓ Valid OKF bundle[/green] — {len(parsed.documents)} document(s), all links resolve.")
215
+ return
216
+ console.print(f"[red]✗ {len(problems)} problem(s) in[/red] {src}:")
217
+ for p in problems:
218
+ console.print(f" - {p}")
219
+ raise typer.Exit(code=1)
220
+
221
+
222
+ @app.command("html")
223
+ def html(
224
+ bundle: Path = typer.Argument(..., help="Path to an OKF bundle directory to render."),
225
+ output: Path = typer.Option(Path("okf_site"), "--output", "-o", help="Directory to write the static HTML site into."),
226
+ ):
227
+ """Render an OKF bundle as a browsable, self-contained static HTML site."""
228
+ src = bundle.expanduser().resolve()
229
+ if not src.is_dir():
230
+ console.print(f"[red]Not a directory:[/red] {src}")
231
+ raise typer.Exit(code=1)
232
+
233
+ parsed = read_bundle(src)
234
+ if not parsed.documents:
235
+ console.print(f"[yellow]No OKF documents (*.md) found in[/yellow] {src}")
236
+ raise typer.Exit(code=1)
237
+
238
+ from devcouncil.reporting.okf_html import write_bundle_html
239
+
240
+ out_dir = output.expanduser().resolve()
241
+ written = write_bundle_html(parsed, out_dir)
242
+ console.print(
243
+ f"[green]Rendered {len(written)} page(s) to[/green] {out_dir} "
244
+ f"([cyan]{out_dir / 'index.html'}[/cyan])."
245
+ )
@@ -2,6 +2,7 @@ import typer
2
2
  import asyncio
3
3
  import json
4
4
  import datetime
5
+ import logging
5
6
  from typing import Any
6
7
  from rich.console import Console
7
8
  from rich.panel import Panel
@@ -18,8 +19,8 @@ from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
18
19
  from devcouncil.llm.provider import Provider, MockProvider, ProviderRequestError, build_role_model_config, create_provider, validate_model_provider
19
20
  from devcouncil.llm.router import ModelRouter, StructuredOutputError
20
21
  from devcouncil.planning.spec_service import SpecService
21
- from devcouncil.planning.prompt_enhancer_service import PromptEnhancerService
22
- from devcouncil.planning.plan_service import PlanService
22
+ from devcouncil.planning.prompt_enhancer_service import PromptEnhancerService, save_active_prompt_enhancement
23
+ from devcouncil.planning.plan_service import PlanService, backfill_acceptance_criteria
23
24
  from devcouncil.planning.critique_service import CritiqueService
24
25
  from devcouncil.planning.arbiter_service import ArbiterDecision, ArbiterService
25
26
  from devcouncil.gating.policy import GatePolicy
@@ -28,9 +29,11 @@ from devcouncil.app.state_machine import ProjectPhase
28
29
  from devcouncil.app.config import ModelRoleConfig, load_config, get_api_key
29
30
  from devcouncil.cli.commands.init import initialize_project
30
31
  from devcouncil.telemetry.traces import TraceLogger
32
+ from devcouncil.telemetry.stages import log_step
31
33
 
32
34
  app = typer.Typer()
33
35
  console = Console()
36
+ logger = logging.getLogger(__name__)
34
37
 
35
38
  REQUIRED_PLANNING_ROLES = (
36
39
  "prompt_enhancer",
@@ -92,6 +95,8 @@ async def run_plan_flow(
92
95
  quick: bool = False,
93
96
  ):
94
97
  root = project_root.expanduser().resolve()
98
+ from devcouncil.telemetry.logging_setup import set_log_dir
99
+ set_log_dir(root)
95
100
  initialize_project(root, quiet=True)
96
101
  db = get_db(root)
97
102
  if not db:
@@ -166,7 +171,7 @@ async def run_plan_flow(
166
171
  if api_key is None:
167
172
  console.print("[red]Missing API key for configured model provider.[/red]")
168
173
  return []
169
- provider = create_provider(config.models.provider, api_key, project_root=root)
174
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
170
175
 
171
176
  # Build role config after dry-run overrides so mocks are routed correctly.
172
177
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
@@ -185,16 +190,20 @@ async def run_plan_flow(
185
190
  transient=True,
186
191
  ) as progress:
187
192
  # 1. Repo Map
193
+ log_step("plan/1: mapping repository", project_root=root, run_id=run_id)
188
194
  progress.add_task(description="Mapping repository...", total=None)
189
195
  repo_map = mapper.map_repo(goal)
190
196
  repo_map_json = repo_map.model_dump_json(indent=2)
191
- orchestrator.save_run_artifact("repo_map.json", json.loads(repo_map_json))
197
+ # save_run_artifact re-serializes via json.dump, so pass the dict directly
198
+ # instead of round-tripping the already-serialized JSON back through json.loads.
199
+ orchestrator.save_run_artifact("repo_map.json", repo_map.model_dump(mode="json"))
192
200
  graph_context = CodeReviewGraphAdapter(root).get_context()
193
201
  if graph_context.available:
194
202
  orchestrator.save_run_artifact("code_review_graph_context.json", graph_context.model_dump())
195
203
  await orchestrator.transition_to(ProjectPhase.REPO_MAPPED)
196
204
 
197
205
  # 2. Codebase-specific prompt enhancement
206
+ log_step("plan/2: enhancing prompt for codebase debate", project_root=root, run_id=run_id)
198
207
  progress.add_task(description="Enhancing prompt for codebase debate...", total=None)
199
208
  prompt_enhancement = await prompt_enhancer.enhance_prompt(
200
209
  goal,
@@ -225,6 +234,7 @@ async def run_plan_flow(
225
234
  )
226
235
 
227
236
  # 3. Spec / Requirements
237
+ log_step("plan/3: generating requirements", project_root=root, run_id=run_id)
228
238
  progress.add_task(description="Generating requirements...", total=None)
229
239
  spec_output = await spec_service.generate_spec(debate_goal, repo_map_json)
230
240
  orchestrator.save_run_artifact("requirements.json", spec_output.model_dump())
@@ -243,6 +253,7 @@ async def run_plan_flow(
243
253
  # adversarial robustness for ~5 fewer model calls — the right setting
244
254
  # for small, well-scoped changes where verification (which still gates
245
255
  # every diff) is the real safety net, not planning debate.
256
+ log_step("plan/4: generating single plan (quick mode)", project_root=root, run_id=run_id)
246
257
  progress.add_task(description="Generating single plan (quick mode)...", total=None)
247
258
  plan_a = await plan_service.generate_plan(
248
259
  "planner_a", debate_goal, requirements_json, repo_map_json
@@ -266,6 +277,7 @@ async def run_plan_flow(
266
277
  final_tasks = [task.model_copy(update={"status": "planned"}) for task in decision.final_tasks]
267
278
  else:
268
279
  # 4. Independent Plans (run concurrently — they don't depend on each other)
280
+ log_step("plan/4: generating Plans A (pragmatic) and B (robust)", project_root=root, run_id=run_id)
269
281
  progress.add_task(description="Generating Plans A (Pragmatic) and B (Robust)...", total=None)
270
282
  plan_a, plan_b = await asyncio.gather(
271
283
  plan_service.generate_plan("planner_a", debate_goal, requirements_json, repo_map_json),
@@ -275,34 +287,44 @@ async def run_plan_flow(
275
287
  orchestrator.save_run_artifact("plan_b.json", plan_b.model_dump())
276
288
  await orchestrator.transition_to(ProjectPhase.PLANS_GENERATED)
277
289
 
290
+ # Serialize each plan/critique once and reuse the strings across the
291
+ # critique, rebuttal, and arbitration steps below.
292
+ plan_a_json = plan_a.model_dump_json()
293
+ plan_b_json = plan_b.model_dump_json()
294
+
278
295
  # 5. Cross-Critique (independent — run concurrently)
296
+ log_step("plan/5: cross-critiquing Plans A and B", project_root=root, run_id=run_id)
279
297
  progress.add_task(description="Critiquing Plans A and B...", total=None)
280
298
  critique_a, critique_b = await asyncio.gather(
281
- critique_service.generate_critique("critic_a", plan_b.model_dump_json(), requirements_json),
282
- critique_service.generate_critique("critic_b", plan_a.model_dump_json(), requirements_json),
299
+ critique_service.generate_critique("critic_a", plan_b_json, requirements_json),
300
+ critique_service.generate_critique("critic_b", plan_a_json, requirements_json),
283
301
  )
284
302
  orchestrator.save_run_artifact("critique_a.json", critique_a.model_dump())
285
303
  orchestrator.save_run_artifact("critique_b.json", critique_b.model_dump())
286
304
  await orchestrator.transition_to(ProjectPhase.CRITIQUES_GENERATED)
305
+ critique_a_json = critique_a.model_dump_json()
306
+ critique_b_json = critique_b.model_dump_json()
287
307
 
288
308
  # 6. Rebuttals (independent — run concurrently)
309
+ log_step("plan/6: generating rebuttals", project_root=root, run_id=run_id)
289
310
  progress.add_task(description="Generating rebuttals...", total=None)
290
311
  rebuttal_a, rebuttal_b = await asyncio.gather(
291
- critique_service.generate_rebuttal("planner_a", plan_a.model_dump_json(), critique_b.model_dump_json()),
292
- critique_service.generate_rebuttal("planner_b", plan_b.model_dump_json(), critique_a.model_dump_json()),
312
+ critique_service.generate_rebuttal("planner_a", plan_a_json, critique_b_json),
313
+ critique_service.generate_rebuttal("planner_b", plan_b_json, critique_a_json),
293
314
  )
294
315
  orchestrator.save_run_artifact("rebuttal_a.json", rebuttal_a.model_dump())
295
316
  orchestrator.save_run_artifact("rebuttal_b.json", rebuttal_b.model_dump())
296
317
 
297
318
  # 7. Arbitration
319
+ log_step("plan/7: arbitrating final plan", project_root=root, run_id=run_id)
298
320
  progress.add_task(description="Arbitrating final plan...", total=None)
299
321
  decision = await arbiter_service.arbitrate(
300
322
  debate_goal,
301
- json.dumps([r.model_dump() for r in spec_output.requirements]),
302
- plan_a.model_dump_json(),
303
- plan_b.model_dump_json(),
304
- critique_a.model_dump_json(),
305
- critique_b.model_dump_json(),
323
+ requirements_json,
324
+ plan_a_json,
325
+ plan_b_json,
326
+ critique_a_json,
327
+ critique_b_json,
306
328
  rebuttal_a.model_dump_json(),
307
329
  rebuttal_b.model_dump_json()
308
330
  )
@@ -317,10 +339,23 @@ async def run_plan_flow(
317
339
  console.print("[blue](DRY RUN: No actual LLM calls were made)[/blue]")
318
340
  if not persist:
319
341
  console.print("[blue](DRY RUN: Final requirements/tasks were not persisted)[/blue]")
342
+ # Operationalize the spec's edge-case elaboration: attach every acceptance criterion
343
+ # the planner left unlinked to a task that owns its requirement, so elaborated edges
344
+ # (truncation semantics, error paths, boundaries) are actually built and per-criterion
345
+ # verified instead of silently dropped — the gap that lets a gated run be no better
346
+ # than the raw prompt.
347
+ final_tasks, backfilled_acs = backfill_acceptance_criteria(final_tasks, decision.final_requirements)
348
+ if backfilled_acs:
349
+ console.print(
350
+ f"[dim]Linked {len(backfilled_acs)} unmapped acceptance criterion(s) to owning task(s) "
351
+ "so every elaborated behavior is verified.[/dim]"
352
+ )
353
+
320
354
  console.print(f"Final Requirements: [bold]{len(decision.final_requirements)}[/bold]")
321
355
  console.print(f"Final Tasks: [bold]{len(final_tasks)}[/bold]")
322
-
356
+
323
357
  # 8. Check Gates
358
+ log_step("plan/8: checking plan-approval gates", project_root=root, run_id=run_id)
324
359
  policy = GatePolicy()
325
360
  result = policy.check_plan_approval(
326
361
  decision.final_requirements,
@@ -342,8 +377,12 @@ async def run_plan_flow(
342
377
  final_tasks,
343
378
  reconciled_findings,
344
379
  )
380
+ # Pin THIS plan's enhancement so the executor reads the domain guidance tied to
381
+ # the plan it runs (not a later run's by mtime).
382
+ save_active_prompt_enhancement(root, prompt_enhancement)
345
383
 
346
384
  console.print("[green]Plan approved by gates.[/green]")
385
+ logger.info("Plan approved by gates: %d task(s)", len(final_tasks))
347
386
  await orchestrator.transition_to(ProjectPhase.PLAN_APPROVED)
348
387
  return [task.id for task in final_tasks]
349
388
  else:
@@ -353,6 +392,7 @@ async def run_plan_flow(
353
392
  for gap in result.gaps:
354
393
  gap_repo.save(gap)
355
394
  console.print("[yellow]Plan generated but failed gates. See status for gaps.[/yellow]")
395
+ logger.warning("Plan failed approval gates with %d gap(s)", len(result.gaps))
356
396
  await orchestrator.transition_to(ProjectPhase.AWAITING_USER_DECISIONS)
357
397
  return []
358
398
 
@@ -9,13 +9,17 @@ from devcouncil.llm.router import ModelRouter
9
9
  from devcouncil.app.config import load_config, get_api_key
10
10
  from devcouncil.cli.commands.init import initialize_project
11
11
  import asyncio
12
+ import logging
12
13
  from pathlib import Path
13
14
 
14
15
  app = typer.Typer()
15
16
  console = Console()
17
+ logger = logging.getLogger(__name__)
16
18
 
17
19
  async def run_repair_flow(project_root: Path = Path(".")):
18
20
  root = project_root.expanduser().resolve()
21
+ from devcouncil.telemetry.logging_setup import set_log_dir
22
+ set_log_dir(root)
19
23
  initialize_project(root, quiet=True)
20
24
  db = get_db(root)
21
25
  if not db:
@@ -28,25 +32,29 @@ async def run_repair_flow(project_root: Path = Path(".")):
28
32
 
29
33
  all_gaps = gap_repo.get_all()
30
34
  blocking_gaps = [g for g in all_gaps if g.blocking]
31
-
35
+
32
36
  if not blocking_gaps:
37
+ logger.info("dev repair: no blocking gaps; nothing to repair")
33
38
  console.print("[green]No blocking gaps found. Nothing to repair![/green]")
34
39
  return
35
40
 
41
+ logger.info("dev repair: %d blocking gap(s) across %d task(s)", len(blocking_gaps), len({g.task_id for g in blocking_gaps if g.task_id}))
36
42
  console.print(f"Found [bold]{len(blocking_gaps)}[/bold] blocking gaps. Orchestrating repair plan...")
37
43
 
38
44
  # Load router when credentials are available. Correction manifests have a
39
45
  # deterministic fallback path, so missing model credentials must not block
40
46
  # repair artifact generation.
41
47
  repair_service = None
48
+ config = None
42
49
  try:
43
50
  config = load_config(root)
44
51
  validate_model_provider(config.models.provider)
45
52
  api_key = get_api_key(config.models.provider, root)
46
53
  except (FileNotFoundError, ValueError) as e:
54
+ logger.warning("dev repair: no LLM provider (%s); using deterministic manifest fallback", e)
47
55
  console.print(f"[yellow]{e}[/yellow]")
48
56
  else:
49
- provider = create_provider(config.models.provider, api_key, project_root=root)
57
+ provider = create_provider(config.models.provider, api_key, project_root=root, provider_prefs=config.provider)
50
58
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
51
59
  router = ModelRouter(provider, role_config, project_root=root)
52
60
  repair_service = RepairService(router)
@@ -60,7 +68,7 @@ async def run_repair_flow(project_root: Path = Path(".")):
60
68
  task_ids = {gap.task_id for gap in blocking_gaps if gap.task_id}
61
69
  for scoped_task_id in task_ids:
62
70
  if scoped_task_id:
63
- path = write_correction_manifest(root, scoped_task_id, repair_service=repair_service)
71
+ path = write_correction_manifest(root, scoped_task_id, repair_service=repair_service, config=config)
64
72
  if path:
65
73
  console.print(f" - Wrote correction manifest [dim]{path}[/dim]")
66
74
 
@@ -73,6 +81,7 @@ async def run_repair_flow(project_root: Path = Path(".")):
73
81
  repair_count += 1
74
82
  console.print(f" - Created intelligent repair task [bold]{task.id}[/bold]: {task.title}")
75
83
 
84
+ logger.info("dev repair complete: generated %d repair task(s)", repair_count)
76
85
  console.print(f"\n[green]Successfully generated {repair_count} repair tasks.[/green]")
77
86
 
78
87
  @app.callback(invoke_without_command=True)