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.
- package/LICENSE +201 -201
- package/README.md +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +163 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1342 -307
- package/uv.lock +205 -64
- 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
|
|
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()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
write_tools = {
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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:
|