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
@@ -0,0 +1,225 @@
1
+ """Guarded shell command session for DevCouncil tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+
14
+ from devcouncil.domain.evidence import CommandResult
15
+ from devcouncil.domain.task import Task
16
+ from devcouncil.execution.checkpoints import CheckpointService
17
+ from devcouncil.execution.hook_policy import HookPolicy
18
+ from devcouncil.storage.db import get_db
19
+ from devcouncil.storage.native import ShellCommandRepository, ShellSessionRepository, TaskLeaseRepository
20
+ from devcouncil.storage.repositories import EvidenceRepository, TaskRepository
21
+ from devcouncil.telemetry.traces import TraceLogger
22
+
23
+
24
+ class ShellBackend:
25
+ def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
26
+ raise NotImplementedError
27
+
28
+
29
+ class CommandLoopBackend(ShellBackend):
30
+ def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
31
+ args = shlex.split(command, posix=(os.name != "nt"))
32
+ return subprocess.run(
33
+ args,
34
+ cwd=cwd,
35
+ capture_output=True,
36
+ text=True,
37
+ encoding="utf-8",
38
+ errors="replace",
39
+ env=env,
40
+ )
41
+
42
+
43
+ class ShellWrappedBackend(ShellBackend):
44
+ """Runs each command through an explicit shell (pwsh/bash/zsh/...)."""
45
+
46
+ _LAUNCHERS = {
47
+ "pwsh": ["pwsh", "-NoProfile", "-Command"],
48
+ "powershell": ["powershell", "-NoProfile", "-Command"],
49
+ "bash": ["bash", "-lc"],
50
+ "zsh": ["zsh", "-lc"],
51
+ "sh": ["sh", "-lc"],
52
+ }
53
+
54
+ def __init__(self, shell: str):
55
+ launcher = self._LAUNCHERS.get(shell)
56
+ if launcher is None:
57
+ supported = ", ".join(sorted(self._LAUNCHERS))
58
+ raise ValueError(f"Unknown shell backend '{shell}'. Use auto or one of: {supported}.")
59
+ if not shutil.which(launcher[0]):
60
+ raise ValueError(f"Shell '{launcher[0]}' is not installed or not on PATH.")
61
+ self.launcher = launcher
62
+
63
+ def run_command(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
64
+ return subprocess.run(
65
+ [*self.launcher, command],
66
+ cwd=cwd,
67
+ capture_output=True,
68
+ text=True,
69
+ encoding="utf-8",
70
+ errors="replace",
71
+ env=env,
72
+ )
73
+
74
+
75
+ _EVIDENCE_COMMAND_HINTS = ("pytest", "ruff", "mypy", "npm test", "npm run lint", "npm run typecheck")
76
+
77
+ console = Console()
78
+
79
+
80
+ class GuardedShellSession:
81
+ def __init__(self, project_root: Path, task: Task, *, shell: str = "auto"):
82
+ self.project_root = project_root.resolve()
83
+ self.task = task
84
+ self.shell = shell
85
+ # HookPolicy (not the bare TaskPolicyEngine) so a chained command is split into
86
+ # its segments and EACH is allowlisted, plus git-safety denies (force-push,
87
+ # --no-verify, protected-branch reset). Critical for the `--shell bash/zsh`
88
+ # backend, which hands the whole string to a real shell that interprets
89
+ # `;`/`&&`/`|` — a single whole-string allowlist match would let an allowed
90
+ # prefix smuggle an arbitrary command past the gate.
91
+ self.policy = HookPolicy(self.project_root)
92
+ self.backend: ShellBackend = (
93
+ CommandLoopBackend() if shell in {"auto", "loop"} else ShellWrappedBackend(shell)
94
+ )
95
+ self.lease_token: str | None = None
96
+ self.session_id: str | None = None
97
+ self.log_dir = self.project_root / ".devcouncil" / "logs"
98
+ self.log_dir.mkdir(parents=True, exist_ok=True)
99
+
100
+ def start(self, *, force: bool = False) -> None:
101
+ db = get_db(self.project_root)
102
+ if not db:
103
+ raise RuntimeError("DevCouncil not initialized.")
104
+ with db.get_session() as session:
105
+ lease = TaskLeaseRepository(session).acquire(
106
+ self.task.id,
107
+ owner="dev shell",
108
+ agent=self.shell,
109
+ force=force,
110
+ )
111
+ self.lease_token = lease.lease_token
112
+ shell_session = ShellSessionRepository(session).start(
113
+ self.task.id,
114
+ self.shell,
115
+ str(self.project_root),
116
+ lease_id=lease.id,
117
+ )
118
+ self.session_id = shell_session.id
119
+ self.task.status = "running"
120
+ TaskRepository(session).save(self.task)
121
+ CheckpointService(self.project_root).create_before(self.task.id)
122
+
123
+ def finish(self) -> None:
124
+ db = get_db(self.project_root)
125
+ if not db or not self.lease_token:
126
+ return
127
+ with db.get_session() as session:
128
+ if self.session_id:
129
+ ShellSessionRepository(session).finish(self.session_id, "finished")
130
+ TaskLeaseRepository(session).release(self.task.id, self.lease_token)
131
+
132
+ def run_one(self, command: str) -> int:
133
+ normalized = " ".join(command.split())
134
+ decision = self.policy.evaluate_command(normalized, self.task)
135
+ log_id = uuid.uuid4().hex[:8]
136
+ stdout_path = self.log_dir / f"{self.task.id}-{log_id}.stdout.log"
137
+ stderr_path = self.log_dir / f"{self.task.id}-{log_id}.stderr.log"
138
+
139
+ if decision.action == "deny":
140
+ self._record_command(normalized, "denied", reason=decision.reason)
141
+ TraceLogger(self.project_root).log_event(
142
+ "shell_command_denied",
143
+ {"command": normalized, "reason": decision.reason},
144
+ task_id=self.task.id,
145
+ )
146
+ # Tell the user *why* — a silent non-zero exit is unactionable.
147
+ console.print(
148
+ f"[red]Command denied for {self.task.id}:[/red] {decision.reason or 'not permitted by task policy.'}"
149
+ )
150
+ console.print(
151
+ "[dim]Add it to the task's allowed_commands, or run it outside DevCouncil.[/dim]"
152
+ )
153
+ return 1
154
+
155
+ try:
156
+ result = self.backend.run_command(normalized, self.project_root)
157
+ except (NotImplementedError, FileNotFoundError, OSError) as exc:
158
+ self._record_command(normalized, "denied", reason=str(exc))
159
+ console.print(f"[red]Could not run '{normalized}':[/red] {exc}")
160
+ return 1
161
+
162
+ stdout_path.write_text(result.stdout or "", encoding="utf-8")
163
+ stderr_path.write_text(result.stderr or "", encoding="utf-8")
164
+ # Echo the command output so the guarded shell is actually usable.
165
+ if result.stdout:
166
+ console.print(result.stdout, end="", markup=False, highlight=False)
167
+ if result.stderr:
168
+ console.print(result.stderr, end="", markup=False, highlight=False, style="dim")
169
+ status = "finished" if result.returncode == 0 else "failed"
170
+ self._record_command(
171
+ normalized,
172
+ status,
173
+ exit_code=result.returncode,
174
+ stdout_path=str(stdout_path),
175
+ stderr_path=str(stderr_path),
176
+ )
177
+ if any(hint in normalized for hint in _EVIDENCE_COMMAND_HINTS):
178
+ self._save_command_evidence(normalized, result.returncode, result.stdout or result.stderr or "")
179
+ TraceLogger(self.project_root).log_event(
180
+ "shell_command_finished",
181
+ {"command": normalized, "exit_code": result.returncode},
182
+ task_id=self.task.id,
183
+ )
184
+ return result.returncode
185
+
186
+ def _record_command(
187
+ self,
188
+ command: str,
189
+ status: str,
190
+ *,
191
+ exit_code: int | None = None,
192
+ reason: str = "",
193
+ stdout_path: str = "",
194
+ stderr_path: str = "",
195
+ ) -> None:
196
+ db = get_db(self.project_root)
197
+ if not db:
198
+ return
199
+ with db.get_session() as session:
200
+ ShellCommandRepository(session).record(
201
+ self.task.id,
202
+ command,
203
+ status,
204
+ session_id=self.session_id,
205
+ exit_code=exit_code,
206
+ reason=reason,
207
+ stdout_path=stdout_path,
208
+ stderr_path=stderr_path,
209
+ )
210
+
211
+ def _save_command_evidence(self, command: str, exit_code: int, summary: str) -> None:
212
+ db = get_db(self.project_root)
213
+ if not db:
214
+ return
215
+ with db.get_session() as session:
216
+ EvidenceRepository(session).save_command_result(
217
+ self.task.id,
218
+ CommandResult(
219
+ command=command,
220
+ exit_code=exit_code,
221
+ stdout_path="",
222
+ stderr_path="",
223
+ summary=summary[:500],
224
+ ),
225
+ )
@@ -1,4 +1,5 @@
1
1
  import hashlib
2
+ import os
2
3
  import subprocess
3
4
  import logging
4
5
  import re
@@ -114,7 +115,10 @@ class TaskRunner:
114
115
 
115
116
  try:
116
117
  result = subprocess.run(
117
- shlex.split(command, posix=False),
118
+ # POSIX-correct tokenization on POSIX hosts (so quoted args like
119
+ # `pytest -k "a and b"` split right and match the allowlist), Windows
120
+ # rules on Windows. Matches shell_session.py.
121
+ shlex.split(command, posix=(os.name != "nt")),
118
122
  shell=False,
119
123
  capture_output=True,
120
124
  text=True,
@@ -149,7 +153,7 @@ class TaskRunner:
149
153
  """Write content to a file if allowed by permissions."""
150
154
  self._validate_path_within_root(path)
151
155
  full_path = resolve_project_path(self.project_root, path)
152
- operation = "modify" if full_path.exists() else "create"
156
+ operation: Literal["create", "modify"] = "modify" if full_path.exists() else "create"
153
157
  self.permissions.validate_action("file_write", path, task, operation=operation)
154
158
 
155
159
  logger.info(f"Writing authorized file: {path}")