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,353 @@
1
+ """Diff↔coverage intersection — proof that the *changed lines* were exercised.
2
+
3
+ DevCouncil's headline promise is that a passing test must prove the **new logic
4
+ was exercised**, not merely that *some* suite exited 0. An agent can make a green
5
+ suite pass while the changed code is never imported, never called, or shadowed by
6
+ an unrelated passing test. This module closes that gap: it runs a task's test
7
+ command under coverage instrumentation, then intersects the lines the tests
8
+ actually executed with the lines the diff changed.
9
+
10
+ Two failure shapes are caught:
11
+
12
+ 1. **Touched-but-not-exercised** — the changed file *is* in the coverage report,
13
+ but the changed executable lines were never executed (e.g. a passing test that
14
+ exercises a different branch).
15
+ 2. **Never-imported** — the changed source file is *absent* from the coverage
16
+ report entirely, meaning the tests never loaded it.
17
+
18
+ False-positive discipline (mirrors :class:`~devcouncil.verification.verifier.Verifier`):
19
+ this analysis only ever produces a *signal* when it has reliable data — a
20
+ parseable diff with real hunks, a detected coverage tool, and changed *executable*
21
+ lines to measure. When any of those is missing it returns
22
+ ``DiffCoverageResult(measured=False, ...)`` and the verifier degrades to its prior
23
+ behaviour rather than blocking correct work. Coverage is read from the **target
24
+ repository's** own tooling (coverage.py for Python); DevCouncil never forces its
25
+ own coverage dependency into the project under verification.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Dict, List, Optional, Set
34
+
35
+ # A unified-diff hunk header: ``@@ -<old> +<newStart>[,<newLen>] @@``. We only need
36
+ # the new-file starting line to walk added/context lines into new-file numbers.
37
+ _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
38
+
39
+ # Path fragments that mark a file as test code. Test files are excluded from the
40
+ # "must be exercised" denominator: a test exercising itself is not the new logic
41
+ # whose behaviour an acceptance criterion is about.
42
+ _TEST_MARKERS = (
43
+ "/tests/",
44
+ "tests/",
45
+ "/test_",
46
+ "test_",
47
+ "_test.",
48
+ ".test.",
49
+ ".spec.",
50
+ "_spec.",
51
+ "/spec/",
52
+ )
53
+
54
+
55
+ def _strip_diff_prefix(path: str) -> str:
56
+ """Strip a leading ``a/`` or ``b/`` and normalise to forward slashes."""
57
+ path = path.strip().strip('"')
58
+ if path.startswith(("a/", "b/")):
59
+ path = path[2:]
60
+ return path.replace("\\", "/")
61
+
62
+
63
+ def is_test_path(path: str) -> bool:
64
+ lowered = path.replace("\\", "/").lower()
65
+ return any(marker in lowered for marker in _TEST_MARKERS)
66
+
67
+
68
+ def is_code_like(line: str) -> bool:
69
+ """Conservative heuristic: a non-blank line that is not a pure comment.
70
+
71
+ Used only for changed files that are *absent* from the coverage report, where
72
+ no authoritative executable-line set exists. Imports, ``def``/``class``,
73
+ decorators and statements all count; blank lines and ``#`` comments do not.
74
+ Kept deliberately conservative so it never inflates the denominator.
75
+ """
76
+ stripped = line.strip()
77
+ return bool(stripped) and not stripped.startswith("#")
78
+
79
+
80
+ def parse_changed_lines(diff: str) -> Dict[str, Dict[int, str]]:
81
+ """Parse a unified diff into ``{file_path: {new_line_number: added_text}}``.
82
+
83
+ Only *added* lines (``+`` in the new file) are recorded, keyed by their line
84
+ number in the post-change file so they can be intersected with coverage data.
85
+ Deletions and context lines advance the counter but are not themselves
86
+ "changed lines" we require coverage for. Paths are normalised relative to the
87
+ repo (``a/``/``b/`` prefixes stripped, forward slashes).
88
+ """
89
+ changed: Dict[str, Dict[int, str]] = {}
90
+ current_file: Optional[str] = None
91
+ new_line = 0
92
+ in_hunk = False
93
+
94
+ for raw in diff.splitlines():
95
+ if raw.startswith("diff --git") or raw.startswith("--- "):
96
+ in_hunk = False
97
+ continue
98
+ if raw.startswith("+++ "):
99
+ target = raw[4:].strip()
100
+ if target == "/dev/null":
101
+ current_file = None
102
+ else:
103
+ current_file = _strip_diff_prefix(target)
104
+ changed.setdefault(current_file, {})
105
+ in_hunk = False
106
+ continue
107
+ if raw.startswith("@@"):
108
+ match = _HUNK_RE.match(raw)
109
+ if match:
110
+ new_line = int(match.group(1))
111
+ in_hunk = True
112
+ else:
113
+ # A header we can't number (e.g. a combined-merge ``@@@``). Stay out
114
+ # of hunk mode rather than mis-attribute added lines to line 0.
115
+ in_hunk = False
116
+ continue
117
+ if not in_hunk or current_file is None:
118
+ continue
119
+ if raw.startswith("\\"): # ""
120
+ continue
121
+ if raw.startswith("+"):
122
+ changed[current_file][new_line] = raw[1:]
123
+ new_line += 1
124
+ elif raw.startswith("-"):
125
+ continue # old-file only; does not advance the new-file counter
126
+ else:
127
+ new_line += 1 # context line
128
+
129
+ return {path: lines for path, lines in changed.items() if lines}
130
+
131
+
132
+ def parse_coverage_json(data: dict, root: Path) -> "CoverageData":
133
+ """Extract executed and executable lines per file from ``coverage json`` output.
134
+
135
+ ``coverage.py`` reports ``executed_lines`` and ``missing_lines`` per file; their
136
+ union is the set of statements coverage knows are executable. Paths are
137
+ normalised to repo-relative POSIX so they intersect with diff paths.
138
+ """
139
+ executed: Dict[str, Set[int]] = {}
140
+ executable: Dict[str, Set[int]] = {}
141
+ files = data.get("files", {}) if isinstance(data, dict) else {}
142
+ for raw_path, payload in files.items():
143
+ if not isinstance(payload, dict):
144
+ continue
145
+ rel = _relativize(raw_path, root)
146
+ if rel is None:
147
+ continue
148
+ run = {int(n) for n in payload.get("executed_lines", []) or []}
149
+ miss = {int(n) for n in payload.get("missing_lines", []) or []}
150
+ executed[rel] = run
151
+ executable[rel] = run | miss
152
+ return CoverageData(executed=executed, executable=executable)
153
+
154
+
155
+ def _relativize(raw_path: str, root: Path) -> Optional[str]:
156
+ candidate = Path(raw_path)
157
+ try:
158
+ if candidate.is_absolute():
159
+ rel = candidate.resolve().relative_to(root.resolve())
160
+ else:
161
+ rel = candidate
162
+ except ValueError:
163
+ # Outside the repo (site-packages, stdlib) — not a changed-file candidate.
164
+ return None
165
+ return rel.as_posix()
166
+
167
+
168
+ @dataclass
169
+ class CoverageData:
170
+ executed: Dict[str, Set[int]] = field(default_factory=dict)
171
+ executable: Dict[str, Set[int]] = field(default_factory=dict)
172
+
173
+
174
+ @dataclass
175
+ class DiffCoverageResult:
176
+ """Outcome of intersecting changed lines with executed lines.
177
+
178
+ ``measured`` is True only when there was a meaningful signal to compute — at
179
+ least one changed *executable* line. When False, callers must NOT treat the
180
+ result as evidence of a problem (false-positive discipline).
181
+ """
182
+
183
+ measured: bool
184
+ tool: str = ""
185
+ reason: str = ""
186
+ changed_executable_lines: int = 0
187
+ covered_changed_lines: int = 0
188
+ uncovered_by_file: Dict[str, List[int]] = field(default_factory=dict)
189
+ absent_files: List[str] = field(default_factory=list)
190
+
191
+ @property
192
+ def ratio(self) -> float:
193
+ if self.changed_executable_lines == 0:
194
+ return 1.0
195
+ return self.covered_changed_lines / self.changed_executable_lines
196
+
197
+ def summary(self) -> str:
198
+ if not self.measured:
199
+ return f"diff coverage not measured ({self.reason})" if self.reason else "diff coverage not measured"
200
+ pct = round(self.ratio * 100)
201
+ base = (
202
+ f"{self.covered_changed_lines}/{self.changed_executable_lines} changed lines exercised "
203
+ f"({pct}%) via {self.tool or 'coverage'}"
204
+ )
205
+ if self.absent_files:
206
+ base += f"; not imported by tests: {', '.join(self.absent_files)}"
207
+ return base
208
+
209
+
210
+ def intersect(
211
+ changed: Dict[str, Dict[int, str]],
212
+ coverage: CoverageData,
213
+ *,
214
+ tool: str = "coverage.py",
215
+ ) -> DiffCoverageResult:
216
+ """Intersect changed lines with executed lines to measure diff coverage.
217
+
218
+ ``changed`` should already be filtered to measurable source files (e.g. ``.py``
219
+ non-test files). For each file present in the coverage report we use coverage's
220
+ authoritative executable-line set; for changed source files *absent* from the
221
+ report (never imported) we fall back to the conservative ``is_code_like``
222
+ heuristic and count those added lines as executable-but-uncovered.
223
+ """
224
+ total_executable = 0
225
+ total_covered = 0
226
+ uncovered_by_file: Dict[str, List[int]] = {}
227
+ absent_files: List[str] = []
228
+
229
+ for path, line_map in changed.items():
230
+ changed_nums = set(line_map.keys())
231
+ if path in coverage.executable:
232
+ file_executable = changed_nums & coverage.executable[path]
233
+ file_covered = changed_nums & coverage.executed.get(path, set())
234
+ total_executable += len(file_executable)
235
+ total_covered += len(file_covered)
236
+ missing = sorted(file_executable - file_covered)
237
+ if missing:
238
+ uncovered_by_file[path] = missing
239
+ else:
240
+ # Absent from the coverage report -> the tests never loaded this file.
241
+ code_like = sorted(num for num, text in line_map.items() if is_code_like(text))
242
+ if code_like:
243
+ total_executable += len(code_like)
244
+ uncovered_by_file[path] = code_like
245
+ absent_files.append(path)
246
+
247
+ if total_executable == 0:
248
+ return DiffCoverageResult(
249
+ measured=False,
250
+ tool=tool,
251
+ reason="no changed executable lines to measure",
252
+ )
253
+
254
+ return DiffCoverageResult(
255
+ measured=True,
256
+ tool=tool,
257
+ changed_executable_lines=total_executable,
258
+ covered_changed_lines=total_covered,
259
+ uncovered_by_file=uncovered_by_file,
260
+ absent_files=absent_files,
261
+ )
262
+
263
+
264
+ def measurable_python_changes(changed: Dict[str, Dict[int, str]]) -> Dict[str, Dict[int, str]]:
265
+ """Filter parsed diff lines to Python source files (non-test) coverage.py can measure."""
266
+ return {
267
+ path: lines
268
+ for path, lines in changed.items()
269
+ if path.endswith(".py") and not is_test_path(path)
270
+ }
271
+
272
+
273
+ def coverage_run_argv(
274
+ command_argv: List[str],
275
+ python: str,
276
+ *,
277
+ append: bool,
278
+ data_file: str,
279
+ source: str = ".",
280
+ ) -> Optional[List[str]]:
281
+ """Transform a test command's argv into a ``coverage run`` invocation.
282
+
283
+ Supports the common Python entry points. Returns ``None`` for commands that
284
+ cannot be instrumented (the caller then leaves diff coverage unmeasured rather
285
+ than guessing). ``python -c "<code>"`` is handled separately by the caller
286
+ because it must materialise a temp script first.
287
+ """
288
+ if not command_argv:
289
+ return None
290
+
291
+ prefix = [python, "-m", "coverage", "run", f"--source={source}", f"--data-file={data_file}"]
292
+ if append:
293
+ prefix.append("-a")
294
+
295
+ head = Path(command_argv[0]).name.lower()
296
+ if head.endswith(".exe"): # Windows: python.exe / pytest.exe
297
+ head = head[:-4]
298
+ rest = command_argv[1:]
299
+
300
+ # python -m pytest / python -m unittest -> reuse the same module entry point.
301
+ if head in {"python", "python3", "py"} and len(rest) >= 2 and rest[0] == "-m":
302
+ module = rest[1]
303
+ if module in {"pytest", "unittest"}:
304
+ return [*prefix, "-m", module, *rest[2:]]
305
+ return None
306
+ # bare pytest -> run via the module entry point under coverage.
307
+ if head in {"pytest", "py.test"}:
308
+ return [*prefix, "-m", "pytest", *rest]
309
+ return None
310
+
311
+
312
+ def inline_python_code(command_argv: List[str]) -> Optional[str]:
313
+ """Return the ``CODE`` of a ``python -c "CODE"`` command, else None.
314
+
315
+ DevCouncil's acceptance compiler and many planner ``expected_tests`` are inline
316
+ assertions (``python -c "import m; assert m.f()==1"``). These are exactly the
317
+ checks whose diff coverage matters, so they are instrumented via a temp script
318
+ (see :func:`coverage_run_script_argv`) rather than left unmeasured.
319
+ """
320
+ if len(command_argv) < 3:
321
+ return None
322
+ head = Path(command_argv[0]).name.lower()
323
+ if head.endswith(".exe"):
324
+ head = head[:-4]
325
+ if head in {"python", "python3", "py"} and command_argv[1] == "-c":
326
+ return command_argv[2]
327
+ return None
328
+
329
+
330
+ def inline_script_content(code: str, root: Path) -> str:
331
+ """Wrap inline ``-c`` code as a script that imports like ``python -c`` would.
332
+
333
+ ``python -c`` puts the current working directory on ``sys.path``; a plain script
334
+ instead puts the script's own directory there. Since the temp script lives under
335
+ ``.devcouncil/tmp`` we re-insert the repo root so ``import <module>`` resolves the
336
+ same way the original inline check did.
337
+ """
338
+ return f"import sys\nsys.path.insert(0, {str(Path(root))!r})\n{code}\n"
339
+
340
+
341
+ def coverage_run_script_argv(
342
+ script_path: str,
343
+ python: str,
344
+ *,
345
+ append: bool,
346
+ data_file: str,
347
+ source: str = ".",
348
+ ) -> List[str]:
349
+ """A ``coverage run`` invocation for a materialised script (used for inline checks)."""
350
+ prefix = [python, "-m", "coverage", "run", f"--source={source}", f"--data-file={data_file}"]
351
+ if append:
352
+ prefix.append("-a")
353
+ return [*prefix, script_path]
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  from typing import List
3
4
  from pydantic import BaseModel
4
5
  from devcouncil.domain.task import Task
@@ -7,6 +8,8 @@ from devcouncil.domain.gap import Gap
7
8
  from devcouncil.llm.router import ModelRouter
8
9
  from devcouncil.utils.redaction import redact_string
9
10
 
11
+ logger = logging.getLogger(__name__)
12
+
10
13
  class ReviewOutput(BaseModel):
11
14
  is_satisfactory: bool
12
15
  findings: List[Gap]
@@ -47,9 +50,15 @@ Your task is to identify if the implementation is complete, correct, and follows
47
50
  Return a JSON object with 'is_satisfactory' and a list of 'findings' (as Gap objects).
48
51
  """
49
52
  messages = [{"role": "user", "content": prompt}]
50
-
51
- return await self.router.complete_structured(
53
+
54
+ logger.info("Implementation review: task=%s diff_bytes=%d", task.id, len(diff))
55
+ result = await self.router.complete_structured(
52
56
  role="implementation_reviewer",
53
57
  messages=messages,
54
58
  schema=ReviewOutput
55
59
  )
60
+ logger.info(
61
+ "Implementation review for %s: satisfactory=%s findings=%d",
62
+ task.id, result.is_satisfactory, len(result.findings),
63
+ )
64
+ return result
@@ -0,0 +1,189 @@
1
+ """Typed, machine-actionable next actions derived from verification gaps.
2
+
3
+ A human-readable "here is what's wrong" report is fine for a person, but an agent
4
+ in a closed loop needs a structured contract it can route on without parsing
5
+ prose. ``build_next_actions`` turns the gaps from a verification run into a list of
6
+ :class:`NextAction` records — ``{category, action, file, line, missing_evidence,
7
+ suggested_command}`` — so a coding agent (over MCP) can self-repair and re-verify
8
+ without a human pasting anything.
9
+
10
+ The mapping is deterministic. Where a gap was created with explicit hints
11
+ (``gap.file``/``gap.line``/``gap.suggested_command``) those are used directly; when
12
+ a gap is reloaded from the database (which does not persist those hint columns) the
13
+ fields are reconstructed best-effort from the gap's evidence and description.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import List, Optional
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ from devcouncil.domain.gap import Gap
23
+
24
+ # Machine-routable buckets an agent can branch on. Kept small and stable so the
25
+ # contract is predictable across releases.
26
+ Category = str
27
+
28
+ _CATEGORY_BY_GAP_TYPE = {
29
+ "orphan_diff": "scope",
30
+ "planned_file_not_changed": "scope",
31
+ "dependency_risk": "scope",
32
+ "test_failed": "fix_code",
33
+ "invalid_verification_command": "fix_verification",
34
+ "acceptance_criteria_unproven": "add_test",
35
+ "diff_not_exercised": "add_test",
36
+ "missing_test": "add_test",
37
+ "security_risk": "security",
38
+ "architecture_drift": "review",
39
+ "assumption_violated": "review",
40
+ "migration_gap": "fix_code",
41
+ "requirement_not_planned": "plan",
42
+ "task_not_implemented": "plan",
43
+ }
44
+
45
+
46
+ class NextAction(BaseModel):
47
+ """One concrete, routable step the agent can take to clear a gap."""
48
+
49
+ gap_id: str
50
+ gap_type: str
51
+ category: Category
52
+ severity: str
53
+ blocking: bool
54
+ action: str
55
+ file: Optional[str] = None
56
+ line: Optional[int] = None
57
+ acceptance_criterion_id: Optional[str] = None
58
+ expected_verification_method: Optional[str] = None
59
+ missing_evidence: Optional[str] = None
60
+ suggested_command: Optional[str] = None
61
+ evidence: List[str] = Field(default_factory=list)
62
+ # Paths to the captured stdout/stderr logs for the failing command behind this
63
+ # gap, so the agent can open the full failure output without re-running. Present
64
+ # only on a fresh verify run (the gap store does not persist them).
65
+ stdout_path: Optional[str] = None
66
+ stderr_path: Optional[str] = None
67
+
68
+
69
+ def _looks_like_path(value: str) -> bool:
70
+ value = value.strip()
71
+ if not value or " " in value or "\n" in value:
72
+ return False
73
+ return "/" in value or "." in value
74
+
75
+
76
+ def _derive_file(gap: Gap) -> Optional[str]:
77
+ if gap.file:
78
+ return gap.file
79
+ for item in gap.evidence:
80
+ if isinstance(item, str) and _looks_like_path(item):
81
+ return item
82
+ return None
83
+
84
+
85
+ def _action_text(gap: Gap, file: Optional[str]) -> str:
86
+ target = file or "the affected file"
87
+ if gap.gap_type == "orphan_diff":
88
+ return f"Revert changes to {target} or add it to the task's planned files."
89
+ if gap.gap_type == "planned_file_not_changed":
90
+ return f"Modify {target} as planned, or remove it from the task's planned files."
91
+ if gap.gap_type == "dependency_risk":
92
+ return f"Justify or revert the unplanned dependency/config change in {target}."
93
+ if gap.gap_type == "test_failed":
94
+ cmd = gap.suggested_command
95
+ return f"Fix the failing check, then re-run: {cmd}" if cmd else "Fix the failing verification check, then re-verify."
96
+ if gap.gap_type == "invalid_verification_command":
97
+ return "Replace the unrunnable verification command with a single runnable command, then re-verify."
98
+ if gap.gap_type == "diff_not_exercised":
99
+ loc = f" ({target}{':' + str(gap.line) if gap.line else ''})" if file else ""
100
+ return f"Add or extend a test that executes the changed lines{loc}, then re-verify."
101
+ if gap.gap_type in {"acceptance_criteria_unproven", "missing_test"}:
102
+ return "Provide a passing verification command that proves this acceptance criterion."
103
+ if gap.gap_type == "security_risk":
104
+ return "Remove the detected secret/finding from the diff and rotate any exposed credential."
105
+ if gap.gap_type == "architecture_drift":
106
+ return "Address the flagged change or resolve the open critique card, then re-verify."
107
+ # Fall back to the gap's own recommended fix for anything unmapped.
108
+ return gap.recommended_fix
109
+
110
+
111
+ def _missing_evidence(gap: Gap) -> Optional[str]:
112
+ """A concrete description of WHAT evidence is missing — not a restatement of the
113
+ description.
114
+
115
+ For an unproven acceptance criterion we name the criterion and the expected
116
+ verification method (and, where the verifier knew one, the expected check command
117
+ via ``gap.suggested_command``) so the agent can author the right proof rather than
118
+ re-reading prose. Falls back to the gap description for the other add-test gap
119
+ types (diff_not_exercised, missing_test) which already carry concrete locations."""
120
+ if gap.gap_type == "acceptance_criteria_unproven":
121
+ parts: List[str] = []
122
+ ac = gap.acceptance_criterion_id
123
+ method = gap.expected_verification_method
124
+ if ac:
125
+ parts.append(f"No passing evidence for acceptance criterion {ac}")
126
+ else:
127
+ parts.append("No passing acceptance evidence")
128
+ if method:
129
+ parts.append(f"expected verification method: {method}")
130
+ if gap.suggested_command:
131
+ parts.append(f"run/repair check: {gap.suggested_command}")
132
+ elif gap.file:
133
+ loc = f"{gap.file}:{gap.line}" if gap.line else gap.file
134
+ parts.append(f"uncovered: {loc}")
135
+ return "; ".join(parts)
136
+ if gap.gap_type in {"diff_not_exercised", "missing_test"}:
137
+ return gap.description
138
+ return None
139
+
140
+
141
+ def next_action_for(gap: Gap) -> NextAction:
142
+ file = _derive_file(gap)
143
+ return NextAction(
144
+ gap_id=gap.id,
145
+ gap_type=gap.gap_type,
146
+ category=_CATEGORY_BY_GAP_TYPE.get(gap.gap_type, "review"),
147
+ severity=gap.severity,
148
+ blocking=gap.blocking,
149
+ action=_action_text(gap, file),
150
+ file=file,
151
+ line=gap.line,
152
+ acceptance_criterion_id=gap.acceptance_criterion_id,
153
+ expected_verification_method=gap.expected_verification_method,
154
+ missing_evidence=_missing_evidence(gap),
155
+ suggested_command=gap.suggested_command,
156
+ evidence=list(gap.evidence),
157
+ stdout_path=gap.stdout_path,
158
+ stderr_path=gap.stderr_path,
159
+ )
160
+
161
+
162
+ def build_next_actions(gaps: List[Gap], *, blocking_only: bool = True) -> List[NextAction]:
163
+ """Build the next-actions contract from verification gaps.
164
+
165
+ By default only *blocking* gaps become next actions — those are what the agent
166
+ must clear to pass verification. Pass ``blocking_only=False`` to include
167
+ advisory signals (non-blocking gaps) as well. Blocking actions are ordered
168
+ first, then by severity.
169
+ """
170
+ selected = [g for g in gaps if g.blocking] if blocking_only else list(gaps)
171
+ severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
172
+ selected.sort(key=lambda g: (not g.blocking, severity_rank.get(g.severity, 4)))
173
+ return [next_action_for(gap) for gap in selected]
174
+
175
+
176
+ def split_next_actions(gaps: List[Gap]) -> tuple[List[NextAction], List[NextAction]]:
177
+ """Return ``(blocking_actions, advisory_actions)``.
178
+
179
+ Blocking actions are what the agent MUST clear to pass the gate. Advisory
180
+ actions are non-blocking signals worth acting on — most importantly the
181
+ diff↔coverage ``diff_not_exercised`` finding ("tests passed but the new code
182
+ was never run") and security/add-test hints, which ``build_next_actions``
183
+ filters out by default. Surfacing them as a distinct array lets an autonomous
184
+ agent improve quality without confusing them with the pass/fail gate.
185
+ """
186
+ all_actions = build_next_actions(gaps, blocking_only=False)
187
+ blocking = [a for a in all_actions if a.blocking]
188
+ advisory = [a for a in all_actions if not a.blocking]
189
+ return blocking, advisory