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.
- package/README.md +190 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +167 -5
- package/src/devcouncil/artifacts/graph.py +23 -3
- 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 +6 -3
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/doctor.py +221 -21
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +452 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +124 -15
- package/src/devcouncil/cli/commands/init.py +154 -18
- package/src/devcouncil/cli/commands/integrate.py +894 -105
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/plan.py +212 -51
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +40 -23
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +69 -49
- 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 +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +25 -1
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +138 -3
- package/src/devcouncil/cli/commands/watch.py +9 -9
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +56 -7
- package/src/devcouncil/domain/evidence.py +22 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +59 -10
- package/src/devcouncil/execution/permissions.py +17 -24
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +633 -21
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +6 -2
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +663 -39
- package/src/devcouncil/executors/native/agent.py +121 -20
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
- package/src/devcouncil/gating/policy.py +158 -10
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +1 -1
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1256 -9
- 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_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +35 -0
- package/src/devcouncil/integrations/mcp/server.py +1552 -29
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/live/cards.py +161 -19
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/transcripts.py +9 -6
- package/src/devcouncil/llm/cache.py +10 -6
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +515 -34
- package/src/devcouncil/llm/router.py +231 -46
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +7 -2
- package/src/devcouncil/planning/plan_service.py +17 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
- package/src/devcouncil/planning/spec_service.py +27 -1
- 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/json_report.py +11 -1
- package/src/devcouncil/reporting/markdown_report.py +15 -0
- 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 +83 -2
- package/src/devcouncil/storage/models.py +121 -0
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +137 -75
- package/src/devcouncil/telemetry/cost.py +123 -17
- 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 +12 -9
- package/src/devcouncil/ui/dashboard.py +324 -23
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- 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/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 +1065 -47
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Git-native checkpoint service with legacy patch compatibility."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from devcouncil.verification.verifier import Verifier
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CheckpointResult(BaseModel):
|
|
21
|
+
task_id: str
|
|
22
|
+
ref: str | None = None
|
|
23
|
+
patch_path: str | None = None
|
|
24
|
+
json_path: str | None = None
|
|
25
|
+
git_ref_created: bool = False
|
|
26
|
+
message: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CheckpointService:
|
|
30
|
+
REF_BEFORE = "refs/devcouncil/tasks/{task_id}/before"
|
|
31
|
+
REF_AFTER = "refs/devcouncil/tasks/{task_id}/after"
|
|
32
|
+
REF_ATTEMPT = "refs/devcouncil/tasks/{task_id}/attempts/{attempt}"
|
|
33
|
+
|
|
34
|
+
def __init__(self, project_root: Path):
|
|
35
|
+
self.project_root = project_root.resolve()
|
|
36
|
+
self.checkpoint_dir = self.project_root / ".devcouncil" / "checkpoints"
|
|
37
|
+
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
def create_before(self, task_id: str) -> CheckpointResult:
|
|
40
|
+
return self._create(task_id, stage="before")
|
|
41
|
+
|
|
42
|
+
def create_after(self, task_id: str) -> CheckpointResult:
|
|
43
|
+
return self._create(task_id, stage="after")
|
|
44
|
+
|
|
45
|
+
def create_attempt(self, task_id: str, attempt: int) -> CheckpointResult:
|
|
46
|
+
ref_template = self.REF_ATTEMPT.format(task_id=task_id, attempt=attempt)
|
|
47
|
+
return self._create(task_id, stage="attempt", ref_name=ref_template)
|
|
48
|
+
|
|
49
|
+
def rollback(self, task_id: str) -> CheckpointResult:
|
|
50
|
+
before_ref = self.REF_BEFORE.format(task_id=task_id)
|
|
51
|
+
after_ref = self.REF_AFTER.format(task_id=task_id)
|
|
52
|
+
after_patch = self.checkpoint_dir / f"{task_id}-after.patch"
|
|
53
|
+
|
|
54
|
+
if self._ref_exists(after_ref) and self._ref_exists(before_ref):
|
|
55
|
+
try:
|
|
56
|
+
diff = subprocess.check_output(
|
|
57
|
+
["git", "diff", before_ref, after_ref],
|
|
58
|
+
cwd=self.project_root,
|
|
59
|
+
text=True,
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
errors="replace",
|
|
62
|
+
)
|
|
63
|
+
if diff.strip():
|
|
64
|
+
subprocess.run(
|
|
65
|
+
["git", "apply", "-R", "--whitespace=nowarn"],
|
|
66
|
+
cwd=self.project_root,
|
|
67
|
+
input=diff,
|
|
68
|
+
text=True,
|
|
69
|
+
check=True,
|
|
70
|
+
)
|
|
71
|
+
return CheckpointResult(
|
|
72
|
+
task_id=task_id,
|
|
73
|
+
ref=after_ref,
|
|
74
|
+
git_ref_created=True,
|
|
75
|
+
message="Rolled back using git refs.",
|
|
76
|
+
)
|
|
77
|
+
except subprocess.CalledProcessError as exc:
|
|
78
|
+
return CheckpointResult(
|
|
79
|
+
task_id=task_id,
|
|
80
|
+
message=f"Git ref rollback failed: {exc}",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if after_patch.exists():
|
|
84
|
+
try:
|
|
85
|
+
subprocess.check_call(
|
|
86
|
+
["git", "apply", "-R", str(after_patch)],
|
|
87
|
+
cwd=self.project_root,
|
|
88
|
+
)
|
|
89
|
+
return CheckpointResult(
|
|
90
|
+
task_id=task_id,
|
|
91
|
+
patch_path=str(after_patch),
|
|
92
|
+
message="Rolled back using after patch.",
|
|
93
|
+
)
|
|
94
|
+
except subprocess.CalledProcessError as exc:
|
|
95
|
+
return CheckpointResult(
|
|
96
|
+
task_id=task_id,
|
|
97
|
+
patch_path=str(after_patch),
|
|
98
|
+
message=f"Patch rollback failed: {exc}",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return CheckpointResult(
|
|
102
|
+
task_id=task_id,
|
|
103
|
+
message="No checkpoint refs or after patch found.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def import_legacy_patch(self, task_id: str) -> CheckpointResult:
|
|
107
|
+
before_patch = self.checkpoint_dir / f"{task_id}-before.patch"
|
|
108
|
+
if not before_patch.exists():
|
|
109
|
+
return CheckpointResult(
|
|
110
|
+
task_id=task_id,
|
|
111
|
+
message="No legacy before patch to import.",
|
|
112
|
+
)
|
|
113
|
+
ref = self.REF_BEFORE.format(task_id=task_id)
|
|
114
|
+
created = self._update_ref(ref)
|
|
115
|
+
return CheckpointResult(
|
|
116
|
+
task_id=task_id,
|
|
117
|
+
ref=ref if created else None,
|
|
118
|
+
patch_path=str(before_patch),
|
|
119
|
+
git_ref_created=created,
|
|
120
|
+
message="Imported legacy before patch ref when possible.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _create(
|
|
124
|
+
self,
|
|
125
|
+
task_id: str,
|
|
126
|
+
*,
|
|
127
|
+
stage: str,
|
|
128
|
+
ref_name: str | None = None,
|
|
129
|
+
) -> CheckpointResult:
|
|
130
|
+
ref = ref_name or (
|
|
131
|
+
self.REF_BEFORE.format(task_id=task_id)
|
|
132
|
+
if stage == "before"
|
|
133
|
+
else self.REF_AFTER.format(task_id=task_id)
|
|
134
|
+
)
|
|
135
|
+
patch_path = self.checkpoint_dir / f"{task_id}-{stage}.patch"
|
|
136
|
+
json_path: str | None = None
|
|
137
|
+
|
|
138
|
+
# Point the ref at a snapshot of the *working tree* (the task's actual state),
|
|
139
|
+
# not bare HEAD — otherwise before/after refs both resolve to the same commit and
|
|
140
|
+
# the git-ref rollback path can never fire. Falls back to HEAD if snapshotting is
|
|
141
|
+
# impossible (unborn HEAD / not a git repo), preserving the patch-based rollback.
|
|
142
|
+
git_ref_created = self._update_ref(ref, self._snapshot_commit())
|
|
143
|
+
try:
|
|
144
|
+
diff = Verifier(self.project_root).get_diff()
|
|
145
|
+
if diff:
|
|
146
|
+
patch_path.write_text(diff, encoding="utf-8")
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
# Without a patch (and if the ref also failed) rollback is impossible —
|
|
149
|
+
# never let this fail silently.
|
|
150
|
+
logger.warning("Failed to capture %s checkpoint patch for %s: %s", stage, task_id, exc)
|
|
151
|
+
|
|
152
|
+
if stage == "before":
|
|
153
|
+
snapshot = {
|
|
154
|
+
"task_id": task_id,
|
|
155
|
+
"changed_files": Verifier(self.project_root).get_changed_files(),
|
|
156
|
+
}
|
|
157
|
+
snapshot_path = self.checkpoint_dir / f"{task_id}-before.json"
|
|
158
|
+
snapshot_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8")
|
|
159
|
+
json_path = str(snapshot_path)
|
|
160
|
+
|
|
161
|
+
return CheckpointResult(
|
|
162
|
+
task_id=task_id,
|
|
163
|
+
ref=ref if git_ref_created else None,
|
|
164
|
+
patch_path=str(patch_path) if patch_path.exists() else None,
|
|
165
|
+
json_path=json_path,
|
|
166
|
+
git_ref_created=git_ref_created,
|
|
167
|
+
message=f"Checkpoint {stage} created.",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _update_ref(self, ref: str, commit: str | None = None) -> bool:
|
|
171
|
+
"""Point ``ref`` at ``commit`` (a working-tree snapshot) or, if None, at HEAD."""
|
|
172
|
+
try:
|
|
173
|
+
target = commit
|
|
174
|
+
if target is None:
|
|
175
|
+
target = subprocess.check_output(
|
|
176
|
+
["git", "rev-parse", "HEAD"],
|
|
177
|
+
cwd=self.project_root,
|
|
178
|
+
text=True,
|
|
179
|
+
encoding="utf-8",
|
|
180
|
+
errors="replace",
|
|
181
|
+
).strip()
|
|
182
|
+
if not target:
|
|
183
|
+
return False
|
|
184
|
+
subprocess.check_call(
|
|
185
|
+
["git", "update-ref", ref, target],
|
|
186
|
+
cwd=self.project_root,
|
|
187
|
+
)
|
|
188
|
+
return True
|
|
189
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
def _snapshot_commit(self) -> str | None:
|
|
193
|
+
"""Create a commit object capturing the FULL working tree (tracked + untracked,
|
|
194
|
+
honoring .gitignore) WITHOUT touching the user's index or working tree.
|
|
195
|
+
|
|
196
|
+
Uses a throwaway ``GIT_INDEX_FILE`` so staging happens in isolation. Returns the
|
|
197
|
+
commit sha, or None if it can't be built (unborn HEAD, not a git repo) so the
|
|
198
|
+
caller falls back to a HEAD ref + the patch-based rollback."""
|
|
199
|
+
# The temp index lives OUTSIDE the repo: if it sat under the working tree (even in
|
|
200
|
+
# the gitignored .devcouncil/), `git add -A` could stage the index file itself into
|
|
201
|
+
# the snapshot. ``.devcouncil`` is excluded from staging for the same reason —
|
|
202
|
+
# DevCouncil's own run state must never be snapshotted or rolled back.
|
|
203
|
+
index_path = Path(tempfile.gettempdir()) / f"devcouncil-snapshot-index-{uuid.uuid4().hex}"
|
|
204
|
+
env = {**os.environ, "GIT_INDEX_FILE": str(index_path)}
|
|
205
|
+
try:
|
|
206
|
+
# Seed the temp index from HEAD, stage every change (incl. new files), then
|
|
207
|
+
# write a tree + commit from that isolated index.
|
|
208
|
+
subprocess.run(
|
|
209
|
+
["git", "read-tree", "HEAD"],
|
|
210
|
+
cwd=self.project_root, env=env, check=True, capture_output=True, text=True,
|
|
211
|
+
)
|
|
212
|
+
subprocess.run(
|
|
213
|
+
["git", "add", "-A", "--", ".", ":(exclude).devcouncil"],
|
|
214
|
+
cwd=self.project_root, env=env, check=True, capture_output=True, text=True,
|
|
215
|
+
)
|
|
216
|
+
tree = subprocess.check_output(
|
|
217
|
+
["git", "write-tree"],
|
|
218
|
+
cwd=self.project_root, env=env, text=True, encoding="utf-8", errors="replace",
|
|
219
|
+
).strip()
|
|
220
|
+
if not tree:
|
|
221
|
+
return None
|
|
222
|
+
commit = subprocess.check_output(
|
|
223
|
+
["git", "-c", "user.name=DevCouncil", "-c", "user.email=devcouncil@local",
|
|
224
|
+
"commit-tree", tree, "-p", "HEAD", "-m", "devcouncil checkpoint"],
|
|
225
|
+
cwd=self.project_root, text=True, encoding="utf-8", errors="replace",
|
|
226
|
+
).strip()
|
|
227
|
+
return commit or None
|
|
228
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
229
|
+
return None
|
|
230
|
+
finally:
|
|
231
|
+
try:
|
|
232
|
+
index_path.unlink()
|
|
233
|
+
except OSError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
def _ref_exists(self, ref: str) -> bool:
|
|
237
|
+
try:
|
|
238
|
+
subprocess.check_output(
|
|
239
|
+
["git", "rev-parse", "--verify", ref],
|
|
240
|
+
cwd=self.project_root,
|
|
241
|
+
stderr=subprocess.DEVNULL,
|
|
242
|
+
text=True,
|
|
243
|
+
)
|
|
244
|
+
return True
|
|
245
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
246
|
+
return False
|
|
@@ -41,7 +41,7 @@ class ContextBuilder:
|
|
|
41
41
|
|
|
42
42
|
return json.dumps(context, indent=2)
|
|
43
43
|
|
|
44
|
-
def get_structure_summary(self, task: Task = None) -> List[str]:
|
|
44
|
+
def get_structure_summary(self, task: Task | None = None) -> List[str]:
|
|
45
45
|
"""Simple list of files in the project for context."""
|
|
46
46
|
try:
|
|
47
47
|
import subprocess
|
|
@@ -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
|