devcouncil 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,253 @@
1
+ """Compile natural-language acceptance criteria into self-contained executable
2
+ checks that DevCouncil owns and runs.
3
+
4
+ This is the difference between trusting the planner/agent's word and gathering
5
+ real evidence: instead of running planner-authored ``expected_tests`` (which the
6
+ benchmark showed often reference tools or test files that do not exist), DevCouncil
7
+ derives one runnable check per acceptance criterion directly from the criterion
8
+ text and the code under review, then maps each check 1:1 to its criterion.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from typing import Dict, List
16
+
17
+ from pydantic import BaseModel
18
+
19
+ from devcouncil.domain.requirement import Requirement
20
+ from devcouncil.domain.task import Task
21
+ from devcouncil.llm.router import ModelRouter
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class CompiledCheck(BaseModel):
27
+ acceptance_criterion_id: str
28
+ command: str # a single shell command that exits 0 iff the criterion holds
29
+
30
+
31
+ class CompiledChecks(BaseModel):
32
+ checks: List[CompiledCheck]
33
+
34
+
35
+ # A worked example pinned to the prompt. Weak/local models follow a concrete
36
+ # AC->command pairing far more reliably than rules alone — it anchors the import
37
+ # style, the exception-guard idiom, and "assert behavior, not state".
38
+ _WORKED_EXAMPLE = """Worked example (follow this shape exactly):
39
+ Acceptance criterion: "median([]) raises ValueError"
40
+ Code under review: a new file `stats.py` defining `def median(values): ...`
41
+ CORRECT command:
42
+ python -c "import stats\\ntry: stats.median([])\\nexcept ValueError: pass\\nelse: raise SystemExit(1)"
43
+ Why: it imports the REAL module name implied by the file (stats.py -> import stats),
44
+ exercises the public function, and asserts the OBSERVABLE behavior (a raised
45
+ exception) — not any file/git state."""
46
+
47
+
48
+ class AcceptanceTestCompiler:
49
+ def __init__(self, router: ModelRouter, role: str = "implementation_reviewer"):
50
+ self.router = router
51
+ self.role = role
52
+
53
+ async def compile(
54
+ self,
55
+ task: Task,
56
+ requirements: List[Requirement],
57
+ code_context: str,
58
+ ) -> Dict[str, List[str]]:
59
+ """Return {acceptance_criterion_id: [self-contained check command(s)]}.
60
+
61
+ Best-effort: returns {} if the model cannot produce usable checks, so the
62
+ caller can fall back to the task's declared expected_tests. Single-shot
63
+ wrapper over :meth:`compile_candidates` (samples=1) for back-compat.
64
+ """
65
+ return await self.compile_candidates(task, requirements, code_context, samples=1)
66
+
67
+ async def compile_candidates(
68
+ self,
69
+ task: Task,
70
+ requirements: List[Requirement],
71
+ code_context: str,
72
+ samples: int = 1,
73
+ per_criterion: bool = False,
74
+ ) -> Dict[str, List[str]]:
75
+ """Return {acceptance_criterion_id: [up to ``samples`` INDEPENDENT check commands]}.
76
+
77
+ Each criterion gets several independently-generated behavioral checks so the
78
+ caller can decide by majority vote — outvoting a single mis-generated check
79
+ without auto-passing a real defect. ``samples=1`` yields one command per
80
+ criterion (identical to the original single-shot ``compile``). Local sampling
81
+ is cost-free, so a weak model benefits most from a higher count.
82
+
83
+ ``per_criterion`` compiles ONE criterion per model call instead of batching them
84
+ all into a single prompt. A weak model batching N criteria into one JSON response
85
+ routinely omits or mis-attributes some — producing false ``incomplete`` verdicts —
86
+ whereas a focused single-criterion prompt is far more reliable. It costs N× the
87
+ calls (cheap on a local monitor), so it is opt-in.
88
+ """
89
+ ac_by_id = {ac.id: ac for req in requirements for ac in req.acceptance_criteria}
90
+ target = [ac_by_id[i] for i in task.acceptance_criterion_ids if i in ac_by_id]
91
+ if not target:
92
+ return {}
93
+
94
+ if per_criterion and len(target) > 1:
95
+ out: Dict[str, List[str]] = {}
96
+ for ac in target:
97
+ out.update(await self._sample_checks([ac], code_context, samples))
98
+ return out
99
+ return await self._sample_checks(target, code_context, samples)
100
+
101
+ async def _sample_checks(
102
+ self, target: List, code_context: str, samples: int
103
+ ) -> Dict[str, List[str]]:
104
+ """Generate up to ``samples`` independent check commands for ``target`` criteria."""
105
+ valid_ids = {ac.id for ac in target}
106
+ out: Dict[str, List[str]] = {}
107
+ # Independent attempts. Each varies temperature + an attempt marker so the router
108
+ # cache keys differ (otherwise an identical prompt+temp returns the cached answer
109
+ # and every "sample" is the same command). Attempt 0 stays deterministic (temp 0).
110
+ for attempt in range(max(1, samples)):
111
+ temperature = 0.0 if attempt == 0 else min(0.8, 0.3 + 0.2 * attempt)
112
+ try:
113
+ result = await self.router.complete_structured(
114
+ role=self.role,
115
+ messages=[{"role": "user", "content": self._compile_prompt(target, code_context, attempt)}],
116
+ schema=CompiledChecks,
117
+ temperature=temperature,
118
+ fallback=CompiledChecks(checks=[]),
119
+ )
120
+ except Exception as e:
121
+ logger.warning("Acceptance-check compile attempt %d failed: %s", attempt, e)
122
+ continue
123
+ for check in result.checks:
124
+ cmd = (check.command or "").strip()
125
+ if check.acceptance_criterion_id in valid_ids and cmd:
126
+ bucket = out.setdefault(check.acceptance_criterion_id, [])
127
+ if cmd not in bucket: # dedup identical candidates across attempts
128
+ bucket.append(cmd)
129
+ logger.info(
130
+ "Compiled acceptance checks for %d/%d criteria (%d samples)",
131
+ len(out), len(valid_ids), max(1, samples),
132
+ )
133
+ return out
134
+
135
+ async def repair(
136
+ self,
137
+ ac_id: str,
138
+ ac_description: str,
139
+ failing_command: str,
140
+ error_summary: str,
141
+ code_context: str,
142
+ ) -> str | None:
143
+ """Regenerate a compiled check that FAILED TO RUN (malformed/unrunnable).
144
+
145
+ Given the broken command and the launcher error, ask the model to fix the
146
+ COMMAND so it runs against the code — never to change what it asserts. Returns
147
+ the repaired command, or None if the model cannot produce a usable one. Safe by
148
+ construction: a check that did not run proves nothing, so regenerating it cannot
149
+ weaken the gate (a repaired check still has to genuinely pass to count)."""
150
+ prompt = f"""The following DevCouncil acceptance check FAILED TO RUN — it is malformed or
151
+ its tooling/import is wrong, so it proves nothing about the code. Fix the COMMAND so it
152
+ RUNS and correctly tests the SAME criterion. Do NOT weaken or change what it asserts;
153
+ only fix what stops it from running (wrong module/import name, broken Python one-liner
154
+ syntax, unavailable tool).
155
+
156
+ Acceptance criterion ({ac_id}): {ac_description}
157
+
158
+ Failing command:
159
+ {failing_command}
160
+
161
+ Launcher error / output:
162
+ {error_summary}
163
+
164
+ Code under review:
165
+ {code_context}
166
+
167
+ {_WORKED_EXAMPLE}
168
+
169
+ Return JSON: one CompiledCheck with acceptance_criterion_id={ac_id!r} and the corrected
170
+ single self-contained command. If you cannot produce a runnable behavioral command,
171
+ return an empty checks list."""
172
+ try:
173
+ result = await self.router.complete_structured(
174
+ role=self.role,
175
+ messages=[{"role": "user", "content": prompt}],
176
+ schema=CompiledChecks,
177
+ fallback=CompiledChecks(checks=[]),
178
+ )
179
+ except Exception:
180
+ return None
181
+ for check in result.checks:
182
+ cmd = (check.command or "").strip()
183
+ if check.acceptance_criterion_id == ac_id and cmd and cmd != failing_command.strip():
184
+ return cmd
185
+ return None
186
+
187
+ def _compile_prompt(self, target, code_context: str, attempt: int = 0) -> str:
188
+ acs_json = json.dumps(
189
+ [{"id": ac.id, "description": ac.description, "method": ac.verification_method} for ac in target],
190
+ indent=2,
191
+ )
192
+ # Independent-attempt marker: nudges diversity across samples AND differentiates
193
+ # the router cache key so a second sample is actually regenerated, not replayed.
194
+ variant = "" if attempt == 0 else (
195
+ f"\nIndependent attempt #{attempt}: derive each check FROM SCRATCH; do not assume "
196
+ "a previous attempt's wording. Prefer a different but equivalent way to exercise "
197
+ "the same behavior.\n"
198
+ )
199
+ prompt = f"""
200
+ You are DevCouncil's acceptance-test compiler. Convert each acceptance criterion
201
+ below into exactly ONE shell command that EXITS 0 if and only if the BEHAVIOR
202
+ described by the criterion holds for the code shown.
203
+ {variant}
204
+ Acceptance criteria:
205
+ {acs_json}
206
+
207
+ Code under review (the diff / current files):
208
+ {code_context}
209
+
210
+ What a check must verify — BEHAVIOR ONLY:
211
+ - A check exists to confirm the code DOES what the criterion describes when its
212
+ public API is exercised: import the module/symbol and call its function(s), or
213
+ run its CLI/entrypoint, and assert on the observable result (return value,
214
+ raised exception, stdout, exit code).
215
+ - DevCouncil already enforces scope, file ownership, and append-only/orphan-diff
216
+ constraints with its OWN gates. Acceptance checks must therefore NEVER re-assert
217
+ repository or filesystem STATE — that is not their job and it produces false
218
+ BLOCKED results because `dev` itself adds workspace files (AGENTS.md, CLAUDE.md,
219
+ .gitignore, .devcouncil/config.yaml, etc.).
220
+
221
+ Rules — the commands are executed verbatim by the verifier:
222
+ - One command per acceptance_criterion_id (reference the id exactly).
223
+ - Each command MUST be a single, SELF-CONTAINED, immediately-runnable command:
224
+ import the real module/symbol from the code and assert the behavior directly.
225
+ Do NOT depend on test files, fixtures, or any external setup.
226
+ - Prefer: python -c "import <module>; assert <expr>". For an expected exception,
227
+ use a one-line guard, e.g.
228
+ python -c "import m; \\ntry: m.f([])\\nexcept ValueError: pass\\nelse: raise SystemExit(1)"
229
+ (real newlines are fine; never put try/if/for after a ';').
230
+ - Use the actual module name implied by the code (e.g. file 'stats.py' -> import stats).
231
+
232
+ HARD PROHIBITIONS — a command that does any of these is INVALID; omit the
233
+ criterion instead of emitting such a command:
234
+ - NEVER assert exact git or filesystem state. Forbidden: `git status`,
235
+ `git status --porcelain`, `git diff`, `git diff --name-only`, `git show`,
236
+ `git ls-files`, `ls`/`find`/`os.listdir` equality checks, asserting a precise
237
+ set or count of changed/created files, or asserting a file does/does not exist
238
+ as the criterion's pass condition.
239
+ - NEVER do append-only or byte-level file/content comparisons (e.g.
240
+ `git show HEAD:file`, diffing bytes, asserting only N bytes/lines were added).
241
+ Assert the resulting BEHAVIOR instead, not how the file changed.
242
+ - NEVER invoke linters, type checkers, formatters, or build/package tools that
243
+ may be absent: flake8, mypy, ruff, pylint, black, isort, eslint, tsc, prettier,
244
+ npm, npx, yarn, pnpm, cargo, go vet, etc. Only use such a tool if the code
245
+ context clearly shows it is configured for this repo (e.g. a matching config
246
+ section/file is present in the context) AND it is essential to the criterion.
247
+ - If a criterion cannot be checked by a behavioral command (e.g. pure 'manual'
248
+ review, or it only describes repo/tooling state), OMIT it rather than inventing
249
+ a state-based or bogus command.
250
+
251
+ {_WORKED_EXAMPLE}
252
+ """
253
+ return prompt
@@ -0,0 +1,135 @@
1
+ """Verify an ad-hoc working-tree diff against an inline requirement — no planning, no keys.
2
+
3
+ This powers ``dev check``'s evidence-gate mode (the lite entry point): wrap whatever is
4
+ in the working tree as a synthetic Requirement→Task, run the *same* deterministic
5
+ :class:`~devcouncil.verification.verifier.Verifier` the full workflow uses — orphan-diff,
6
+ secret scan, acceptance evidence, and the diff↔coverage gate — and return the verdict
7
+ plus the typed next-actions contract. ``router=None`` keeps it provider-key-free so a
8
+ newcomer can taste the evidence gate before committing to the full council flow.
9
+
10
+ The logic lives here (not in the CLI command) so it is unit-testable without Typer and
11
+ resilient to churn in the command module.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import List, Optional
21
+
22
+ from devcouncil.domain.evidence import DiffCoverageEvidence
23
+ from devcouncil.domain.gap import Gap
24
+ from devcouncil.domain.requirement import AcceptanceCriterion, Requirement
25
+ from devcouncil.domain.task import PlannedFile, Task
26
+ from devcouncil.llm.router import ModelRouter
27
+ from devcouncil.verification.next_actions import NextAction, build_next_actions
28
+ from devcouncil.verification.verifier import Verifier
29
+
30
+ _REQ_ID = "REQ-CHECK"
31
+ _AC_ID = "AC-CHECK"
32
+ _TASK_ID = "CHECK"
33
+
34
+ _DEFAULT_CRITERION = "The working-tree changes are correct and exercised by tests."
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class AdHocCheckResult:
41
+ requirement: str
42
+ changed_files: List[str] = field(default_factory=list)
43
+ gaps: List[Gap] = field(default_factory=list)
44
+ next_actions: List[NextAction] = field(default_factory=list)
45
+ diff_coverage: Optional[DiffCoverageEvidence] = None
46
+ passed: bool = True
47
+ reason: str = ""
48
+
49
+ def to_dict(self) -> dict:
50
+ return {
51
+ "ok": True,
52
+ "verified": self.passed,
53
+ "requirement": self.requirement,
54
+ "changed_files": self.changed_files,
55
+ "reason": self.reason,
56
+ "gap_count": len(self.gaps),
57
+ "blocking_gap_count": len([g for g in self.gaps if g.blocking]),
58
+ "gaps": [g.model_dump() for g in self.gaps],
59
+ "next_actions": [a.model_dump() for a in self.next_actions],
60
+ "diff_coverage": self.diff_coverage.model_dump() if self.diff_coverage else None,
61
+ }
62
+
63
+
64
+ def run_working_tree_check(
65
+ project_root: Path,
66
+ requirement: Optional[str] = None,
67
+ *,
68
+ test_commands: Optional[List[str]] = None,
69
+ enforce_coverage: bool = False,
70
+ min_ratio: float = 0.0,
71
+ router: Optional[ModelRouter] = None,
72
+ verifier: Optional[Verifier] = None,
73
+ ) -> AdHocCheckResult:
74
+ """Verify the current working-tree diff against a one-line requirement.
75
+
76
+ Builds a synthetic task whose planned files are exactly the changed files (so the
77
+ result is about evidence, not scope noise) and whose expected tests are
78
+ ``test_commands``. Diff coverage is always measured; pass ``enforce_coverage`` (or a
79
+ positive ``min_ratio``) to make an unexercised diff blocking.
80
+ """
81
+ verifier = verifier or Verifier(project_root, router=router)
82
+
83
+ diff = verifier.get_diff()
84
+ changed_files = verifier.get_changed_files()
85
+ if not diff or not changed_files:
86
+ logger.info("Ad-hoc check: no working-tree changes; passing trivially")
87
+ return AdHocCheckResult(requirement="", passed=True, reason="no_changes")
88
+ logger.info("Ad-hoc check: %d changed file(s), enforce_coverage=%s", len(changed_files), enforce_coverage or min_ratio > 0)
89
+
90
+ criterion = requirement or _DEFAULT_CRITERION
91
+ req = Requirement(
92
+ id=_REQ_ID,
93
+ title=(requirement or "Working-tree change")[:80],
94
+ description=criterion,
95
+ priority="high",
96
+ source="user",
97
+ acceptance_criteria=[
98
+ AcceptanceCriterion(id=_AC_ID, description=criterion, verification_method="unit_test"),
99
+ ],
100
+ )
101
+ untracked = set(verifier._get_untracked_files())
102
+ task = Task(
103
+ id=_TASK_ID,
104
+ title="Ad-hoc working-tree check",
105
+ description=criterion,
106
+ requirement_ids=[_REQ_ID],
107
+ acceptance_criterion_ids=[_AC_ID],
108
+ planned_files=[
109
+ PlannedFile(
110
+ path=path,
111
+ reason="working-tree change",
112
+ allowed_change="create" if path in untracked else "modify",
113
+ )
114
+ for path in changed_files
115
+ ],
116
+ expected_tests=list(test_commands or []),
117
+ )
118
+
119
+ # Always measure diff coverage in lite mode; block on it only when asked. A positive
120
+ # --min-coverage implies enforcement so the flag is never silently inert.
121
+ enforce = enforce_coverage or min_ratio > 0
122
+ verifier._diff_coverage_override = (True, enforce, float(min_ratio))
123
+
124
+ gaps, evidence = asyncio.run(verifier.verify_task(task, [req]))
125
+ coverage = next((ev for ev in evidence if isinstance(ev, DiffCoverageEvidence)), None)
126
+ blocking = [g for g in gaps if g.blocking]
127
+ logger.info("Ad-hoc check result: passed=%s (%d gap(s), %d blocking)", not blocking, len(gaps), len(blocking))
128
+ return AdHocCheckResult(
129
+ requirement=criterion,
130
+ changed_files=changed_files,
131
+ gaps=gaps,
132
+ next_actions=build_next_actions(gaps),
133
+ diff_coverage=coverage,
134
+ passed=not blocking,
135
+ )