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
@@ -0,0 +1,223 @@
1
+ """`dev runs` — list and inspect per-run agent manifests.
2
+
3
+ Coding-CLI executors write a manifest at
4
+ ``.devcouncil/runs/<run-id>/agent-run.json`` (prompt file, executor, profile,
5
+ resolved command, exit status, run metadata). These commands let a developer or a
6
+ supervisor list and inspect those runs without reading raw JSON, and flag a run
7
+ whose status is still ``running`` but whose manifest has gone stale (the executor
8
+ process likely crashed) as ``orphaned``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import typer
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from devcouncil.utils.redaction import redact_text
22
+
23
+ app = typer.Typer(help="List and inspect coding-agent run manifests.")
24
+ console = Console()
25
+
26
+ # A run still marked ``running`` whose manifest has not been touched for longer
27
+ # than this is treated as orphaned (the executor process likely died). Used as a
28
+ # sane default; can be overridden per-call with --orphan-after.
29
+ _DEFAULT_ORPHAN_AFTER_SECONDS = 600
30
+
31
+ # Transcript/log files (in priority order) whose tail `dev runs show` surfaces.
32
+ _TRANSCRIPT_CANDIDATES = ("transcript.txt", "transcript.log", "output.log", "run.log")
33
+ _TRANSCRIPT_TAIL_LINES = 40
34
+
35
+
36
+ def _runs_dir(project_root: Path) -> Path:
37
+ return project_root / ".devcouncil" / "runs"
38
+
39
+
40
+ def _orphan_after_seconds(project_root: Path) -> int:
41
+ """Threshold (seconds) after which a stale ``running`` manifest is orphaned.
42
+
43
+ Config-driven when available (execution.lease_ttl_seconds is a reasonable
44
+ proxy for "how long a live run can plausibly stay quiet"); falls back to a
45
+ sane default so the command works before `dev init`."""
46
+ try:
47
+ from devcouncil.app.config import load_config
48
+
49
+ ttl = int(load_config(project_root).execution.lease_ttl_seconds)
50
+ if ttl > 0:
51
+ return ttl
52
+ except Exception:
53
+ pass
54
+ return _DEFAULT_ORPHAN_AFTER_SECONDS
55
+
56
+
57
+ def _load_manifest(manifest_path: Path) -> dict | None:
58
+ try:
59
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
60
+ except (OSError, json.JSONDecodeError):
61
+ return None
62
+ return data if isinstance(data, dict) else None
63
+
64
+
65
+ def _is_orphaned(manifest: dict, manifest_path: Path, *, orphan_after: int, now: float) -> bool:
66
+ """A run is orphaned when it is still ``running`` but its manifest file has
67
+ not been updated within the threshold — i.e. no heartbeat, executor gone."""
68
+ if manifest.get("status") != "running":
69
+ return False
70
+ try:
71
+ mtime = manifest_path.stat().st_mtime
72
+ except OSError:
73
+ return False
74
+ return (now - mtime) > max(0, orphan_after)
75
+
76
+
77
+ def _run_summary(manifest: dict, manifest_path: Path, *, orphan_after: int, now: float) -> dict:
78
+ return {
79
+ "run_id": manifest.get("run_id") or manifest_path.parent.name,
80
+ "task_id": manifest.get("task_id"),
81
+ "agent": manifest.get("agent"),
82
+ "profile": manifest.get("profile"),
83
+ "status": manifest.get("status"),
84
+ "started_at": manifest.get("started_at") or manifest.get("timestamp"),
85
+ "finished_at": manifest.get("finished_at"),
86
+ "returncode": manifest.get("returncode"),
87
+ "orphaned": _is_orphaned(manifest, manifest_path, orphan_after=orphan_after, now=now),
88
+ }
89
+
90
+
91
+ def _collect_runs(project_root: Path, *, orphan_after: int) -> list[dict]:
92
+ runs_dir = _runs_dir(project_root)
93
+ if not runs_dir.is_dir():
94
+ return []
95
+ now = time.time()
96
+ summaries: list[tuple[float, dict]] = []
97
+ for manifest_path in runs_dir.glob("*/agent-run.json"):
98
+ manifest = _load_manifest(manifest_path)
99
+ if manifest is None:
100
+ continue
101
+ try:
102
+ sort_key = manifest_path.stat().st_mtime
103
+ except OSError:
104
+ sort_key = 0.0
105
+ summaries.append((sort_key, _run_summary(manifest, manifest_path, orphan_after=orphan_after, now=now)))
106
+ # Newest first.
107
+ summaries.sort(key=lambda item: item[0], reverse=True)
108
+ return [summary for _, summary in summaries]
109
+
110
+
111
+ def _find_transcript(run_dir: Path, manifest: dict) -> Path | None:
112
+ recorded = manifest.get("transcript")
113
+ if isinstance(recorded, str) and recorded:
114
+ candidate = Path(recorded)
115
+ if not candidate.is_absolute():
116
+ candidate = run_dir / recorded
117
+ if candidate.is_file():
118
+ return candidate
119
+ for name in _TRANSCRIPT_CANDIDATES:
120
+ candidate = run_dir / name
121
+ if candidate.is_file():
122
+ return candidate
123
+ return None
124
+
125
+
126
+ def _transcript_tail(path: Path, *, lines: int = _TRANSCRIPT_TAIL_LINES) -> str:
127
+ """Return the redacted tail of a transcript file (best-effort)."""
128
+ try:
129
+ content = path.read_text(encoding="utf-8", errors="replace")
130
+ except OSError:
131
+ return ""
132
+ tail = content.splitlines()[-lines:]
133
+ return redact_text("\n".join(tail))
134
+
135
+
136
+ @app.command("list")
137
+ def list_runs(
138
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
139
+ limit: int = typer.Option(20, "--limit", help="Maximum number of runs to show."),
140
+ status: str | None = typer.Option(None, "--status", help="Filter by run status (e.g. running, finished, failed, timeout)."),
141
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
142
+ ) -> None:
143
+ """List recorded coding-agent runs, newest first."""
144
+ root = project_root.expanduser().resolve()
145
+ orphan_after = _orphan_after_seconds(root)
146
+ runs = _collect_runs(root, orphan_after=orphan_after)
147
+ if status:
148
+ runs = [run for run in runs if run.get("status") == status]
149
+ if limit > 0:
150
+ runs = runs[:limit]
151
+
152
+ if json_output:
153
+ console.print_json(data={"runs": runs, "count": len(runs)})
154
+ return
155
+
156
+ if not runs:
157
+ console.print("[dim]No agent runs found under .devcouncil/runs/.[/dim]")
158
+ return
159
+
160
+ table = Table(title="Agent runs")
161
+ table.add_column("Run ID", overflow="fold")
162
+ table.add_column("Task")
163
+ table.add_column("Agent")
164
+ table.add_column("Profile")
165
+ table.add_column("Status")
166
+ table.add_column("Started")
167
+ for run in runs:
168
+ status_text = str(run.get("status") or "?")
169
+ if run.get("orphaned"):
170
+ status_text = f"[red]{status_text} (orphaned)[/red]"
171
+ table.add_row(
172
+ str(run.get("run_id") or ""),
173
+ str(run.get("task_id") or ""),
174
+ str(run.get("agent") or ""),
175
+ str(run.get("profile") or ""),
176
+ status_text,
177
+ str(run.get("started_at") or ""),
178
+ )
179
+ console.print(table)
180
+
181
+
182
+ @app.command("show")
183
+ def show_run(
184
+ run_id: str = typer.Argument(..., help="The run id to inspect."),
185
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
186
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
187
+ ) -> None:
188
+ """Show the full manifest for a run plus a redacted transcript tail."""
189
+ root = project_root.expanduser().resolve()
190
+ run_dir = _runs_dir(root) / run_id
191
+ manifest_path = run_dir / "agent-run.json"
192
+ manifest = _load_manifest(manifest_path)
193
+ if manifest is None:
194
+ if json_output:
195
+ console.print_json(data={"ok": False, "error": f"Run {run_id} not found.", "run_id": run_id})
196
+ else:
197
+ console.print(f"[red]Run {run_id} not found under .devcouncil/runs/.[/red]")
198
+ raise typer.Exit(code=1)
199
+
200
+ orphan_after = _orphan_after_seconds(root)
201
+ orphaned = _is_orphaned(manifest, manifest_path, orphan_after=orphan_after, now=time.time())
202
+ transcript_path = _find_transcript(run_dir, manifest)
203
+ transcript_tail = _transcript_tail(transcript_path) if transcript_path else ""
204
+
205
+ if json_output:
206
+ console.print_json(data={
207
+ "ok": True,
208
+ "run_id": run_id,
209
+ "manifest": manifest,
210
+ "orphaned": orphaned,
211
+ "transcript_path": str(transcript_path) if transcript_path else None,
212
+ "transcript_tail": transcript_tail,
213
+ })
214
+ return
215
+
216
+ console.print_json(data=manifest)
217
+ if orphaned:
218
+ console.print("[red]This run is orphaned: still marked running but its manifest is stale.[/red]")
219
+ if transcript_path:
220
+ console.print(f"\n[bold]Transcript tail[/bold] [dim]({transcript_path})[/dim]:")
221
+ console.print(transcript_tail or "[dim](empty)[/dim]")
222
+ else:
223
+ console.print("\n[dim]No transcript file found for this run.[/dim]")
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from devcouncil.repo.ci_scaffold import WORKFLOW_RELPATH, detect_stacks, scaffold_ci
7
+
8
+ console = Console()
9
+
10
+
11
+ def scaffold_ci_command(
12
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root to scaffold CI into."),
13
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing devcouncil.yml workflow."),
14
+ ):
15
+ """Write a starter GitHub Actions workflow derived from the configured commands."""
16
+ root = project_root.expanduser().resolve()
17
+ if not (root / ".devcouncil").exists():
18
+ console.print("[red]DevCouncil is not initialized here. Run 'dev setup' first.[/red]")
19
+ raise typer.Exit(code=1)
20
+
21
+ target = scaffold_ci(root, force=force)
22
+ if target is None:
23
+ console.print(
24
+ f"[yellow]{WORKFLOW_RELPATH.as_posix()} already exists. "
25
+ f"Re-run with --force to overwrite.[/yellow]"
26
+ )
27
+ return
28
+
29
+ stacks = detect_stacks(root)
30
+ detected = ", ".join(sorted(stacks)) if stacks else "none auto-detected"
31
+ console.print(f"[green]Wrote {target.relative_to(root).as_posix()} (stacks: {detected}).[/green]")
32
+ console.print("[dim]Review the setup/install steps and commands before relying on it.[/dim]")
@@ -0,0 +1,47 @@
1
+ import json
2
+ import typer
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+
6
+ from devcouncil.cli.commands.init import initialize_project
7
+ from devcouncil.indexing.semantic_index import SemanticIndex
8
+
9
+ app = typer.Typer(help="Semantic snapshots and diffs.")
10
+ console = Console()
11
+
12
+
13
+ @app.command("snapshot")
14
+ def snapshot(
15
+ task_id: str = typer.Argument(...),
16
+ stage: str = typer.Option("before", "--stage", help="before or after"),
17
+ project_root: Path = typer.Option(Path("."), "--project-root"),
18
+ json_format: bool = typer.Option(False, "--json"),
19
+ ):
20
+ root = project_root.expanduser().resolve()
21
+ initialize_project(root, quiet=True)
22
+ if stage not in {"before", "after"}:
23
+ console.print("[red]--stage must be before or after[/red]")
24
+ raise typer.Exit(code=2)
25
+ path = SemanticIndex(root).create_snapshot(task_id, stage)
26
+ payload = {"task_id": task_id, "stage": stage, "path": str(path)}
27
+ if json_format:
28
+ typer.echo(json.dumps(payload, indent=2))
29
+ else:
30
+ console.print(f"[green]Wrote semantic snapshot:[/green] {path}")
31
+
32
+
33
+ @app.command("diff")
34
+ def semantic_diff(
35
+ task_id: str = typer.Argument(...),
36
+ project_root: Path = typer.Option(Path("."), "--project-root"),
37
+ json_format: bool = typer.Option(False, "--json"),
38
+ ):
39
+ root = project_root.expanduser().resolve()
40
+ initialize_project(root, quiet=True)
41
+ result = SemanticIndex(root).diff(task_id)
42
+ if json_format:
43
+ typer.echo(json.dumps(result, indent=2))
44
+ else:
45
+ console.print(f"[cyan]Summary:[/cyan] {result['summary']}")
46
+ for item in result["classifications"]:
47
+ console.print(f" - {item['type']}: {item.get('path', '')}")
@@ -8,18 +8,27 @@ import yaml
8
8
  from rich.console import Console
9
9
  from rich.panel import Panel
10
10
 
11
- from devcouncil.app.config import load_config, load_local_secrets, provider_api_key_env_var
11
+ from devcouncil.app.config import (
12
+ _normalized_provider_name,
13
+ get_gcloud_access_token,
14
+ load_config,
15
+ load_local_secrets,
16
+ provider_api_key_env_var,
17
+ )
12
18
  from devcouncil.cli.commands.doctor import render_doctor_check
13
- from devcouncil.cli.commands.init import initialize_project
19
+ from devcouncil.cli.commands.init import initialize_project, parse_role_model_overrides
14
20
  from devcouncil.cli.commands.integrate import (
15
21
  _claude_command,
16
22
  _codex_command,
23
+ _configure_antigravity,
24
+ _configure_cursor,
17
25
  _configure_native_hooks,
26
+ _configure_opencode,
27
+ _configure_warp,
18
28
  _configure,
19
- _cursor_command,
20
29
  _gemini_command,
21
30
  )
22
- from devcouncil.llm.provider import validate_model_provider
31
+ from devcouncil.llm.provider import apply_provider_default_role_models, build_role_model_config, validate_model_provider
23
32
 
24
33
  app = typer.Typer()
25
34
  console = Console()
@@ -32,9 +41,36 @@ def _set_model_provider(project_root: Path, provider: str) -> None:
32
41
  raw_config.setdefault("models", {})
33
42
  previous = raw_config["models"].get("provider", "openrouter")
34
43
  raw_config["models"]["provider"] = normalized
44
+ updated_role_defaults = apply_provider_default_role_models(raw_config, previous, normalized)
35
45
  config_path.write_text(yaml.dump(raw_config, default_flow_style=False), encoding="utf-8")
36
46
  if previous != normalized:
37
47
  console.print(f"[green]Updated model provider from {previous} to {normalized}.[/green]")
48
+ if updated_role_defaults:
49
+ console.print(f"[green]Updated default role models for {normalized}.[/green]")
50
+
51
+
52
+ def _set_model_roles(
53
+ project_root: Path,
54
+ model: str | None = None,
55
+ role_models: dict[str, str] | None = None,
56
+ ) -> None:
57
+ if not model and not role_models:
58
+ return
59
+
60
+ config_path = project_root / ".devcouncil" / "config.yaml"
61
+ raw_config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
62
+ raw_config.setdefault("models", {})
63
+ provider = validate_model_provider(raw_config["models"].get("provider", "openrouter"))
64
+ raw_config["models"]["roles"] = build_role_model_config(
65
+ provider,
66
+ model=model,
67
+ role_models=role_models,
68
+ )
69
+ config_path.write_text(yaml.dump(raw_config, default_flow_style=False), encoding="utf-8")
70
+ if model:
71
+ console.print(f"[green]Updated all model roles to use {model}.[/green]")
72
+ for role, selected_model in (role_models or {}).items():
73
+ console.print(f"[green]Updated {role} to use {selected_model}.[/green]")
38
74
 
39
75
 
40
76
  def _write_local_secret(project_root: Path, env_var: str, value: str) -> Path:
@@ -54,9 +90,43 @@ def _write_local_secret(project_root: Path, env_var: str, value: str) -> Path:
54
90
  return path
55
91
 
56
92
 
93
+ def _configure_vertexai_settings(
94
+ project_root: Path,
95
+ provider: str,
96
+ vertex_project: str | None,
97
+ vertex_location: str | None,
98
+ ) -> None:
99
+ if provider != "vertexai":
100
+ return
101
+
102
+ local_secrets = load_local_secrets(project_root)
103
+ if vertex_project:
104
+ _write_local_secret(project_root, "VERTEXAI_PROJECT", vertex_project)
105
+ console.print("[green]Saved VERTEXAI_PROJECT to .devcouncil/secrets.env.[/green]")
106
+ elif not (
107
+ os.environ.get("VERTEXAI_PROJECT")
108
+ or os.environ.get("GOOGLE_CLOUD_PROJECT")
109
+ or local_secrets.get("VERTEXAI_PROJECT")
110
+ or local_secrets.get("GOOGLE_CLOUD_PROJECT")
111
+ ):
112
+ console.print(
113
+ "[yellow]VERTEXAI_PROJECT is not set. "
114
+ "Set it in your shell or rerun setup with --vertex-project PROJECT_ID.[/yellow]"
115
+ )
116
+
117
+ if vertex_location:
118
+ _write_local_secret(project_root, "VERTEXAI_LOCATION", vertex_location)
119
+ console.print("[green]Saved VERTEXAI_LOCATION to .devcouncil/secrets.env.[/green]")
120
+
121
+
57
122
  def _configure_api_key(project_root: Path, api_key: str | None, skip_api_key: bool) -> None:
58
123
  config = load_config(project_root)
59
124
  provider = config.models.provider
125
+ if _normalized_provider_name(provider) == "ollama":
126
+ console.print(
127
+ "[green]Ollama uses a local server (default http://localhost:11434); no API key required.[/green]"
128
+ )
129
+ return
60
130
  env_var = provider_api_key_env_var(provider)
61
131
  local_secrets = load_local_secrets(project_root)
62
132
 
@@ -70,6 +140,9 @@ def _configure_api_key(project_root: Path, api_key: str | None, skip_api_key: bo
70
140
  _write_local_secret(project_root, env_var, api_key)
71
141
  console.print(f"[green]Saved {env_var} to .devcouncil/secrets.env.[/green]")
72
142
  return
143
+ if provider == "vertexai" and get_gcloud_access_token():
144
+ console.print("[green]Vertex AI access token is available from gcloud auth print-access-token.[/green]")
145
+ return
73
146
  if skip_api_key:
74
147
  console.print(f"[yellow]Skipped {env_var} setup. Model-backed commands will ask again if it is missing.[/yellow]")
75
148
  return
@@ -109,7 +182,6 @@ def _configure_coding_cli_integrations(project_root: Path, apply: bool, gemini_s
109
182
  ("Codex CLI", _codex_command(project_root)),
110
183
  ("Gemini CLI", _gemini_command(project_root, gemini_scope)),
111
184
  ("Claude Code", _claude_command(project_root, "local")),
112
- ("Cursor", _cursor_command(project_root)),
113
185
  ]
114
186
  results = []
115
187
  for tool, command in commands:
@@ -117,6 +189,10 @@ def _configure_coding_cli_integrations(project_root: Path, apply: bool, gemini_s
117
189
  console.print(f"[yellow]{tool} CLI not found on PATH. Skipping optional integration.[/yellow]")
118
190
  continue
119
191
  results.append(_configure(tool, command, apply))
192
+ results.append(_configure_cursor(project_root, apply))
193
+ results.append(_configure_opencode(project_root, apply))
194
+ results.append(_configure_antigravity(project_root, apply))
195
+ results.append(_configure_warp(project_root, apply))
120
196
  _configure_native_hooks(project_root, "all", apply)
121
197
  if apply and any(not ok for ok in results):
122
198
  raise typer.Exit(code=1)
@@ -157,9 +233,20 @@ def setup(
157
233
  apply: bool = typer.Option(False, "--apply", help="Apply integration config instead of previewing commands."),
158
234
  gemini_scope: str = typer.Option("project", "--gemini-scope", help="Gemini MCP config scope: project or user."),
159
235
  provider: str | None = typer.Option(None, "--provider", help="Set models.provider before configuring the API key."),
236
+ model: str | None = typer.Option(None, "--model", "-m", help="Model id to use for every default role."),
237
+ role_model: list[str] | None = typer.Option(
238
+ None,
239
+ "--role-model",
240
+ help="Per-role model override in ROLE=MODEL form. Can be repeated.",
241
+ ),
160
242
  api_key: str | None = typer.Option(None, "--api-key", help="Store the configured provider API key in local .devcouncil/secrets.env."),
243
+ vertex_project: str | None = typer.Option(None, "--vertex-project", help="Store VERTEXAI_PROJECT for the vertexai provider."),
244
+ vertex_location: str | None = typer.Option(None, "--vertex-location", help="Store VERTEXAI_LOCATION for the vertexai provider. Defaults to global."),
161
245
  skip_api_key: bool = typer.Option(False, "--skip-api-key", help="Skip the first-run model API key prompt."),
162
246
  skip_integrations: bool = typer.Option(False, "--skip-integrations", help="Skip the first-run coding CLI integration prompt."),
247
+ skip_map: bool = typer.Option(False, "--skip-map", help="Skip generating repo_map.json and agent guides on init."),
248
+ skip_skills: bool = typer.Option(False, "--skip-skills", help="Skip scaffolding engineering skills into .claude/skills/ on init."),
249
+ scaffold_ci: bool = typer.Option(False, "--scaffold-ci", help="Write a starter .github/workflows/devcouncil.yml from the configured commands."),
163
250
  ):
164
251
  """
165
252
  Initialize DevCouncil from a normal terminal in the target repository root.
@@ -174,7 +261,44 @@ def setup(
174
261
  raise typer.Exit(code=2)
175
262
 
176
263
  root = project_root.expanduser().resolve()
177
- created = initialize_project(root, project_name=name)
264
+ try:
265
+ role_models = parse_role_model_overrides(role_model)
266
+ initial_provider = validate_model_provider(provider) if provider else "openrouter"
267
+ except ValueError as e:
268
+ console.print(f"[red]{e}[/red]")
269
+ raise typer.Exit(code=2) from e
270
+
271
+ # Size the default local model to the host's memory ceiling (unified RAM on Apple
272
+ # Silicon, VRAM on a discrete GPU, system RAM otherwise) instead of the static 7b,
273
+ # so users on any OS get a capable council out of the box. Only when the user chose
274
+ # Ollama and did not pin a model.
275
+ if initial_provider == "ollama" and model is None:
276
+ from devcouncil import hardware
277
+
278
+ host = hardware.describe_host()
279
+ model = host.recommended_ollama_model
280
+ console.print(
281
+ f"[green]Detected {host.chip_label} ({host.memory_label}); "
282
+ f"defaulting Ollama model to {model}.[/green] "
283
+ "Override with --model, and pull it first: "
284
+ f"[bold]ollama pull {model}[/bold]."
285
+ )
286
+ elif initial_provider == "ollama" and model is not None:
287
+ # Model pinned explicitly — still remind the user it must be pulled locally.
288
+ console.print(
289
+ f"[green]Using Ollama model {model}.[/green] "
290
+ f"Pull it first if needed: [bold]ollama pull {model}[/bold]."
291
+ )
292
+
293
+ created = initialize_project(
294
+ root,
295
+ project_name=name,
296
+ model_provider=initial_provider,
297
+ model=model,
298
+ role_models=role_models,
299
+ with_map=not skip_map,
300
+ with_skills=not skip_skills,
301
+ )
178
302
  if not created:
179
303
  console.print(f"[yellow]DevCouncil is already initialized at {root / '.devcouncil'}.[/yellow]")
180
304
 
@@ -185,11 +309,24 @@ def setup(
185
309
  console.print(f"[red]{e}[/red]")
186
310
  raise typer.Exit(code=2) from e
187
311
 
312
+ _set_model_roles(root, model=model, role_models=role_models)
313
+
314
+ configured_provider = load_config(root).models.provider
315
+ _configure_vertexai_settings(root, configured_provider, vertex_project, vertex_location)
188
316
  _configure_api_key(root, api_key, skip_api_key)
189
317
 
190
318
  console.print()
191
319
  render_doctor_check(root)
192
320
 
321
+ if scaffold_ci:
322
+ from devcouncil.repo.ci_scaffold import WORKFLOW_RELPATH, scaffold_ci as scaffold_ci_workflow
323
+
324
+ written = scaffold_ci_workflow(root)
325
+ if written is None:
326
+ console.print(f"[yellow]{WORKFLOW_RELPATH.as_posix()} already exists; left unchanged.[/yellow]")
327
+ else:
328
+ console.print(f"[green]Wrote starter CI workflow {written.relative_to(root).as_posix()}.[/green]")
329
+
193
330
  if integrate:
194
331
  _configure_coding_cli_integrations(root, apply=apply, gemini_scope=gemini_scope)
195
332
  elif created and not skip_integrations:
@@ -213,6 +350,8 @@ def setup(
213
350
  "dev run TASK-001 --executor codex",
214
351
  "dev run TASK-001 --executor gemini",
215
352
  "dev run TASK-001 --executor claude",
353
+ "dev run TASK-001 --executor opencode",
354
+ "dev run TASK-001 --executor antigravity",
216
355
  "dev verify TASK-001",
217
356
  "",
218
357
  "Use [bold]dev setup --integrate[/bold] to preview coding CLI MCP and native hook setup.",
@@ -0,0 +1,73 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from rich.console import Console
4
+
5
+ from devcouncil.cli.commands.init import initialize_project
6
+ from devcouncil.execution.shell_session import GuardedShellSession
7
+ from devcouncil.storage.db import get_db
8
+ from devcouncil.storage.repositories import TaskRepository
9
+
10
+ console = Console()
11
+
12
+
13
+ def shell(
14
+ task_id: str = typer.Argument(..., help="Task ID"),
15
+ command: str | None = typer.Option(None, "--command", help="Run one guarded command and exit."),
16
+ shell_name: str = typer.Option("auto", "--shell", help="Shell backend: auto, pwsh, bash, zsh."),
17
+ force: bool = typer.Option(False, "--force", help="Reclaim a stale lease from a previous (possibly crashed) session."),
18
+ project_root: Path = typer.Option(Path("."), "--project-root"),
19
+ ):
20
+ """
21
+ Run guarded shell commands for a task.
22
+ """
23
+ root = project_root.expanduser().resolve()
24
+ initialize_project(root, quiet=True)
25
+ db = get_db(root)
26
+ if not db:
27
+ console.print("[red]DevCouncil not initialized.[/red]")
28
+ raise typer.Exit(code=1)
29
+
30
+ with db.get_session() as session:
31
+ task = TaskRepository(session).get_by_id(task_id)
32
+ if not task:
33
+ console.print(f"[red]Task {task_id} not found.[/red]")
34
+ raise typer.Exit(code=1)
35
+
36
+ try:
37
+ session_runner = GuardedShellSession(root, task, shell=shell_name)
38
+ except ValueError as exc:
39
+ console.print(f"[red]{exc}[/red]")
40
+ raise typer.Exit(code=2)
41
+ try:
42
+ session_runner.start(force=force)
43
+ except ValueError as exc:
44
+ # A live or stale lease from a prior session blocks a new one. Don't dump
45
+ # a traceback — tell the user how to recover.
46
+ console.print(f"[red]{exc}[/red]")
47
+ console.print(
48
+ "[yellow]Another shell session may be active for this task. If it is "
49
+ "stale (a previous session crashed), re-run with [bold]--force[/bold] to "
50
+ "reclaim it.[/yellow]"
51
+ )
52
+ raise typer.Exit(code=2)
53
+ try:
54
+ if command:
55
+ code = session_runner.run_one(command)
56
+ raise typer.Exit(code=code)
57
+
58
+ console.print(f"[cyan]Guarded shell for {task_id}. Type exit or quit to end.[/cyan]")
59
+ while True:
60
+ try:
61
+ line = input(f"devcouncil:{task_id}> ")
62
+ except (EOFError, KeyboardInterrupt):
63
+ break
64
+ normalized = line.strip()
65
+ if normalized.lower() in {"exit", "quit"}:
66
+ break
67
+ if not normalized:
68
+ continue
69
+ code = session_runner.run_one(normalized)
70
+ if code != 0:
71
+ console.print(f"[yellow]Command exited with {code}[/yellow]")
72
+ finally:
73
+ session_runner.finish()