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
|
@@ -11,7 +11,9 @@ from devcouncil.storage.repositories import TaskRepository, RequirementRepositor
|
|
|
11
11
|
from devcouncil.verification.verifier import Verifier
|
|
12
12
|
from devcouncil.llm.provider import create_provider, validate_model_provider
|
|
13
13
|
from devcouncil.llm.router import ModelRouter
|
|
14
|
-
from devcouncil.domain.evidence import CommandResult, DiffEvidence, TestEvidence
|
|
14
|
+
from devcouncil.domain.evidence import CommandResult, DiffEvidence, DiffCoverageEvidence, TestEvidence
|
|
15
|
+
from devcouncil.domain.gap import Gap
|
|
16
|
+
from devcouncil.verification.next_actions import split_next_actions
|
|
15
17
|
from devcouncil.app.config import load_config, get_api_key
|
|
16
18
|
from devcouncil.app.state_machine import ProjectPhase
|
|
17
19
|
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
@@ -20,8 +22,32 @@ from devcouncil.telemetry.traces import TraceLogger
|
|
|
20
22
|
console = Console()
|
|
21
23
|
MAX_RENDERED_GAPS = 20
|
|
22
24
|
|
|
25
|
+
|
|
26
|
+
def reconcile_cross_task_acceptance(
|
|
27
|
+
gaps: list[Gap], proven_acs: set[str]
|
|
28
|
+
) -> list[Gap]:
|
|
29
|
+
"""Drop a task's blocking ``acceptance_criteria_unproven`` gaps whose criterion is
|
|
30
|
+
already proven by passing evidence in another task.
|
|
31
|
+
|
|
32
|
+
Acceptance criteria are requirement-level, not task-private: when the planner splits
|
|
33
|
+
"implement X" and "add tests for X" into separate tasks that share criteria, the
|
|
34
|
+
implement task would otherwise stay blocked for criteria the test task proved. The
|
|
35
|
+
caller passes ``proven_acs`` gathered from passing evidence re-run against the current
|
|
36
|
+
tree, so a regression would have failed the test and excluded the criterion — only
|
|
37
|
+
genuinely-satisfied criteria are cleared. Returns the gaps to keep."""
|
|
38
|
+
return [
|
|
39
|
+
gap
|
|
40
|
+
for gap in gaps
|
|
41
|
+
if not (
|
|
42
|
+
gap.blocking
|
|
43
|
+
and gap.gap_type == "acceptance_criteria_unproven"
|
|
44
|
+
and gap.acceptance_criterion_id in proven_acs
|
|
45
|
+
)
|
|
46
|
+
]
|
|
47
|
+
|
|
23
48
|
def verify(
|
|
24
49
|
task_id: Optional[str] = typer.Argument(None, help="Optional ID of the task to verify"),
|
|
50
|
+
sandbox: str = typer.Option("local", "--sandbox", help="Verification sandbox: local, docker, or nix."),
|
|
25
51
|
json_format: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
|
|
26
52
|
project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
|
|
27
53
|
):
|
|
@@ -62,18 +88,69 @@ def verify(
|
|
|
62
88
|
config = load_config(root)
|
|
63
89
|
validate_model_provider(config.models.provider)
|
|
64
90
|
api_key = get_api_key(config.models.provider, root)
|
|
65
|
-
provider = create_provider(config.models.provider, api_key)
|
|
91
|
+
provider = create_provider(config.models.provider, api_key, project_root=root)
|
|
66
92
|
role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
|
|
67
|
-
router = ModelRouter(provider, role_config)
|
|
93
|
+
router = ModelRouter(provider, role_config, project_root=root)
|
|
68
94
|
except Exception:
|
|
69
95
|
pass
|
|
70
96
|
|
|
97
|
+
from devcouncil.verification.sandbox import get_sandbox
|
|
98
|
+
|
|
71
99
|
verifier = Verifier(root, router=router)
|
|
72
100
|
total_gaps = 0
|
|
73
101
|
blocked_tasks = 0
|
|
74
102
|
task_results = []
|
|
103
|
+
# Cross-task acceptance reconciliation state: a criterion proven by passing
|
|
104
|
+
# evidence in ANY task is proven for every task that shares it.
|
|
105
|
+
proven_acs: set[str] = set()
|
|
106
|
+
per_task_gaps: dict[str, list] = {}
|
|
75
107
|
|
|
76
108
|
for task in tasks:
|
|
109
|
+
if sandbox != "local":
|
|
110
|
+
commands = task.expected_tests or task.allowed_commands
|
|
111
|
+
sandbox_result = get_sandbox(sandbox, root).run(task, commands, reqs)
|
|
112
|
+
if sandbox_result.status == "unsupported":
|
|
113
|
+
message = f"Sandbox {sandbox} is unavailable."
|
|
114
|
+
if json_format:
|
|
115
|
+
typer.echo(json.dumps({"ok": False, "error": message, "sandbox": sandbox}, indent=2))
|
|
116
|
+
else:
|
|
117
|
+
console.print(f"[red]{message}[/red]")
|
|
118
|
+
return
|
|
119
|
+
if sandbox_result.status == "failed":
|
|
120
|
+
task.status = "blocked"
|
|
121
|
+
blocked_tasks += 1
|
|
122
|
+
task_repo.save(task)
|
|
123
|
+
task_results.append({
|
|
124
|
+
"task_id": task.id,
|
|
125
|
+
"status": task.status,
|
|
126
|
+
"sandbox": sandbox,
|
|
127
|
+
"gap_count": 1,
|
|
128
|
+
"blocking_gap_count": 1,
|
|
129
|
+
"gaps": [],
|
|
130
|
+
})
|
|
131
|
+
if json_format:
|
|
132
|
+
typer.echo(json.dumps({
|
|
133
|
+
"ok": False,
|
|
134
|
+
"task_id": task.id,
|
|
135
|
+
"sandbox": sandbox,
|
|
136
|
+
"commands": sandbox_result.commands,
|
|
137
|
+
}, indent=2))
|
|
138
|
+
else:
|
|
139
|
+
console.print(f"[red]{task.id} failed in {sandbox} sandbox.[/red]")
|
|
140
|
+
continue
|
|
141
|
+
task.status = "verified"
|
|
142
|
+
task_repo.save(task)
|
|
143
|
+
task_results.append({
|
|
144
|
+
"task_id": task.id,
|
|
145
|
+
"status": task.status,
|
|
146
|
+
"sandbox": sandbox,
|
|
147
|
+
"gap_count": 0,
|
|
148
|
+
"blocking_gap_count": 0,
|
|
149
|
+
"gaps": [],
|
|
150
|
+
})
|
|
151
|
+
if not json_format:
|
|
152
|
+
console.print(f"[green]{task.id} passed in {sandbox} sandbox.[/green]")
|
|
153
|
+
continue
|
|
77
154
|
TraceLogger(root).log_event(
|
|
78
155
|
"task_verification_started",
|
|
79
156
|
{"task_id": task.id},
|
|
@@ -95,6 +172,7 @@ def verify(
|
|
|
95
172
|
evidence_repo.delete_for_task(task.id)
|
|
96
173
|
|
|
97
174
|
gaps, evidence = asyncio.run(verifier.verify_task(task, reqs))
|
|
175
|
+
outcome = verifier.last_outcome
|
|
98
176
|
total_gaps += len(gaps)
|
|
99
177
|
|
|
100
178
|
for gap in gaps:
|
|
@@ -103,11 +181,16 @@ def verify(
|
|
|
103
181
|
for ev in evidence:
|
|
104
182
|
if isinstance(ev, CommandResult):
|
|
105
183
|
evidence_repo.save_command_result(task.id, ev)
|
|
184
|
+
elif isinstance(ev, DiffCoverageEvidence):
|
|
185
|
+
evidence_repo.save_diff_coverage_evidence(ev)
|
|
106
186
|
elif isinstance(ev, DiffEvidence):
|
|
107
187
|
evidence_repo.save_diff_evidence(ev)
|
|
108
188
|
elif isinstance(ev, TestEvidence):
|
|
109
189
|
evidence_repo.save_test_evidence(ev, task.id)
|
|
190
|
+
if ev.status == "passed" and ev.acceptance_criterion_id:
|
|
191
|
+
proven_acs.add(ev.acceptance_criterion_id)
|
|
110
192
|
|
|
193
|
+
per_task_gaps[task.id] = gaps
|
|
111
194
|
if not json_format:
|
|
112
195
|
_print_task_result(task.id, gaps)
|
|
113
196
|
|
|
@@ -129,14 +212,59 @@ def verify(
|
|
|
129
212
|
summary=f"{task.id} verified",
|
|
130
213
|
)
|
|
131
214
|
task_repo.save(task)
|
|
215
|
+
blocking_actions, advisory_actions = split_next_actions(gaps)
|
|
132
216
|
task_results.append({
|
|
133
217
|
"task_id": task.id,
|
|
134
218
|
"status": task.status,
|
|
135
219
|
"gap_count": len(gaps),
|
|
136
220
|
"blocking_gap_count": len([gap for gap in gaps if gap.blocking]),
|
|
137
221
|
"gaps": [gap.model_dump() for gap in gaps],
|
|
222
|
+
"next_actions": [action.model_dump() for action in blocking_actions],
|
|
223
|
+
"advisory_actions": [action.model_dump() for action in advisory_actions],
|
|
224
|
+
"verification_mode": outcome.mode if outcome else "unknown",
|
|
225
|
+
"compiler_active": outcome.compiler_active if outcome else False,
|
|
226
|
+
"diff_empty": outcome.diff_empty if outcome else False,
|
|
227
|
+
"coverage_measured": outcome.coverage_measured if outcome else False,
|
|
228
|
+
"coverage_skipped_reason": outcome.coverage_skipped_reason if outcome else None,
|
|
138
229
|
})
|
|
139
230
|
|
|
231
|
+
# Cross-task acceptance reconciliation (only meaningful across the full set).
|
|
232
|
+
# The planner sometimes splits "implement X" and "add tests for X" into separate
|
|
233
|
+
# tasks that share acceptance criteria; the implement task would otherwise stay
|
|
234
|
+
# blocked for criteria the test task already proved. A criterion proven by passing
|
|
235
|
+
# evidence in ANY task is proven for every task that shares it. Evidence was re-run
|
|
236
|
+
# against the current tree, so a regression would have failed the test and the AC
|
|
237
|
+
# would not be in proven_acs — this clears only genuinely-satisfied criteria.
|
|
238
|
+
if task_id is None and proven_acs:
|
|
239
|
+
for task in tasks:
|
|
240
|
+
gaps = per_task_gaps.get(task.id, [])
|
|
241
|
+
kept = reconcile_cross_task_acceptance(gaps, proven_acs)
|
|
242
|
+
if len(kept) == len(gaps):
|
|
243
|
+
continue
|
|
244
|
+
gap_repo.delete_for_task(task.id)
|
|
245
|
+
for gap in kept:
|
|
246
|
+
gap_repo.save(gap)
|
|
247
|
+
per_task_gaps[task.id] = kept
|
|
248
|
+
if task.status == "blocked" and not any(gap.blocking for gap in kept):
|
|
249
|
+
task.status = "verified"
|
|
250
|
+
blocked_tasks = max(0, blocked_tasks - 1)
|
|
251
|
+
TraceLogger(root).log_event(
|
|
252
|
+
"task_reconciled",
|
|
253
|
+
{"task_id": task.id, "cross_task_proven": True},
|
|
254
|
+
task_id=task.id,
|
|
255
|
+
summary=f"{task.id} verified via cross-task acceptance reconciliation",
|
|
256
|
+
)
|
|
257
|
+
task_repo.save(task)
|
|
258
|
+
for result in task_results:
|
|
259
|
+
if result["task_id"] == task.id:
|
|
260
|
+
blocking_actions, advisory_actions = split_next_actions(kept)
|
|
261
|
+
result["status"] = task.status
|
|
262
|
+
result["gap_count"] = len(kept)
|
|
263
|
+
result["blocking_gap_count"] = len([gap for gap in kept if gap.blocking])
|
|
264
|
+
result["gaps"] = [gap.model_dump() for gap in kept]
|
|
265
|
+
result["next_actions"] = [action.model_dump() for action in blocking_actions]
|
|
266
|
+
result["advisory_actions"] = [action.model_dump() for action in advisory_actions]
|
|
267
|
+
|
|
140
268
|
StateRepository(session).record_phase(
|
|
141
269
|
ProjectPhase.TASK_BLOCKED.value if blocked_tasks else ProjectPhase.TASK_VERIFIED.value
|
|
142
270
|
)
|
|
@@ -158,6 +286,13 @@ def verify(
|
|
|
158
286
|
else:
|
|
159
287
|
console.print(f"\n[green]Verified {len(tasks)} tasks successfully.[/green]")
|
|
160
288
|
|
|
289
|
+
# Exit-code contract (so shell-driven agents can gate on $?):
|
|
290
|
+
# 0 = all verified, no blocking gaps
|
|
291
|
+
# 1 = at least one task is blocked by a verification gap
|
|
292
|
+
# Argument/state errors above return early with their own message and exit 0.
|
|
293
|
+
if blocked_tasks:
|
|
294
|
+
raise typer.Exit(code=1)
|
|
295
|
+
|
|
161
296
|
|
|
162
297
|
def _print_task_result(task_id: str, gaps):
|
|
163
298
|
if not gaps:
|
|
@@ -284,13 +284,13 @@ def repair_all(
|
|
|
284
284
|
for item in summary["blocking_cards"]
|
|
285
285
|
if isinstance(item.get("id"), str)
|
|
286
286
|
]
|
|
287
|
-
|
|
288
|
-
prompt = build_bulk_live_repair_prompt(root,
|
|
287
|
+
resolved_cards = [card for card in cards if card is not None]
|
|
288
|
+
prompt = build_bulk_live_repair_prompt(root, resolved_cards)
|
|
289
289
|
if json_format:
|
|
290
290
|
typer.echo(json.dumps({
|
|
291
291
|
"ok": True,
|
|
292
292
|
"scope_task_id": summary["scope_task_id"],
|
|
293
|
-
"cards": [card.model_dump() for card in
|
|
293
|
+
"cards": [card.model_dump() for card in resolved_cards],
|
|
294
294
|
"prompt": prompt,
|
|
295
295
|
}, indent=2))
|
|
296
296
|
return
|
|
@@ -557,18 +557,18 @@ def _log_signal_processed(root: Path, signal: ReviewSignal, processed_path: Path
|
|
|
557
557
|
|
|
558
558
|
async def _review_turn(turn, root: Path, client: str, use_llm: bool, task_id: str | None = None):
|
|
559
559
|
if not use_llm:
|
|
560
|
-
|
|
561
|
-
|
|
560
|
+
# Pass task_id so the deterministic review can ground completion/evidence
|
|
561
|
+
# claims against the task's real verification state.
|
|
562
|
+
return review_turn(turn, root, client=client, task_id=task_id)
|
|
562
563
|
try:
|
|
563
564
|
config = load_config(root)
|
|
564
565
|
validate_model_provider(config.models.provider)
|
|
565
566
|
api_key = get_api_key(config.models.provider, root)
|
|
566
|
-
provider = create_provider(config.models.provider, api_key)
|
|
567
|
+
provider = create_provider(config.models.provider, api_key, project_root=root)
|
|
567
568
|
role_config = {name: role.model_dump() for name, role in config.models.roles.items()}
|
|
568
|
-
router = ModelRouter(provider, role_config)
|
|
569
|
+
router = ModelRouter(provider, role_config, project_root=root)
|
|
569
570
|
except Exception as exc:
|
|
570
571
|
console.print(f"[yellow]Model-backed review unavailable; using deterministic card: {exc}[/yellow]")
|
|
571
|
-
|
|
572
|
-
return card.model_copy(update={"task_id": task_id}) if task_id else card
|
|
572
|
+
return review_turn(turn, root, client=client, task_id=task_id)
|
|
573
573
|
card = await LiveReviewService(router).review(turn, root, client=client, use_llm=True)
|
|
574
574
|
return card.model_copy(update={"task_id": task_id}) if task_id else card
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typer
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from devcouncil.cli.commands.init import initialize_project
|
|
7
|
+
from devcouncil.execution.fs_watcher import FilesystemWatcher
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def watch_fs(
|
|
13
|
+
task_id: str = typer.Option(..., "--task", help="Task ID to attribute file changes to."),
|
|
14
|
+
poll_interval: float = typer.Option(1.0, "--poll-interval"),
|
|
15
|
+
once: bool = typer.Option(False, "--once", help="Scan once and exit."),
|
|
16
|
+
project_root: Path = typer.Option(Path("."), "--project-root"),
|
|
17
|
+
json_format: bool = typer.Option(False, "--json"),
|
|
18
|
+
):
|
|
19
|
+
root = project_root.expanduser().resolve()
|
|
20
|
+
initialize_project(root, quiet=True)
|
|
21
|
+
|
|
22
|
+
def _print_event(event: dict) -> None:
|
|
23
|
+
status = "allowed" if event["allowed"] else "denied"
|
|
24
|
+
console.print(f"[cyan]{event['path']}[/cyan] {status}: {event['reason']}")
|
|
25
|
+
|
|
26
|
+
watcher = FilesystemWatcher(root, task_id, poll_interval=poll_interval, on_event=_print_event)
|
|
27
|
+
if once:
|
|
28
|
+
events = watcher.scan_once()
|
|
29
|
+
if json_format:
|
|
30
|
+
typer.echo(json.dumps({"events": events}, indent=2))
|
|
31
|
+
else:
|
|
32
|
+
for event in events:
|
|
33
|
+
status = "allowed" if event["allowed"] else "denied"
|
|
34
|
+
console.print(f"[cyan]{event['path']}[/cyan] {status}: {event['reason']}")
|
|
35
|
+
return
|
|
36
|
+
console.print(f"[cyan]Watching filesystem for task {task_id}. Ctrl+C to stop.[/cyan]")
|
|
37
|
+
try:
|
|
38
|
+
watcher.watch()
|
|
39
|
+
except KeyboardInterrupt:
|
|
40
|
+
console.print("[yellow]Stopped filesystem watcher.[/yellow]")
|
|
@@ -1,10 +1,40 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
1
3
|
import typer
|
|
2
|
-
from pathlib import Path
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
|
|
6
|
+
def _configure_stdio() -> None:
|
|
7
|
+
"""Make stdout/stderr resilient to non-cp1252 characters.
|
|
8
|
+
|
|
9
|
+
Coding agents and rich output emit Unicode such as ``✓``. On Windows the
|
|
10
|
+
default console / redirected-pipe encoding is cp1252, where an un-encodable
|
|
11
|
+
character raises UnicodeEncodeError mid-write. Because Rich buffers output,
|
|
12
|
+
that error can surface during an unrelated later write — which previously
|
|
13
|
+
got misreported as a coding agent "failing to start". Reconfigure both
|
|
14
|
+
streams to UTF-8 with replacement so output can never crash the process.
|
|
15
|
+
"""
|
|
16
|
+
for stream in (sys.stdout, sys.stderr):
|
|
17
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
18
|
+
if reconfigure is None:
|
|
19
|
+
continue
|
|
20
|
+
try:
|
|
21
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
22
|
+
except (ValueError, OSError):
|
|
23
|
+
try:
|
|
24
|
+
reconfigure(errors="replace")
|
|
25
|
+
except (ValueError, OSError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_configure_stdio()
|
|
30
|
+
|
|
31
|
+
from devcouncil.cli.commands import ( # noqa: E402 - imports follow stdio reconfiguration
|
|
5
32
|
artifacts,
|
|
33
|
+
agents,
|
|
6
34
|
baseline,
|
|
35
|
+
check,
|
|
7
36
|
config,
|
|
37
|
+
cost,
|
|
8
38
|
ast,
|
|
9
39
|
dashboard,
|
|
10
40
|
doctor,
|
|
@@ -22,6 +52,7 @@ from devcouncil.cli.commands import (
|
|
|
22
52
|
reset_demo_state,
|
|
23
53
|
rollback,
|
|
24
54
|
run,
|
|
55
|
+
runs,
|
|
25
56
|
setup,
|
|
26
57
|
show,
|
|
27
58
|
status,
|
|
@@ -30,8 +61,14 @@ from devcouncil.cli.commands import (
|
|
|
30
61
|
verify,
|
|
31
62
|
version,
|
|
32
63
|
watch,
|
|
64
|
+
shell,
|
|
65
|
+
semantic,
|
|
66
|
+
evidence,
|
|
67
|
+
handoff,
|
|
68
|
+
skills,
|
|
69
|
+
scaffold,
|
|
33
70
|
)
|
|
34
|
-
from devcouncil.cli.commands.
|
|
71
|
+
from devcouncil.cli.commands.watch_fs import watch_fs # noqa: E402 - imports follow stdio reconfiguration
|
|
35
72
|
|
|
36
73
|
app = typer.Typer(
|
|
37
74
|
name="dev",
|
|
@@ -47,41 +84,53 @@ app.add_typer(report.app, name="report")
|
|
|
47
84
|
app.add_typer(rollback.app, name="rollback")
|
|
48
85
|
app.add_typer(config.app, name="config")
|
|
49
86
|
app.add_typer(artifacts.app, name="artifacts")
|
|
87
|
+
app.add_typer(agents.app, name="agents")
|
|
50
88
|
app.add_typer(hook.app, name="hook")
|
|
51
89
|
app.add_typer(version.app, name="version")
|
|
52
90
|
app.add_typer(mcp_server.app, name="mcp-server")
|
|
53
91
|
app.add_typer(integrate.app, name="integrate")
|
|
54
92
|
app.add_typer(integrate.app, name="integrations")
|
|
55
93
|
app.add_typer(trace.app, name="trace")
|
|
94
|
+
app.add_typer(cost.app, name="cost")
|
|
95
|
+
app.add_typer(runs.app, name="runs")
|
|
56
96
|
app.add_typer(setup.app, name="setup")
|
|
57
97
|
app.add_typer(lsp.app, name="lsp")
|
|
58
98
|
app.add_typer(ast.app, name="ast")
|
|
59
99
|
app.add_typer(dashboard.app, name="dashboard")
|
|
60
100
|
app.add_typer(watch.app, name="watch")
|
|
101
|
+
app.add_typer(semantic.app, name="semantic")
|
|
102
|
+
app.add_typer(evidence.app, name="evidence")
|
|
103
|
+
app.add_typer(skills.app, name="skills")
|
|
104
|
+
watch.app.command("fs")(watch_fs)
|
|
61
105
|
|
|
62
106
|
# Direct command registrations (those defined as def cmd())
|
|
63
107
|
app.command(name="baseline")(baseline.baseline)
|
|
64
108
|
app.command(name="e2e")(go.go)
|
|
65
109
|
app.command(name="go")(go.go)
|
|
66
110
|
app.command(name="map")(map.map_repo)
|
|
111
|
+
app.command(name="scaffold-ci")(scaffold.scaffold_ci_command)
|
|
67
112
|
app.command(name="plan")(plan.plan)
|
|
113
|
+
app.command(name="approve")(plan.approve)
|
|
68
114
|
app.command(name="prompt")(prompt.prompt)
|
|
69
115
|
app.command(name="reset-demo-state")(reset_demo_state.reset_demo_state)
|
|
70
116
|
app.command(name="run")(run.run)
|
|
117
|
+
# shell/handoff take a positional TASK_ID followed by options, so they must be
|
|
118
|
+
# plain commands — as typer sub-apps (click groups) the documented
|
|
119
|
+
# `dev shell TASK-001 --command ...` form fails to parse.
|
|
120
|
+
app.command(name="shell")(shell.shell)
|
|
121
|
+
app.command(name="handoff")(handoff.handoff)
|
|
71
122
|
app.command(name="show")(show.show)
|
|
72
123
|
app.command(name="verify")(verify.verify)
|
|
124
|
+
app.command(name="check")(check.check)
|
|
73
125
|
app.command(name="repair")(repair.repair)
|
|
74
126
|
app.command(name="status")(status.status)
|
|
127
|
+
app.command(name="optimize")(agents.optimize_agent)
|
|
75
128
|
|
|
76
129
|
@app.callback()
|
|
77
130
|
def main(ctx: typer.Context):
|
|
78
131
|
"""
|
|
79
132
|
DevCouncil: Gated orchestrator for AI-assisted software development.
|
|
80
133
|
"""
|
|
81
|
-
if ctx.invoked_subcommand in {"init", "setup"}:
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
initialize_project(Path("."), quiet=True)
|
|
85
134
|
return
|
|
86
135
|
|
|
87
136
|
if __name__ == "__main__":
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
from pydantic import BaseModel
|
|
2
|
-
from typing import Literal, List
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from typing import Literal, List, Dict
|
|
3
3
|
|
|
4
4
|
class CommandResult(BaseModel):
|
|
5
5
|
command: str
|
|
@@ -15,6 +15,26 @@ class DiffEvidence(BaseModel):
|
|
|
15
15
|
deleted_files: List[str]
|
|
16
16
|
diff_summary: str
|
|
17
17
|
|
|
18
|
+
class DiffCoverageEvidence(BaseModel):
|
|
19
|
+
"""Proof that the changed lines were (or were not) exercised by the tests.
|
|
20
|
+
|
|
21
|
+
This is the executable evidence behind DevCouncil's core promise: a passing
|
|
22
|
+
suite is only acceptance evidence if the lines the diff changed were actually
|
|
23
|
+
run. ``measured`` is False when no reliable signal could be computed (no
|
|
24
|
+
coverage tool, no instrumentable test command, or no changed executable
|
|
25
|
+
lines), in which case it must never be read as a defect.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
task_id: str
|
|
29
|
+
tool: str = ""
|
|
30
|
+
measured: bool = False
|
|
31
|
+
changed_lines: int = 0
|
|
32
|
+
covered_lines: int = 0
|
|
33
|
+
coverage_ratio: float = 0.0
|
|
34
|
+
uncovered_by_file: Dict[str, List[int]] = Field(default_factory=dict)
|
|
35
|
+
absent_files: List[str] = Field(default_factory=list)
|
|
36
|
+
summary: str = ""
|
|
37
|
+
|
|
18
38
|
class VerificationEvidence(BaseModel):
|
|
19
39
|
__test__ = False # Prevent pytest from collecting this as a test class
|
|
20
40
|
requirement_id: str
|
|
@@ -11,12 +11,17 @@ class Gap(BaseModel):
|
|
|
11
11
|
"orphan_diff",
|
|
12
12
|
"missing_test",
|
|
13
13
|
"test_failed",
|
|
14
|
+
"invalid_verification_command",
|
|
14
15
|
"acceptance_criteria_unproven",
|
|
16
|
+
"diff_not_exercised",
|
|
15
17
|
"assumption_violated",
|
|
16
18
|
"architecture_drift",
|
|
17
19
|
"security_risk",
|
|
18
20
|
"dependency_risk",
|
|
19
|
-
"migration_gap"
|
|
21
|
+
"migration_gap",
|
|
22
|
+
"quality_gate_failed",
|
|
23
|
+
"skipped_verification_command",
|
|
24
|
+
"coarse_acceptance_proof",
|
|
20
25
|
]
|
|
21
26
|
requirement_id: Optional[str] = None
|
|
22
27
|
task_id: Optional[str] = None
|
|
@@ -24,3 +29,24 @@ class Gap(BaseModel):
|
|
|
24
29
|
evidence: List[str] = Field(default_factory=list)
|
|
25
30
|
recommended_fix: str
|
|
26
31
|
blocking: bool
|
|
32
|
+
# Machine-actionable hints for the agent self-repair loop. Populated at gap
|
|
33
|
+
# creation where known; consumed by the typed next-actions contract (see
|
|
34
|
+
# devcouncil.verification.next_actions). As of schema v4 these are persisted by
|
|
35
|
+
# the gap store and round-tripped on reload, so a reconnecting agent gets the
|
|
36
|
+
# full repair contract rather than a heuristic reconstruction.
|
|
37
|
+
file: Optional[str] = None
|
|
38
|
+
line: Optional[int] = None
|
|
39
|
+
suggested_command: Optional[str] = None
|
|
40
|
+
# The acceptance criterion this gap is about (when applicable), so the agent can
|
|
41
|
+
# tie a failure straight back to the criterion it must satisfy.
|
|
42
|
+
acceptance_criterion_id: Optional[str] = None
|
|
43
|
+
# Paths to the captured stdout/stderr logs (written under .devcouncil/logs) for the
|
|
44
|
+
# failing command behind this gap, so the agent can open the FULL failure output
|
|
45
|
+
# without re-running. Optional and defaulted for backward compatibility; the gap
|
|
46
|
+
# store does not persist these, so they are only present on a fresh verify run.
|
|
47
|
+
stdout_path: Optional[str] = None
|
|
48
|
+
stderr_path: Optional[str] = None
|
|
49
|
+
# Expected verification method (e.g. "unit_test"/"static_check") for an unproven
|
|
50
|
+
# acceptance criterion, so missing-evidence routing is concrete rather than a
|
|
51
|
+
# restatement of the description.
|
|
52
|
+
expected_verification_method: Optional[str] = None
|
|
@@ -13,9 +13,38 @@ class Task(BaseModel):
|
|
|
13
13
|
requirement_ids: List[str] = Field(default_factory=list)
|
|
14
14
|
acceptance_criterion_ids: List[str] = Field(default_factory=list)
|
|
15
15
|
planned_files: List[PlannedFile] = Field(default_factory=list)
|
|
16
|
-
expected_tests: List[str] = Field(
|
|
17
|
-
|
|
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
|
+
)
|
|
18
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
|
+
)
|
|
19
48
|
status: Literal[
|
|
20
49
|
"planned",
|
|
21
50
|
"ready",
|