devcouncil 0.1.0 → 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 (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,180 @@
1
+ """Filesystem change attribution with polling fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from devcouncil.domain.gap import Gap
11
+ from devcouncil.domain.task import Task
12
+ from devcouncil.execution.policy_engine import TaskPolicyEngine
13
+ from devcouncil.storage.db import get_db
14
+ from devcouncil.storage.native import FileChangeRepository, TaskLeaseRepository
15
+ from devcouncil.storage.repositories import GapRepository, TaskRepository
16
+ from devcouncil.verification.verifier import Verifier
17
+
18
+ _IGNORED_PREFIXES = (
19
+ ".git/",
20
+ ".devcouncil/cache/",
21
+ ".devcouncil/logs/",
22
+ "__pycache__/",
23
+ ".pytest_cache/",
24
+ ".mypy_cache/",
25
+ ".ruff_cache/",
26
+ "dist/",
27
+ "build/",
28
+ "target/",
29
+ "node_modules/",
30
+ )
31
+
32
+ # Event mode must additionally ignore everything DevCouncil writes while
33
+ # recording events (state DB, traces, run artifacts) — otherwise recording a
34
+ # file-change event triggers another filesystem event, feeding back forever.
35
+ _EVENT_IGNORED_PREFIXES = _IGNORED_PREFIXES + (".devcouncil/", ".gitignore")
36
+
37
+ _TASK_CACHE_TTL_SECONDS = 10.0
38
+ _EVENT_DEBOUNCE_SECONDS = 0.5
39
+
40
+
41
+ class FilesystemWatcher:
42
+ def __init__(
43
+ self,
44
+ project_root: Path,
45
+ task_id: str,
46
+ *,
47
+ poll_interval: float = 1.0,
48
+ on_event: Callable[[dict], None] | None = None,
49
+ ):
50
+ self.project_root = project_root.resolve()
51
+ self.task_id = task_id
52
+ self.poll_interval = poll_interval
53
+ self.on_event = on_event
54
+ self.policy = TaskPolicyEngine(self.project_root)
55
+ self._seen: dict[str, float] = {}
56
+ self._task_cache: tuple[float, Task | None] | None = None
57
+
58
+ def should_ignore(self, path: str) -> bool:
59
+ normalized = path.replace("\\", "/")
60
+ return any(normalized.startswith(prefix) for prefix in _IGNORED_PREFIXES)
61
+
62
+ def scan_once(self) -> list[dict]:
63
+ task = self._load_task()
64
+ changed = Verifier(self.project_root).get_changed_files()
65
+ events: list[dict] = []
66
+ for path in changed:
67
+ if self.should_ignore(path):
68
+ continue
69
+ events.append(self._record_path(path, task, operation="modify"))
70
+ return events
71
+
72
+ def watch(self) -> None:
73
+ observer = self._start_event_observer()
74
+ if observer is None:
75
+ # Polling fallback when watchdog is unavailable.
76
+ while True:
77
+ for event in self.scan_once():
78
+ self._notify(event)
79
+ time.sleep(self.poll_interval)
80
+ try:
81
+ while True:
82
+ time.sleep(self.poll_interval)
83
+ finally:
84
+ observer.stop()
85
+ observer.join(timeout=5)
86
+
87
+ def _start_event_observer(self):
88
+ try:
89
+ from watchdog.events import FileSystemEventHandler
90
+ from watchdog.observers import Observer
91
+ except ImportError:
92
+ return None
93
+
94
+ watcher = self
95
+
96
+ class _Handler(FileSystemEventHandler):
97
+ def on_any_event(self, event):
98
+ if getattr(event, "is_directory", False):
99
+ return
100
+ operation = {"created": "create", "deleted": "delete"}.get(event.event_type, "modify")
101
+ # For moves, attribute the destination path.
102
+ path = getattr(event, "dest_path", "") or event.src_path
103
+ watcher.handle_event(str(path), operation=operation)
104
+
105
+ observer = Observer()
106
+ observer.schedule(_Handler(), str(self.project_root), recursive=True)
107
+ observer.start()
108
+ return observer
109
+
110
+ def handle_event(self, path: str, *, operation: str = "modify") -> dict | None:
111
+ """Attribute one raw filesystem event (event-driven mode)."""
112
+ try:
113
+ rel = Path(path).resolve().relative_to(self.project_root).as_posix()
114
+ except (ValueError, OSError):
115
+ return None
116
+ if any(rel.startswith(prefix) for prefix in _EVENT_IGNORED_PREFIXES):
117
+ return None
118
+ if self._debounced(rel):
119
+ return None
120
+ event = self._record_path(rel, self._task_cached(), operation=operation)
121
+ self._notify(event)
122
+ return event
123
+
124
+ def _notify(self, event: dict) -> None:
125
+ if self.on_event is not None:
126
+ self.on_event(event)
127
+
128
+ def _debounced(self, rel: str, *, window: float = _EVENT_DEBOUNCE_SECONDS) -> bool:
129
+ now = time.monotonic()
130
+ last = self._seen.get(rel)
131
+ self._seen[rel] = now
132
+ return last is not None and (now - last) < window
133
+
134
+ def _task_cached(self) -> Task | None:
135
+ now = time.monotonic()
136
+ if self._task_cache is not None and now - self._task_cache[0] < _TASK_CACHE_TTL_SECONDS:
137
+ return self._task_cache[1]
138
+ task = self._load_task()
139
+ self._task_cache = (now, task)
140
+ return task
141
+
142
+ def _load_task(self) -> Task | None:
143
+ db = get_db(self.project_root)
144
+ if not db:
145
+ return None
146
+ with db.get_session() as session:
147
+ return TaskRepository(session).get_by_id(self.task_id)
148
+
149
+ def _record_path(self, path: str, task: Task | None, *, operation: str) -> dict:
150
+ decision = self.policy.evaluate_file_change(path, task, operation=operation) # type: ignore[arg-type]
151
+ allowed = decision.action in {"allow", "warn"}
152
+ db = get_db(self.project_root)
153
+ if db:
154
+ # Resolve the lease and record the event in one session so the
155
+ # recorded lease_id cannot go stale between lookups.
156
+ with db.get_session() as session:
157
+ active = TaskLeaseRepository(session).active_for_task(self.task_id)
158
+ lease_id = active.id if active else None
159
+ FileChangeRepository(session).record(
160
+ path,
161
+ operation,
162
+ allowed,
163
+ task_id=self.task_id,
164
+ lease_id=lease_id,
165
+ reason=decision.reason,
166
+ )
167
+ if not allowed:
168
+ gap_repo = GapRepository(session)
169
+ gap_repo.save(
170
+ Gap(
171
+ id=f"GAP-{self.task_id}-ORPHAN-{uuid.uuid4().hex[:12]}",
172
+ severity="high",
173
+ gap_type="orphan_diff",
174
+ task_id=self.task_id,
175
+ description=f"Unplanned file change: {path}",
176
+ recommended_fix="Revert change or update planned_files.",
177
+ blocking=True,
178
+ )
179
+ )
180
+ return {"path": path, "operation": operation, "allowed": allowed, "reason": decision.reason}
@@ -0,0 +1,102 @@
1
+ """Native agent handoff manifest builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from devcouncil.execution.checkpoints import CheckpointService
12
+ from devcouncil.storage.db import get_db
13
+ from devcouncil.storage.native import AgentHandoffRepository, SemanticDiffRepository, TaskLeaseRepository
14
+ from devcouncil.storage.repositories import EvidenceRepository, GapRepository, RequirementRepository, TaskRepository
15
+ from devcouncil.verification.verifier import Verifier
16
+
17
+
18
+ class HandoffManifest(BaseModel):
19
+ task: dict
20
+ requirements: list[dict] = Field(default_factory=list)
21
+ planned_files: list[dict] = Field(default_factory=list)
22
+ changed_files: list[str] = Field(default_factory=list)
23
+ semantic_diff: dict | None = None
24
+ checkpoint_refs: dict[str, str] = Field(default_factory=dict)
25
+ command_evidence: list[dict] = Field(default_factory=list)
26
+ open_gaps: list[dict] = Field(default_factory=list)
27
+ from_agent: str
28
+ to_agent: str
29
+ instruction: str = ""
30
+ created_at: str
31
+
32
+
33
+ class HandoffService:
34
+ def __init__(self, project_root: Path):
35
+ self.project_root = project_root.resolve()
36
+
37
+ def create(
38
+ self,
39
+ task_id: str,
40
+ from_agent: str,
41
+ to_agent: str,
42
+ *,
43
+ instruction: str = "",
44
+ ) -> tuple[HandoffManifest, Path, str]:
45
+ db = get_db(self.project_root)
46
+ if not db:
47
+ raise RuntimeError("DevCouncil not initialized.")
48
+ run_id = str(uuid.uuid4())
49
+ run_dir = self.project_root / ".devcouncil" / "runs" / run_id
50
+ run_dir.mkdir(parents=True, exist_ok=True)
51
+ manifest_path = run_dir / "handoff.json"
52
+
53
+ with db.get_session() as session:
54
+ task = TaskRepository(session).get_by_id(task_id)
55
+ if not task:
56
+ raise ValueError(f"Task {task_id} not found")
57
+ reqs = RequirementRepository(session).get_all()
58
+ gaps = [g for g in GapRepository(session).get_all() if g.task_id == task_id and g.blocking]
59
+ evidence = EvidenceRepository(session).get_command_results_for_task(task_id)
60
+ semantic = SemanticDiffRepository(session).latest_for_task(task_id)
61
+
62
+ changed = Verifier(self.project_root).get_task_changed_files(task_id)
63
+ refs = {
64
+ "before": CheckpointService.REF_BEFORE.format(task_id=task_id),
65
+ "after": CheckpointService.REF_AFTER.format(task_id=task_id),
66
+ }
67
+ manifest = HandoffManifest(
68
+ task=task.model_dump(),
69
+ requirements=[r.model_dump() for r in reqs if r.id in task.requirement_ids],
70
+ planned_files=[pf.model_dump() for pf in task.planned_files],
71
+ changed_files=changed,
72
+ semantic_diff={"classifications": semantic.classifications, "summary": semantic.summary} if semantic else None,
73
+ checkpoint_refs=refs,
74
+ command_evidence=[ev.model_dump() for ev in evidence],
75
+ open_gaps=[g.model_dump() for g in gaps],
76
+ from_agent=from_agent,
77
+ to_agent=to_agent,
78
+ instruction=instruction,
79
+ created_at=datetime.now(timezone.utc).isoformat(),
80
+ )
81
+ manifest_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
82
+
83
+ status = "manifest_only"
84
+ with db.get_session() as session:
85
+ active = TaskLeaseRepository(session).active_for_task(task_id)
86
+ if active and active.agent == from_agent:
87
+ TaskLeaseRepository(session).release(task_id, active.lease_token)
88
+ TaskLeaseRepository(session).acquire(
89
+ task_id,
90
+ owner=f"handoff:{to_agent}",
91
+ agent=to_agent,
92
+ )
93
+ status = "lease_transferred"
94
+ AgentHandoffRepository(session).save(
95
+ task_id,
96
+ from_agent,
97
+ to_agent,
98
+ run_id,
99
+ str(manifest_path),
100
+ status,
101
+ )
102
+ return manifest, manifest_path, run_id
@@ -2,10 +2,29 @@ import fnmatch
2
2
  import re
3
3
  from dataclasses import dataclass
4
4
  from pathlib import Path
5
- from pathlib import PurePosixPath
6
5
  from typing import Any, Optional
7
6
 
8
7
  from devcouncil.domain.task import Task
8
+ from devcouncil.execution.policy_engine import (
9
+ PROTECTED_WRITE_PATTERNS,
10
+ SECRET_PATH_PATTERNS,
11
+ TaskPolicyEngine,
12
+ normalize_repo_path,
13
+ )
14
+ from devcouncil.utils.redaction import SECRET_PATTERNS
15
+
16
+ # Splits a shell command into the segments a shell would execute independently, on
17
+ # the chaining/pipe/sequence operators. We deliberately do NOT try to parse quoting
18
+ # perfectly — any operator we miss only makes us evaluate a *larger* segment as a
19
+ # single command (which then fails the allowlist), so this errs toward DENY.
20
+ _SEGMENT_SPLIT_RE = re.compile(r"\s*(?:\|\||&&|\||;|\n)\s*")
21
+
22
+ # Strips a `bash -c "..."` / `sh -c '...'` wrapper so the inner command is what gets
23
+ # checked against the allowlist, closing the trivial obfuscation where a denied
24
+ # command is smuggled inside a shell wrapper.
25
+ _SHELL_WRAPPER_RE = re.compile(
26
+ r"""^\s*(?:[A-Za-z0-9_./\\-]*?(?:bash|sh|zsh|dash|ksh))(?:\.exe)?\s+-[A-Za-z]*c\s+(?P<quote>['"])(?P<inner>.*)(?P=quote)\s*$"""
27
+ )
9
28
 
10
29
 
11
30
  @dataclass(frozen=True)
@@ -20,35 +39,60 @@ class HookDecision:
20
39
 
21
40
 
22
41
  class HookPolicy:
23
- """Policy-backed hook checks for Claude-style pre-tool-use events."""
42
+ """Policy-backed hook checks for coding CLI tool-use events."""
24
43
 
25
44
  def __init__(self, project_root: Path | None = None):
26
- self.project_root = project_root.resolve() if project_root else None
27
-
28
- secret_path_patterns = (
29
- ".env",
30
- ".env.*",
31
- "**/.env",
32
- "**/.env.*",
33
- "**/credentials/**",
34
- "**/secrets/**",
35
- "**/*.pem",
36
- "**/*.key",
37
- )
38
- protected_path_patterns = (
39
- "package.json",
40
- "pyproject.toml",
41
- "uv.lock",
42
- "Dockerfile",
43
- "docker-compose.yml",
44
- ".github/workflows/*.yml",
45
- ".github/workflows/*.yaml",
46
- "schema.prisma",
47
- "wrangler.toml",
48
- "index.html",
49
- )
50
- write_tools = {"write_file", "edit_file", "replace", "Write", "Edit", "MultiEdit"}
51
- shell_tools = {"bash", "shell", "run_command", "Bash"}
45
+ self.project_root = (project_root or Path(".")).resolve()
46
+ self.policy_engine = TaskPolicyEngine(
47
+ self.project_root,
48
+ global_allowed_commands=self._load_global_allowed_commands(),
49
+ )
50
+
51
+ def _load_global_allowed_commands(self) -> list[str]:
52
+ """Best-effort load of repo-wide allowed commands from config.
53
+
54
+ Never raises — a missing/invalid config must not disable the gate, and the
55
+ gate stays fail-closed (empty allowlist) when config can't be read."""
56
+ try:
57
+ from devcouncil.app.config import load_config
58
+
59
+ execution = load_config(self.project_root).execution
60
+ configured = getattr(execution, "global_allowed_commands", None)
61
+ if isinstance(configured, (list, tuple)):
62
+ return [str(item) for item in configured]
63
+ except Exception:
64
+ pass
65
+ return []
66
+
67
+ secret_path_patterns = SECRET_PATH_PATTERNS
68
+ protected_path_patterns = PROTECTED_WRITE_PATTERNS
69
+ write_tools = {
70
+ "apply_patch",
71
+ "edit",
72
+ "edit_file",
73
+ "replace",
74
+ "write",
75
+ "write_file",
76
+ "Edit",
77
+ "MultiEdit",
78
+ "Write",
79
+ "create_file",
80
+ "str_replace",
81
+ "search_replace",
82
+ }
83
+ shell_tools = {
84
+ "bash",
85
+ "exec",
86
+ "exec_command",
87
+ "local_shell",
88
+ "run_command",
89
+ "run_shell_command",
90
+ "run_terminal_cmd",
91
+ "shell",
92
+ "shell_command",
93
+ "Bash",
94
+ "Shell",
95
+ }
52
96
 
53
97
  def evaluate(self, call_data: dict[str, Any], active_task: Optional[Task]) -> HookDecision:
54
98
  tool_name = str(call_data.get("name") or call_data.get("tool_name") or call_data.get("tool") or "")
@@ -58,57 +102,131 @@ class HookPolicy:
58
102
 
59
103
  if tool_name in self.shell_tools:
60
104
  command = self._extract_command(arguments)
61
- return self.evaluate_command(command)
105
+ return self.evaluate_command(command, active_task)
62
106
 
63
107
  if tool_name in self.write_tools:
64
108
  target = self._extract_path(arguments)
65
- return self.evaluate_file_write(target, active_task)
109
+ content = self._extract_content(arguments)
110
+ return self.evaluate_file_write(target, active_task, content=content)
66
111
 
67
112
  return HookDecision("allow", "Tool is outside DevCouncil hook policy.")
68
113
 
69
- def evaluate_command(self, command: str) -> HookDecision:
70
- normalized = " ".join(command.split())
71
- lowered = normalized.lower()
114
+ def evaluate_command(self, command: str, active_task: Optional[Task] = None) -> HookDecision:
115
+ """Evaluate a shell command before execution.
116
+
117
+ Two gates, deny wins:
118
+ 1. Git-safety regexes (force push, --no-verify, protected-branch resets).
119
+ 2. The task command allowlist: every segment of a chained command must be
120
+ authorized by the active task (or the global allowlist). With no active
121
+ task only the read-only no-task allowlist applies, so a Bash-routed
122
+ write/destructive command can no longer escape the planned-files gate.
123
+ This fails closed: anything we cannot positively authorize is denied."""
124
+ if self.policy_engine is None:
125
+ return HookDecision("deny", "No project root configured.", command)
126
+
127
+ # 1) Git-safety check first — a hard deny here wins regardless of allowlist.
128
+ git_decision = self.policy_engine.evaluate_hook_command(command)
129
+ if git_decision.action == "deny":
130
+ return HookDecision(git_decision.action, git_decision.reason, git_decision.target)
131
+
132
+ # 2) Allowlist enforcement over every executed segment.
133
+ segments = self._split_command_segments(command)
134
+ if not segments:
135
+ return HookDecision("deny", "Empty command is not allowed.", command)
136
+
137
+ warn: HookDecision | None = None
138
+ for segment in segments:
139
+ decision = self.policy_engine.evaluate_command(segment, active_task)
140
+ if decision.action == "deny":
141
+ return HookDecision("deny", decision.reason, decision.target)
142
+ if decision.action == "warn" and warn is None:
143
+ warn = HookDecision("warn", decision.reason, decision.target)
144
+
145
+ # A git-safety warn (e.g. direct push to a protected branch) should surface even
146
+ # when every segment is otherwise allowed.
147
+ if git_decision.action == "warn":
148
+ return HookDecision(git_decision.action, git_decision.reason, git_decision.target)
149
+ if warn is not None:
150
+ return warn
151
+ return HookDecision("allow", "Command authorized by task allowlist.", command)
152
+
153
+ def _split_command_segments(self, command: str) -> list[str]:
154
+ """Split a shell command on ; && || | and newlines, unwrapping bash -c wrappers.
155
+
156
+ Each returned segment is itself unwrapped/re-split so a denied command nested
157
+ inside a `bash -c "..."` wrapper is still checked. Errs toward DENY: anything
158
+ ambiguous collapses into a larger segment that the allowlist will reject."""
159
+ normalized = command.strip()
72
160
  if not normalized:
73
- return HookDecision("allow", "No command detected.")
74
-
75
- if "--no-verify" in lowered or "--no-gpg-sign" in lowered:
76
- return HookDecision("deny", "Verification bypass flags are not allowed.", normalized)
77
-
78
- if re.search(r"\bgit\s+reset\s+--hard\s+(origin/)?(main|master)\b", lowered):
79
- return HookDecision("deny", "Protected branch hard resets are not allowed.", normalized)
80
-
81
- if re.search(r"\bgit\s+push\b.*(\s--force(?:-with-lease)?\b|\s-f\b)", lowered):
82
- return HookDecision("deny", "Force pushes are not allowed.", normalized)
83
-
84
- if re.search(r"\bgit\s+push\s+\S+\s+((head:)?(main|master)|(main|master):\S+)\b", lowered):
85
- return HookDecision("warn", "Direct pushes to protected branches should go through verification gates.", normalized)
86
-
87
- return HookDecision("allow", "Command is allowed.", normalized)
88
-
89
- def evaluate_file_write(self, raw_path: Optional[str], active_task: Optional[Task]) -> HookDecision:
161
+ return []
162
+
163
+ wrapper = _SHELL_WRAPPER_RE.match(normalized)
164
+ if wrapper is not None:
165
+ # Recurse into the wrapped command so its own chaining is evaluated.
166
+ return self._split_command_segments(wrapper.group("inner"))
167
+
168
+ segments: list[str] = []
169
+ for raw in _SEGMENT_SPLIT_RE.split(normalized):
170
+ piece = raw.strip()
171
+ if not piece:
172
+ continue
173
+ nested = _SHELL_WRAPPER_RE.match(piece)
174
+ if nested is not None:
175
+ segments.extend(self._split_command_segments(nested.group("inner")))
176
+ else:
177
+ segments.append(piece)
178
+ return segments
179
+
180
+ def evaluate_file_write(
181
+ self,
182
+ raw_path: Optional[str],
183
+ active_task: Optional[Task],
184
+ *,
185
+ content: Optional[str] = None,
186
+ ) -> HookDecision:
90
187
  if not raw_path:
91
188
  return HookDecision("allow", "No file path detected.")
92
-
93
- path = self._normalize_path(raw_path)
94
- if self._matches_any(path, self.secret_path_patterns):
95
- return HookDecision("deny", "Secret and credential paths are never writable through hooks.", path)
96
-
97
- if active_task is None:
98
- return HookDecision("deny", "No running DevCouncil task authorizes this file write.", path)
99
-
100
- if active_task and not self._is_planned_file(path, active_task):
101
- return HookDecision("deny", f"Task {active_task.id} does not authorize changes to {path}.", path)
102
-
103
- if self._matches_any(path, self.protected_path_patterns):
104
- return HookDecision("warn", f"{path} is a protected high-impact file; verification gates must approve it.", path)
105
-
106
- return HookDecision("allow", "File write is allowed.", path)
189
+ if self.policy_engine is None:
190
+ return HookDecision("deny", "No project root configured.", raw_path)
191
+
192
+ # Pre-action secret scan: refuse to write content that contains a secret,
193
+ # before it ever lands on disk. Reuses the shared secret regexes.
194
+ if content is not None:
195
+ secret = self._scan_content_for_secret(content)
196
+ if secret is not None:
197
+ return HookDecision(
198
+ "deny",
199
+ f"Refusing to write content containing a potential {secret}.",
200
+ raw_path,
201
+ )
202
+
203
+ # Delegate to the engine, which normalizes via the shared normalize_repo_path and
204
+ # denies out-of-root targets — so the path that is checked is the path enforced.
205
+ decision = self.policy_engine.evaluate_file_change(raw_path, active_task)
206
+ return HookDecision(decision.action, decision.reason, decision.target)
207
+
208
+ def _scan_content_for_secret(self, content: str) -> Optional[str]:
209
+ if not isinstance(content, str) or not content:
210
+ return None
211
+ for key_type, pattern in SECRET_PATTERNS.items():
212
+ if pattern.search(content):
213
+ return key_type
214
+ return None
107
215
 
108
216
  def _extract_command(self, arguments: dict[str, Any]) -> str:
109
217
  value = arguments.get("command") or arguments.get("cmd") or arguments.get("script") or ""
110
218
  return str(value)
111
219
 
220
+ def _extract_content(self, arguments: dict[str, Any]) -> Optional[str]:
221
+ """Pull the to-be-written content from a write tool's arguments.
222
+
223
+ Covers the common shapes across coding CLIs (content/new_str/new_string/text)."""
224
+ for key in ("content", "new_str", "new_string", "text", "file_text", "contents"):
225
+ value = arguments.get(key)
226
+ if isinstance(value, str) and value:
227
+ return value
228
+ return None
229
+
112
230
  def _extract_path(self, arguments: dict[str, Any]) -> Optional[str]:
113
231
  value = (
114
232
  arguments.get("path")
@@ -116,22 +234,13 @@ class HookPolicy:
116
234
  or arguments.get("filepath")
117
235
  or arguments.get("filePath")
118
236
  or arguments.get("target")
237
+ or arguments.get("target_file")
119
238
  )
120
239
  return str(value) if value else None
121
240
 
122
241
  def _normalize_path(self, raw_path: str) -> str:
123
- path = raw_path.strip().strip('"').replace("\\", "/")
124
- if self.project_root:
125
- try:
126
- candidate = Path(path)
127
- resolved = candidate.resolve() if candidate.is_absolute() else (self.project_root / path).resolve()
128
- return resolved.relative_to(self.project_root).as_posix()
129
- except (OSError, ValueError):
130
- pass
131
- if re.match(r"^[A-Za-z]:/", path):
132
- parts = PurePosixPath(path).parts
133
- path = "/".join(parts[1:])
134
- return path[2:] if path.startswith("./") else path
242
+ # Shared normalizer — single source of truth with TaskPolicyEngine.
243
+ return normalize_repo_path(self.project_root, raw_path)[0]
135
244
 
136
245
  def _is_planned_file(self, path: str, task: Task) -> bool:
137
246
  for planned in task.planned_files: