devcouncil 0.1.1 → 0.2.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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,8 +1,9 @@
1
1
  import typer
2
- import subprocess
3
2
  from pathlib import Path
4
3
  from rich.console import Console
5
4
 
5
+ from devcouncil.execution.checkpoints import CheckpointService
6
+
6
7
  app = typer.Typer()
7
8
  console = Console()
8
9
 
@@ -19,42 +20,40 @@ def rollback(
19
20
  return
20
21
 
21
22
  root = project_root.expanduser().resolve()
22
- checkpoint_file = root / ".devcouncil" / "checkpoints" / f"{task_id}-before.patch"
23
- after_patch = root / ".devcouncil" / "checkpoints" / f"{task_id}-after.patch"
23
+ checkpoint_dir = root / ".devcouncil" / "checkpoints"
24
+ checkpoint_file = checkpoint_dir / f"{task_id}-before.patch"
25
+ after_patch = checkpoint_dir / f"{task_id}-after.patch"
26
+ service = CheckpointService(root)
24
27
 
25
28
  if not checkpoint_file.exists() and not after_patch.exists():
26
- console.print(
27
- f"[red]No checkpoint found for task {task_id}. Expected {after_patch} "
28
- f"or {checkpoint_file}.[/red]"
29
- )
30
- raise typer.Exit(code=1)
29
+ before_ref = CheckpointService.REF_BEFORE.format(task_id=task_id)
30
+ after_ref = CheckpointService.REF_AFTER.format(task_id=task_id)
31
+ if not service._ref_exists(before_ref) and not service._ref_exists(after_ref):
32
+ console.print(
33
+ f"[red]No checkpoint found for task {task_id}. Expected {after_patch} "
34
+ f"or {checkpoint_file}.[/red]"
35
+ )
36
+ raise typer.Exit(code=1)
31
37
 
32
38
  console.print(f"Rolling back task [bold]{task_id}[/bold]...")
33
-
34
- try:
35
- if after_patch.exists():
36
- # Reverse-apply the task's changes only
37
- console.print(f"Applying reverse patch from [bold]{after_patch}[/bold]...")
38
- subprocess.check_call(
39
- ["git", "apply", "-R", str(after_patch)],
40
- cwd=root,
41
- )
42
- console.print(f"[green]Successfully rolled back task {task_id} changes.[/green]")
43
- else:
44
- # No after-patch, but we have the before-patch — warn and offer manual reset
39
+ result = service.rollback(task_id)
40
+ if "failed" in result.message.lower() or result.message.startswith("No checkpoint"):
41
+ console.print(f"[yellow]{result.message}[/yellow]")
42
+ if checkpoint_file.exists():
45
43
  console.print(
46
- f"[yellow]No after-patch found at {after_patch}.[/yellow]\n"
47
44
  f"The before-patch at {checkpoint_file} captured the state before the task ran.\n"
48
45
  f"To manually reset:\n"
49
46
  f" 1. [bold]git stash[/bold] (if you want to keep current changes)\n"
50
47
  f" 2. [bold]git checkout -- .[/bold] (discard working tree changes)\n"
51
48
  f" 3. [bold]git apply {checkpoint_file}[/bold] (restore pre-task state)"
52
49
  )
53
- except subprocess.CalledProcessError as e:
54
- console.print(f"[red]Failed to apply reverse patch: {e}[/red]")
55
- console.print("[yellow]The patch may conflict with current changes. Try resolving manually:[/yellow]")
56
- console.print(f" git apply -R --3way {after_patch}")
57
- raise typer.Exit(code=1)
58
- except Exception as e:
59
- console.print(f"[red]Failed to rollback: {e}[/red]")
50
+ elif after_patch.exists():
51
+ console.print(
52
+ f"Only the after-patch at {after_patch} exists (it captured the task's changes).\n"
53
+ f"To manually revert those changes from the working tree:\n"
54
+ f" 1. [bold]git apply --stat {after_patch}[/bold] (inspect what the task changed)\n"
55
+ f" 2. [bold]git apply -R {after_patch}[/bold] (reverse-apply the task's changes)"
56
+ )
60
57
  raise typer.Exit(code=1)
58
+
59
+ console.print(f"[green]Successfully rolled back task {task_id}.[/green] {result.message}")
@@ -1,5 +1,4 @@
1
1
  import typer
2
- import json
3
2
  from rich.console import Console
4
3
  from pathlib import Path
5
4
  from devcouncil.storage.db import get_db
@@ -7,6 +6,11 @@ from devcouncil.storage.repositories import TaskRepository, RequirementRepositor
7
6
  from devcouncil.executors.mini_swe import MiniSWEExecutor
8
7
  from devcouncil.executors.openhands import OpenHandsExecutor
9
8
  from devcouncil.executors.coding_cli import CodingCliExecutor
9
+ from devcouncil.executors.agent_registry import (
10
+ AGENT_ALIASES,
11
+ BUILTIN_CODING_EXECUTOR_NAMES,
12
+ load_cli_agent_specs,
13
+ )
10
14
  from devcouncil.executors.native.agent import NativeAgent
11
15
  from devcouncil.llm.provider import create_provider, validate_model_provider
12
16
  from devcouncil.llm.router import ModelRouter
@@ -16,20 +20,18 @@ from devcouncil.storage.repositories import GapRepository, EvidenceRepository, S
16
20
  from devcouncil.verification.verifier import Verifier
17
21
  from devcouncil.app.state_machine import ProjectPhase
18
22
  from devcouncil.cli.commands.init import initialize_project
23
+ from devcouncil.telemetry.traces import TraceLogger
19
24
 
20
25
  console = Console()
21
- CODING_EXECUTOR_ALIASES = {
22
- "codex": "codex",
23
- "codex-cli": "codex",
24
- "gemini": "gemini",
25
- "gemini-cli": "gemini",
26
- "claude": "claude",
27
- "claude-code": "claude",
28
- "claude-cli": "claude",
29
- }
26
+ CODING_EXECUTOR_ALIASES = {name: name for name in BUILTIN_CODING_EXECUTOR_NAMES} | AGENT_ALIASES
30
27
 
31
28
  CODING_EXECUTORS = set(CODING_EXECUTOR_ALIASES.keys())
32
29
 
30
+
31
+ def _custom_cli_agents(project_root: Path) -> set[str]:
32
+ specs = load_cli_agent_specs(project_root)
33
+ return {name for name, spec in specs.items() if not spec.built_in}
34
+
33
35
  def _current_changed_files(project_root: Path = Path(".")) -> list[str]:
34
36
  from devcouncil.verification.verifier import Verifier
35
37
 
@@ -38,28 +40,16 @@ def _current_changed_files(project_root: Path = Path(".")) -> list[str]:
38
40
  def _capture_after_patch(task_id: str, project_root: Path = Path(".")):
39
41
  """Capture the diff after task execution for use by rollback."""
40
42
  try:
41
- from devcouncil.verification.verifier import Verifier
43
+ from devcouncil.execution.checkpoints import CheckpointService
42
44
 
43
- checkpoint_dir = project_root / ".devcouncil" / "checkpoints"
44
- checkpoint_dir.mkdir(exist_ok=True)
45
- diff = Verifier(project_root).get_diff()
46
- if diff:
47
- with open(checkpoint_dir / f"{task_id}-after.patch", "w", encoding="utf-8") as f:
48
- f.write(diff)
45
+ CheckpointService(project_root).create_after(task_id)
49
46
  except Exception:
50
47
  pass # Non-critical — don't block execution
51
48
 
52
49
  def _capture_before_snapshot(task_id: str, project_root: Path = Path(".")):
53
- checkpoint_dir = project_root / ".devcouncil" / "checkpoints"
54
- checkpoint_dir.mkdir(exist_ok=True)
55
- snapshot = {
56
- "task_id": task_id,
57
- "changed_files": _current_changed_files(project_root),
58
- }
59
- (checkpoint_dir / f"{task_id}-before.json").write_text(
60
- json.dumps(snapshot, indent=2),
61
- encoding="utf-8",
62
- )
50
+ from devcouncil.execution.checkpoints import CheckpointService
51
+
52
+ CheckpointService(project_root).create_before(task_id)
63
53
 
64
54
  def _record_project_phase(session, phase: ProjectPhase):
65
55
  StateRepository(session).record_phase(phase.value)
@@ -90,13 +80,33 @@ def _verify_after_execution(session, task, reqs, router=None, project_root: Path
90
80
  task.status = "blocked" if any(g.blocking for g in gaps) else "verified"
91
81
  return task.status == "verified"
92
82
 
83
+
84
+ def _record_agent_verification(project_root: Path, task_id: str, executor: str, run_id: str | None, verified: bool) -> None:
85
+ TraceLogger(project_root).log_event(
86
+ "agent_run_verified",
87
+ {"agent": executor, "verified": verified},
88
+ run_id=run_id,
89
+ task_id=task_id,
90
+ summary=f"{executor} verification {'passed' if verified else 'blocked'} for {task_id}",
91
+ )
92
+
93
93
  def run(
94
94
  task_id: str = typer.Argument(..., help="ID of the task to run"),
95
95
  executor: str = typer.Option(
96
96
  "manual",
97
97
  "--executor",
98
98
  "-e",
99
- help="Executor to use (manual, mini, openhands, native, codex, codex-cli, gemini, gemini-cli, claude, claude-code, claude-cli)",
99
+ help=(
100
+ "Executor to use (manual, mini, openhands, native-preview, "
101
+ "codex, gemini, claude, opencode, antigravity, warp, cursor, aider, "
102
+ "copilot, goose, amp, qwen, crush, or a configured agent)"
103
+ ),
104
+ ),
105
+ profile: str | None = typer.Option(None, "--profile", help="CLI-agent execution profile: default, yolo, prod, or a configured profile."),
106
+ stream: bool = typer.Option(
107
+ False,
108
+ "--stream",
109
+ help="Stream coding CLI stdout/stderr live (also enabled by execution.stream_cli_output).",
100
110
  ),
101
111
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
102
112
  ):
@@ -118,8 +128,8 @@ def run(
118
128
  return
119
129
 
120
130
  from devcouncil.gating.policy import GatePolicy
121
- policy = GatePolicy()
122
- gate_result = policy.check_task_ready(task, root)
131
+ gate_policy = GatePolicy()
132
+ gate_result = gate_policy.check_task_ready(task, root)
123
133
  if not gate_result.passed:
124
134
  console.print(f"[red]Task {task_id} is not ready for execution.[/red]")
125
135
  for gap in gate_result.gaps:
@@ -131,20 +141,23 @@ def run(
131
141
 
132
142
  # 1. Create Git checkpoint
133
143
  try:
134
- from devcouncil.verification.verifier import Verifier
144
+ from devcouncil.execution.checkpoints import CheckpointService
135
145
 
136
- checkpoint_dir = root / ".devcouncil" / "checkpoints"
137
- checkpoint_dir.mkdir(exist_ok=True)
138
- _capture_before_snapshot(task_id, root)
139
- diff = Verifier(root).get_diff()
140
- if diff:
141
- with open(checkpoint_dir / f"{task_id}-before.patch", "w", encoding="utf-8") as f:
142
- f.write(diff)
143
- console.print(f"Created git checkpoint at {checkpoint_dir}/{task_id}-before.patch")
146
+ result = CheckpointService(root).create_before(task_id)
147
+ if result.patch_path:
148
+ console.print(f"Created git checkpoint at {result.patch_path}")
149
+ elif result.git_ref_created:
150
+ console.print(f"Created git checkpoint ref {result.ref}")
144
151
  except Exception as e:
145
152
  console.print(f"[yellow]Warning: Failed to create git checkpoint: {e}[/yellow]")
146
153
 
147
154
  executor = executor.strip().lower().replace("_", "-")
155
+ if executor not in CODING_EXECUTORS and executor not in _custom_cli_agents(root):
156
+ ignored = [flag for flag, value in (("--profile", profile), ("--stream", stream)) if value]
157
+ if ignored:
158
+ console.print(
159
+ f"[yellow]{' and '.join(ignored)} only apply to coding CLI executors and are ignored for '{executor}'.[/yellow]"
160
+ )
148
161
  if executor == "manual":
149
162
  _record_project_phase(session, ProjectPhase.TASK_EXECUTING)
150
163
  task.status = "running"
@@ -152,22 +165,30 @@ def run(
152
165
  console.print(f"\n[green]Task {task_id} is now marked as RUNNING.[/green]")
153
166
  console.print("Use 'dev prompt TASK-ID' to get the prompt for this task.")
154
167
  console.print("When finished, use 'dev verify TASK-ID' to check the results.")
155
- elif executor in CODING_EXECUTORS:
168
+ elif executor in CODING_EXECUTORS or executor in _custom_cli_agents(root):
156
169
  _record_project_phase(session, ProjectPhase.TASK_EXECUTING)
157
170
  req_repo = RequirementRepository(session)
158
171
  reqs = req_repo.get_all()
159
- cli_client = CODING_EXECUTOR_ALIASES[executor]
160
- cli_executor = CodingCliExecutor(root, cli_client)
172
+ cli_client = CODING_EXECUTOR_ALIASES.get(executor, executor)
173
+ cli_executor = CodingCliExecutor(root, cli_client, profile=profile, stream_output=stream or None)
161
174
  exec_result = cli_executor.run_task(task, reqs)
162
175
  _capture_after_patch(task_id, root)
163
176
  if exec_result.success:
164
177
  _record_project_phase(session, ProjectPhase.TASK_VERIFYING)
165
178
  verified = _verify_after_execution(session, task, reqs, project_root=root)
179
+ _record_agent_verification(root, task.id, cli_client, getattr(cli_executor, "last_run_id", None), verified)
166
180
  _record_project_phase(
167
181
  session,
168
182
  ProjectPhase.TASK_VERIFIED if verified else ProjectPhase.TASK_BLOCKED,
169
183
  )
170
184
  task_repo.save(task)
185
+ run_id = getattr(cli_executor, "last_run_id", None)
186
+ if run_id:
187
+ run_dir = root / ".devcouncil" / "runs" / run_id
188
+ console.print(f"Run artifacts: [dim]{run_dir}[/dim]")
189
+ transcript_path = getattr(cli_executor, "last_transcript_path", None)
190
+ if transcript_path:
191
+ console.print(f"Transcript: [dim]{transcript_path}[/dim]")
171
192
  if verified:
172
193
  console.print(f"\n[green]{executor.upper()} finished and task {task_id} verified.[/green]")
173
194
  else:
@@ -216,7 +237,7 @@ def run(
216
237
  console.print(f"\n[yellow]OpenHands finished, but task {task_id} is blocked by verification gaps.[/yellow]")
217
238
  else:
218
239
  console.print("\n[red]OpenHands failed to start or execute.[/red]")
219
- elif executor == "native":
240
+ elif executor in {"native", "native-preview"}:
220
241
  # Load config for model routing and permissions
221
242
  try:
222
243
  config = load_config(root)
@@ -226,9 +247,9 @@ def run(
226
247
  console.print(f"[red]{e}[/red]")
227
248
  return
228
249
 
229
- provider = create_provider(config.models.provider, api_key)
250
+ provider = create_provider(config.models.provider, api_key, project_root=root)
230
251
  role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
231
- router = ModelRouter(provider, role_config)
252
+ router = ModelRouter(provider, role_config, project_root=root)
232
253
 
233
254
  # Setup Permission System
234
255
  from devcouncil.execution.permissions import PermissionPolicy, PermissionManager
@@ -236,19 +257,18 @@ def run(
236
257
 
237
258
  # Populate policy from config commands
238
259
  allowed_cmds = config.commands.test + config.commands.lint + config.commands.typecheck
239
- policy = PermissionPolicy(
260
+ permission_policy = PermissionPolicy(
240
261
  allowed_shell_commands=allowed_cmds,
241
262
  )
242
- perm_manager = PermissionManager(policy, root)
263
+ perm_manager = PermissionManager(permission_policy, root)
243
264
  task_runner = TaskRunner(root, perm_manager)
244
265
 
245
266
  req_repo = RequirementRepository(session)
246
267
  reqs = req_repo.get_all()
247
268
 
248
- import asyncio
249
269
  _record_project_phase(session, ProjectPhase.TASK_EXECUTING)
250
270
  agent = NativeAgent(router, task_runner)
251
- exec_result = asyncio.run(agent.run_task(task, reqs))
271
+ exec_result = agent.run_task(task, reqs)
252
272
  _capture_after_patch(task_id, root)
253
273
 
254
274
  if exec_result.success:
@@ -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', '')}")