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
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
from pydantic import BaseModel, Field
|
|
2
|
-
from typing import Literal, List
|
|
3
|
-
|
|
4
|
-
class AcceptanceCriterion(BaseModel):
|
|
5
|
-
id: str
|
|
6
|
-
description: str
|
|
7
|
-
verification_method: Literal[
|
|
8
|
-
"unit_test",
|
|
9
|
-
"integration_test",
|
|
10
|
-
"manual",
|
|
11
|
-
"static_check",
|
|
12
|
-
"llm_review"
|
|
13
|
-
]
|
|
14
|
-
required: bool = True
|
|
15
|
-
|
|
16
|
-
class Requirement(BaseModel):
|
|
17
|
-
id: str
|
|
18
|
-
title: str
|
|
19
|
-
description: str
|
|
20
|
-
priority: Literal["low", "medium", "high", "critical"]
|
|
21
|
-
source: Literal["user", "planner", "critic", "arbiter"]
|
|
22
|
-
acceptance_criteria: List[AcceptanceCriterion] = Field(default_factory=list)
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Literal, List
|
|
3
|
+
|
|
4
|
+
class AcceptanceCriterion(BaseModel):
|
|
5
|
+
id: str
|
|
6
|
+
description: str
|
|
7
|
+
verification_method: Literal[
|
|
8
|
+
"unit_test",
|
|
9
|
+
"integration_test",
|
|
10
|
+
"manual",
|
|
11
|
+
"static_check",
|
|
12
|
+
"llm_review"
|
|
13
|
+
]
|
|
14
|
+
required: bool = True
|
|
15
|
+
|
|
16
|
+
class Requirement(BaseModel):
|
|
17
|
+
id: str
|
|
18
|
+
title: str
|
|
19
|
+
description: str
|
|
20
|
+
priority: Literal["low", "medium", "high", "critical"]
|
|
21
|
+
source: Literal["user", "planner", "critic", "arbiter"]
|
|
22
|
+
acceptance_criteria: List[AcceptanceCriterion] = Field(default_factory=list)
|
|
@@ -1,26 +1,55 @@
|
|
|
1
|
-
from pydantic import BaseModel, Field
|
|
2
|
-
from typing import Literal, List
|
|
3
|
-
|
|
4
|
-
class PlannedFile(BaseModel):
|
|
5
|
-
path: str
|
|
6
|
-
reason: str
|
|
7
|
-
allowed_change: Literal["create", "modify", "delete", "read_only"]
|
|
8
|
-
|
|
9
|
-
class Task(BaseModel):
|
|
10
|
-
id: str
|
|
11
|
-
title: str
|
|
12
|
-
description: str
|
|
13
|
-
requirement_ids: List[str] = Field(default_factory=list)
|
|
14
|
-
acceptance_criterion_ids: List[str] = Field(default_factory=list)
|
|
15
|
-
planned_files: List[PlannedFile] = Field(default_factory=list)
|
|
16
|
-
expected_tests: List[str] = Field(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Literal, List
|
|
3
|
+
|
|
4
|
+
class PlannedFile(BaseModel):
|
|
5
|
+
path: str
|
|
6
|
+
reason: str
|
|
7
|
+
allowed_change: Literal["create", "modify", "delete", "read_only"]
|
|
8
|
+
|
|
9
|
+
class Task(BaseModel):
|
|
10
|
+
id: str
|
|
11
|
+
title: str
|
|
12
|
+
description: str
|
|
13
|
+
requirement_ids: List[str] = Field(default_factory=list)
|
|
14
|
+
acceptance_criterion_ids: List[str] = Field(default_factory=list)
|
|
15
|
+
planned_files: List[PlannedFile] = Field(default_factory=list)
|
|
16
|
+
expected_tests: List[str] = Field(
|
|
17
|
+
default_factory=list,
|
|
18
|
+
description=(
|
|
19
|
+
"Runnable shell commands (not prose) that prove this task's acceptance "
|
|
20
|
+
"criteria when they exit 0; they are executed verbatim by the verifier. "
|
|
21
|
+
"They must run right after THIS task with no missing tools or test files. "
|
|
22
|
+
"Prefer self-contained inline assertions, e.g. "
|
|
23
|
+
"python -c \"import calc; assert calc.subtract(10,3)==7\". Use pytest only "
|
|
24
|
+
"on a test file this or an earlier task creates, running the whole file "
|
|
25
|
+
"(python -m pytest tests/test_x.py -q), never a ::name node. Do not use "
|
|
26
|
+
"linters/type-checkers (flake8, mypy, ruff, eslint, tsc, npm) unless the "
|
|
27
|
+
"repo is already configured for them."
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
allowed_commands: List[str] = Field(
|
|
31
|
+
default_factory=list,
|
|
32
|
+
description=(
|
|
33
|
+
"Shell commands the executor and verifier are permitted to run for this "
|
|
34
|
+
"task (build/test/lint commands the implementation may invoke). Literal "
|
|
35
|
+
"commands only, e.g. 'python -m pytest -q', 'uv run pytest'. Leave empty "
|
|
36
|
+
"only when the task needs no commands beyond those in expected_tests."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
forbidden_changes: List[str] = Field(default_factory=list)
|
|
40
|
+
depends_on: List[str] = Field(
|
|
41
|
+
default_factory=list,
|
|
42
|
+
description=(
|
|
43
|
+
"IDs of tasks that must complete before this one (e.g. a task that creates a "
|
|
44
|
+
"module this task imports/tests). Used to order execution and to skip a task "
|
|
45
|
+
"whose prerequisites are unmet rather than letting it fail spuriously."
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
status: Literal[
|
|
49
|
+
"planned",
|
|
50
|
+
"ready",
|
|
51
|
+
"running",
|
|
52
|
+
"blocked",
|
|
53
|
+
"verified",
|
|
54
|
+
"done"
|
|
55
|
+
] = "planned"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
@@ -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
|
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
from typing import List
|
|
3
|
-
from devcouncil.domain.task import Task
|
|
4
|
-
from devcouncil.domain.requirement import Requirement
|
|
5
|
-
from devcouncil.utils.redaction import redact_string
|
|
6
|
-
import json
|
|
7
|
-
|
|
8
|
-
class ContextBuilder:
|
|
9
|
-
"""Gathers repo-level and task-level context for agent prompts."""
|
|
10
|
-
|
|
11
|
-
def __init__(self, project_root: Path):
|
|
12
|
-
self.project_root = project_root
|
|
13
|
-
|
|
14
|
-
def build_task_context(self, task: Task, requirements: List[Requirement]) -> str:
|
|
15
|
-
"""Collects all relevant information for implementing a task."""
|
|
16
|
-
|
|
17
|
-
# 1. Map requirements relevant to this task
|
|
18
|
-
req_map = {r.id: r for r in requirements}
|
|
19
|
-
task_reqs = [req_map[rid] for rid in task.requirement_ids if rid in req_map]
|
|
20
|
-
|
|
21
|
-
# 2. Gather content of planned files (if they exist)
|
|
22
|
-
file_contents = {}
|
|
23
|
-
for pf in task.planned_files:
|
|
24
|
-
file_path = self.project_root / pf.path
|
|
25
|
-
if file_path.exists() and file_path.is_file():
|
|
26
|
-
try:
|
|
27
|
-
raw = file_path.read_text(encoding="utf-8")
|
|
28
|
-
file_contents[pf.path] = redact_string(raw)
|
|
29
|
-
except Exception:
|
|
30
|
-
file_contents[pf.path] = "[Error reading file]"
|
|
31
|
-
else:
|
|
32
|
-
file_contents[pf.path] = "[New file - does not exist yet]"
|
|
33
|
-
|
|
34
|
-
# 3. Assemble the context string
|
|
35
|
-
context = {
|
|
36
|
-
"task": task.model_dump(),
|
|
37
|
-
"relevant_requirements": [r.model_dump() for r in task_reqs],
|
|
38
|
-
"file_contents": file_contents,
|
|
39
|
-
"project_structure": self.get_structure_summary(task)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return json.dumps(context, indent=2)
|
|
43
|
-
|
|
44
|
-
def get_structure_summary(self, task: Task = None) -> List[str]:
|
|
45
|
-
"""Simple list of files in the project for context."""
|
|
46
|
-
try:
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List
|
|
3
|
+
from devcouncil.domain.task import Task
|
|
4
|
+
from devcouncil.domain.requirement import Requirement
|
|
5
|
+
from devcouncil.utils.redaction import redact_string
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
class ContextBuilder:
|
|
9
|
+
"""Gathers repo-level and task-level context for agent prompts."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, project_root: Path):
|
|
12
|
+
self.project_root = project_root
|
|
13
|
+
|
|
14
|
+
def build_task_context(self, task: Task, requirements: List[Requirement]) -> str:
|
|
15
|
+
"""Collects all relevant information for implementing a task."""
|
|
16
|
+
|
|
17
|
+
# 1. Map requirements relevant to this task
|
|
18
|
+
req_map = {r.id: r for r in requirements}
|
|
19
|
+
task_reqs = [req_map[rid] for rid in task.requirement_ids if rid in req_map]
|
|
20
|
+
|
|
21
|
+
# 2. Gather content of planned files (if they exist)
|
|
22
|
+
file_contents = {}
|
|
23
|
+
for pf in task.planned_files:
|
|
24
|
+
file_path = self.project_root / pf.path
|
|
25
|
+
if file_path.exists() and file_path.is_file():
|
|
26
|
+
try:
|
|
27
|
+
raw = file_path.read_text(encoding="utf-8")
|
|
28
|
+
file_contents[pf.path] = redact_string(raw)
|
|
29
|
+
except Exception:
|
|
30
|
+
file_contents[pf.path] = "[Error reading file]"
|
|
31
|
+
else:
|
|
32
|
+
file_contents[pf.path] = "[New file - does not exist yet]"
|
|
33
|
+
|
|
34
|
+
# 3. Assemble the context string
|
|
35
|
+
context = {
|
|
36
|
+
"task": task.model_dump(),
|
|
37
|
+
"relevant_requirements": [r.model_dump() for r in task_reqs],
|
|
38
|
+
"file_contents": file_contents,
|
|
39
|
+
"project_structure": self.get_structure_summary(task)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return json.dumps(context, indent=2)
|
|
43
|
+
|
|
44
|
+
def get_structure_summary(self, task: Task | None = None) -> List[str]:
|
|
45
|
+
"""Simple list of files in the project for context."""
|
|
46
|
+
try:
|
|
47
47
|
import subprocess
|
|
48
48
|
output = subprocess.check_output(
|
|
49
49
|
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
50
50
|
cwd=self.project_root,
|
|
51
51
|
stderr=subprocess.DEVNULL
|
|
52
52
|
).decode().splitlines()
|
|
53
|
-
|
|
54
|
-
if task and task.planned_files:
|
|
55
|
-
planned_paths = {pf.path for pf in task.planned_files}
|
|
56
|
-
output = [p for p in output if p in planned_paths] + [p for p in output if p not in planned_paths]
|
|
57
|
-
|
|
58
|
-
return output[:100] # Limit to avoid context overflow
|
|
59
|
-
except Exception:
|
|
60
|
-
return []
|
|
53
|
+
|
|
54
|
+
if task and task.planned_files:
|
|
55
|
+
planned_paths = {pf.path for pf in task.planned_files}
|
|
56
|
+
output = [p for p in output if p in planned_paths] + [p for p in output if p not in planned_paths]
|
|
57
|
+
|
|
58
|
+
return output[:100] # Limit to avoid context overflow
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
from abc import ABC, abstractmethod
|
|
2
|
-
from typing import List
|
|
3
|
-
from pydantic import BaseModel
|
|
4
|
-
from devcouncil.domain.task import Task
|
|
5
|
-
from devcouncil.domain.requirement import Requirement
|
|
6
|
-
|
|
7
|
-
class ExecutionResult(BaseModel):
|
|
8
|
-
success: bool
|
|
9
|
-
message: str
|
|
10
|
-
|
|
11
|
-
class Executor(ABC):
|
|
12
|
-
@abstractmethod
|
|
13
|
-
def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
14
|
-
"""Execute the task and return the result."""
|
|
15
|
-
pass
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from devcouncil.domain.task import Task
|
|
5
|
+
from devcouncil.domain.requirement import Requirement
|
|
6
|
+
|
|
7
|
+
class ExecutionResult(BaseModel):
|
|
8
|
+
success: bool
|
|
9
|
+
message: str
|
|
10
|
+
|
|
11
|
+
class Executor(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
14
|
+
"""Execute the task and return the result."""
|
|
15
|
+
pass
|