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,69 @@
1
+ import json
2
+ from typing import NoReturn
3
+
4
+ import typer
5
+ from pathlib import Path
6
+ from rich.console import Console
7
+
8
+ from devcouncil.cli.commands.init import initialize_project
9
+ from devcouncil.execution.handoff import HandoffService
10
+ from devcouncil.executors.agent_registry import load_cli_agent_specs, normalize_agent_name
11
+
12
+ console = Console()
13
+
14
+
15
+ def handoff(
16
+ task_id: str = typer.Argument(...),
17
+ from_agent: str = typer.Option(..., "--from"),
18
+ to_agent: str = typer.Option(..., "--to"),
19
+ instruction: str = typer.Option("", "--instruction"),
20
+ json_format: bool = typer.Option(False, "--json", help="Output machine-readable JSON the agent can chain on."),
21
+ project_root: Path = typer.Option(Path("."), "--project-root"),
22
+ ):
23
+ """
24
+ Hand off a task between coding CLI agents.
25
+ """
26
+ def _fail(message: str) -> NoReturn:
27
+ if json_format:
28
+ typer.echo(json.dumps({"ok": False, "task_id": task_id, "error": message}, indent=2))
29
+ else:
30
+ console.print(f"[red]{message}[/red]")
31
+ raise typer.Exit(code=1)
32
+
33
+ root = project_root.expanduser().resolve()
34
+ initialize_project(root, quiet=True)
35
+ specs = load_cli_agent_specs(root)
36
+ from_name = normalize_agent_name(from_agent)
37
+ to_name = normalize_agent_name(to_agent)
38
+ if from_name not in specs or to_name not in specs:
39
+ _fail("Unknown agent name. Use dev agents list.")
40
+
41
+ try:
42
+ manifest, path, run_id = HandoffService(root).create(
43
+ task_id,
44
+ from_name,
45
+ to_name,
46
+ instruction=instruction,
47
+ )
48
+ except ValueError as exc:
49
+ _fail(str(exc))
50
+
51
+ next_command = f"dev run {task_id} --executor {to_name}"
52
+ if json_format:
53
+ typer.echo(json.dumps({
54
+ "ok": True,
55
+ "task_id": task_id,
56
+ "from": from_name,
57
+ "to": to_name,
58
+ "manifest_path": str(path),
59
+ "run_id": run_id,
60
+ "next_command": next_command,
61
+ }, indent=2))
62
+ return
63
+
64
+ console.print(f"[green]Handoff manifest:[/green] {path}")
65
+ console.print(f"[cyan]Next:[/cyan] {next_command}")
66
+ console.print(f"[dim]Run artifacts: .devcouncil/runs/{run_id}[/dim]")
67
+ if instruction:
68
+ console.print(f"[dim]Instruction: {instruction}[/dim]")
69
+ _ = manifest
@@ -23,13 +23,17 @@ def _project_root(project_root: Path | None = None) -> Path:
23
23
 
24
24
 
25
25
  def _active_task(root: Path):
26
+ # Resolve the *single* unambiguous running task. active_task_id returns None when
27
+ # zero or multiple tasks are running, so we never authorize a write against the
28
+ # wrong task; the policy engine then denies for task=None (fail-closed).
29
+ active_id = active_task_id(root)
30
+ if not active_id:
31
+ return None
26
32
  db = get_db(root)
27
33
  if not db:
28
34
  return None
29
35
  with db.get_session() as session:
30
- task_repo = TaskRepository(session)
31
- running_tasks = [t for t in task_repo.get_all() if t.status == "running"]
32
- return running_tasks[0] if running_tasks else None
36
+ return TaskRepository(session).get_by_id(active_id)
33
37
 
34
38
 
35
39
  def _emit_decision(client: str, action: str, reason: str) -> None:
@@ -48,31 +52,53 @@ def _emit_decision(client: str, action: str, reason: str) -> None:
48
52
  console.print(f"[yellow]DevCouncil Warning:[/yellow] {reason}")
49
53
 
50
54
 
55
+ def _emit_unevaluable(client: str, reason: str, strict: bool, *, action: str = "warn") -> None:
56
+ """Decide what to do when a tool call cannot be evaluated (empty/malformed/error).
57
+
58
+ Fail-closed in strict mode (block), otherwise surface a warning but allow — and
59
+ never leak an undefined exit code, which would silently disable the only pre-action
60
+ gate."""
61
+ _emit_decision(client, "deny" if strict else action, f"{reason}{' (strict mode: blocking)' if strict else ''}")
62
+
63
+
51
64
  @app.command()
52
65
  def pre_tool_use(
53
66
  tool_call_json: str | None = typer.Argument(None, help="The JSON string of the tool call from the coding CLI."),
54
67
  client: str = typer.Option("claude", "--client", help="Hook client: claude, codex, gemini, cursor, or generic."),
55
68
  project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
69
+ strict: bool = typer.Option(
70
+ False,
71
+ "--strict",
72
+ envvar="DEVCOUNCIL_HOOK_STRICT",
73
+ help="Fail closed (block) when a tool call cannot be parsed or evaluated.",
74
+ ),
56
75
  ):
57
76
  """
58
77
  Coding CLI hook: Inspects a tool call before execution.
59
78
  Exits with code 2 to block unauthorized file writes.
60
79
  """
80
+ normalized_client = client.lower()
61
81
  try:
62
82
  if tool_call_json is None:
63
83
  tool_call_json = sys.stdin.read()
84
+ # Empty payload: nothing to evaluate. Benign in normal use, so allow — but make
85
+ # it observable, and block under --strict.
64
86
  if not tool_call_json.strip():
65
- raise typer.Exit(code=0)
66
- call_data = json.loads(tool_call_json)
67
- normalized_client = client.lower()
87
+ return _emit_unevaluable(normalized_client, "Empty tool-call payload; nothing to evaluate.", strict, action="allow")
88
+ try:
89
+ call_data = json.loads(tool_call_json)
90
+ except json.JSONDecodeError:
91
+ # A real tool call we cannot parse must not silently pass the gate.
92
+ return _emit_unevaluable(normalized_client, "Tool-call payload was not valid JSON; could not enforce policy.", strict)
68
93
  root = _project_root(project_root)
69
94
  active_task = _active_task(root)
70
95
 
71
96
  decision = HookPolicy(project_root=root).evaluate(call_data, active_task)
72
97
  _emit_decision(normalized_client, decision.action, decision.reason)
73
-
74
- except json.JSONDecodeError:
75
- raise typer.Exit(code=0)
98
+ except typer.Exit:
99
+ raise
100
+ except Exception as exc: # never emit an undefined exit code from a crashing hook
101
+ return _emit_unevaluable(normalized_client, f"Hook error: {exc}", strict)
76
102
 
77
103
  @app.command()
78
104
  def post_tool_use(
@@ -116,13 +142,268 @@ def agent_response(
116
142
  if client.lower() in {"codex", "gemini"}:
117
143
  print(json.dumps({"decision": "allow", "suppressOutput": True}, separators=(",", ":")))
118
144
 
145
+ def _status_line(root: Path) -> str | None:
146
+ """A one-line DevCouncil status snapshot, or None when uninitialized/unavailable.
147
+
148
+ Used by the SessionStart and UserPromptSubmit hooks to inject lightweight project
149
+ context into Claude Code. Best-effort: any failure returns None so a hook never
150
+ breaks the session."""
151
+ try:
152
+ db = get_db(root)
153
+ if not db:
154
+ return None
155
+ from devcouncil.storage.repositories import ArtifactGraphRepository, StateRepository
156
+ from devcouncil.app.project_status import compute_phase
157
+
158
+ with db.get_session() as session:
159
+ graph = ArtifactGraphRepository(session).load_graph()
160
+ summary = graph.coverage_summary()
161
+ state = StateRepository(session).get_state()
162
+ phase = compute_phase(graph, state.current_phase if state else None)
163
+ return (
164
+ f"DevCouncil — phase: {phase}; tasks: {summary['total_tasks']}; "
165
+ f"gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking). "
166
+ "Use the devcouncil_* MCP tools and `dev` CLI to stay inside the verify loop."
167
+ )
168
+ except Exception:
169
+ return None
170
+
171
+
172
+ def _emit_additional_context(event_name: str, context: str | None) -> None:
173
+ """Emit a Claude-Code hook result that injects additionalContext (exit 0)."""
174
+ if not context:
175
+ return
176
+ print(json.dumps({
177
+ "hookSpecificOutput": {
178
+ "hookEventName": event_name,
179
+ "additionalContext": context,
180
+ }
181
+ }, separators=(",", ":")))
182
+
183
+
184
+ def _read_stdin_payload(event_json: str | None) -> dict:
185
+ text = event_json if event_json is not None else sys.stdin.read()
186
+ if not text or not text.strip():
187
+ return {}
188
+ try:
189
+ parsed = json.loads(text)
190
+ return parsed if isinstance(parsed, dict) else {"raw": text}
191
+ except json.JSONDecodeError:
192
+ return {"raw": text}
193
+
194
+
195
+ @app.command()
196
+ def session_start(
197
+ event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
198
+ client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
199
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
200
+ ):
201
+ """Claude Code SessionStart hook: inject a DevCouncil status snapshot as context."""
202
+ _read_stdin_payload(event_json)
203
+ root = _project_root(project_root)
204
+ try:
205
+ TraceLogger(root).log_event("session_start", {"client": client.lower()}, summary="Claude session started.")
206
+ except Exception:
207
+ pass
208
+ _emit_additional_context("SessionStart", _status_line(root))
209
+
210
+
211
+ @app.command()
212
+ def user_prompt_submit(
213
+ event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
214
+ client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
215
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
216
+ ):
217
+ """Claude Code UserPromptSubmit hook: surface the current DevCouncil status as context."""
218
+ _read_stdin_payload(event_json)
219
+ root = _project_root(project_root)
220
+ _emit_additional_context("UserPromptSubmit", _status_line(root))
221
+
222
+
223
+ @app.command()
224
+ def session_end(
225
+ event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
226
+ client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
227
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
228
+ ):
229
+ """Claude Code SessionEnd hook: record session teardown in the DevCouncil trace."""
230
+ _read_stdin_payload(event_json)
231
+ root = _project_root(project_root)
232
+ try:
233
+ TraceLogger(root).log_event("session_end", {"client": client.lower()}, summary="Claude session ended.")
234
+ except Exception:
235
+ pass
236
+
237
+
238
+ @app.command()
239
+ def pre_compact(
240
+ event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
241
+ client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
242
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
243
+ ):
244
+ """Claude Code PreCompact hook: record that context compaction is about to run."""
245
+ _read_stdin_payload(event_json)
246
+ root = _project_root(project_root)
247
+ try:
248
+ TraceLogger(root).log_event("pre_compact", {"client": client.lower()}, summary="Claude context compaction starting.")
249
+ except Exception:
250
+ pass
251
+
252
+
253
+ @app.command()
254
+ def subagent_stop(
255
+ event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
256
+ client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
257
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
258
+ ):
259
+ """Claude Code SubagentStop hook: write a review signal when a subagent finishes."""
260
+ payload = _read_stdin_payload(event_json)
261
+ root = _project_root(project_root)
262
+ if not any(key in payload for key in ("task_id", "taskId", "task")):
263
+ active_id = active_task_id(root)
264
+ if active_id:
265
+ payload["task_id"] = active_id
266
+ try:
267
+ signal_path = write_signal(root, client.lower(), payload)
268
+ TraceLogger(root).log_event(
269
+ "subagent_stop",
270
+ {"client": client.lower(), "signal": str(signal_path)},
271
+ summary=f"{client} subagent finished; signal recorded.",
272
+ )
273
+ except Exception:
274
+ pass
275
+
276
+
277
+ @app.command()
278
+ def notification(
279
+ event_json: str | None = typer.Argument(None, help="The JSON hook payload from Claude Code."),
280
+ client: str = typer.Option("claude", "--client", help="Hook client (claude)."),
281
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
282
+ ):
283
+ """Claude Code Notification hook: record a Claude notification in the DevCouncil trace."""
284
+ payload = _read_stdin_payload(event_json)
285
+ root = _project_root(project_root)
286
+ try:
287
+ TraceLogger(root).log_event(
288
+ "claude_notification",
289
+ {"client": client.lower(), "message": str(payload.get("message", ""))[:200]},
290
+ summary="Claude notification.",
291
+ )
292
+ except Exception:
293
+ pass
294
+
295
+
296
+ @app.command()
297
+ def claude_statusline(
298
+ event_json: str | None = typer.Argument(None, help="The JSON status payload from Claude Code."),
299
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
300
+ ):
301
+ """Claude Code statusLine command: print a compact DevCouncil status line.
302
+
303
+ Reads Claude's status JSON on stdin (cwd, model, ...) and prints one line. Falls back
304
+ to a minimal marker when the project isn't initialized so the status bar never breaks."""
305
+ payload = _read_stdin_payload(event_json)
306
+ # Prefer the cwd Claude reports so the line reflects the active workspace.
307
+ cwd = payload.get("cwd") if isinstance(payload, dict) else None
308
+ root = _project_root(Path(cwd) if isinstance(cwd, str) and cwd else project_root)
309
+ line = _status_line(root)
310
+ if not line:
311
+ print("DevCouncil: not initialized")
312
+ return
313
+ # statusLine wants a short line; drop the trailing guidance sentence.
314
+ print(line.split(". Use the")[0])
315
+
316
+
119
317
  @app.command()
120
- def post_task():
318
+ def post_task(
319
+ client: str = typer.Option("claude", "--client", help="Hook client: claude, codex, gemini, cursor, or generic."),
320
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
321
+ ):
121
322
  """
122
323
  Coding CLI hook: Runs after a task is completed.
123
- Triggers deterministic verification.
324
+
325
+ When ``execution.verify_on_post_task`` is enabled, this runs deterministic
326
+ verification of the active task and records gaps; otherwise it just reminds the
327
+ user to run ``dev verify`` (the default, to keep hooks fast/cheap).
124
328
  """
125
- console.print("[cyan]DevCouncil: coding agent finished task. Triggering automatic verification...[/cyan]")
126
- # In a real environment, this would invoke 'dev verify <active-task>'
127
- # For the hook script, we just notify the user.
128
- console.print("Run [bold]dev verify[/bold] to finalize implementation evidence.")
329
+ root = _project_root(project_root)
330
+ try:
331
+ from devcouncil.app.config import load_config
332
+ verify_enabled = load_config(root).execution.verify_on_post_task
333
+ except Exception:
334
+ verify_enabled = False
335
+
336
+ if not verify_enabled:
337
+ console.print("[cyan]DevCouncil: coding agent finished task.[/cyan]")
338
+ console.print("Run [bold]dev verify[/bold] to finalize implementation evidence.")
339
+ _emit_post_task_allow(client)
340
+ return
341
+
342
+ summary = _verify_active_task(root)
343
+ console.print(summary)
344
+ _emit_post_task_allow(client)
345
+
346
+
347
+ def _emit_post_task_allow(client: str) -> None:
348
+ if client.lower() in {"codex", "gemini"}:
349
+ print(json.dumps({"decision": "allow", "suppressOutput": True}, separators=(",", ":")))
350
+
351
+
352
+ def _verify_active_task(root: Path) -> str:
353
+ """Run deterministic verification of the active task and persist gaps/evidence.
354
+ Returns a human summary line. Best-effort: never raises out of a hook."""
355
+ try:
356
+ import asyncio
357
+
358
+ from devcouncil.domain.evidence import CommandResult, DiffCoverageEvidence, DiffEvidence, TestEvidence
359
+ from devcouncil.storage.repositories import (
360
+ EvidenceRepository,
361
+ GapRepository,
362
+ RequirementRepository,
363
+ )
364
+ from devcouncil.verification.next_actions import split_next_actions
365
+ from devcouncil.verification.verifier import Verifier
366
+
367
+ active_id = active_task_id(root)
368
+ db = get_db(root)
369
+ if not active_id or not db:
370
+ return "Run [bold]dev verify[/bold] to finalize implementation evidence."
371
+ with db.get_session() as session:
372
+ task = TaskRepository(session).get_by_id(active_id)
373
+ if not task:
374
+ return "Run [bold]dev verify[/bold] to finalize implementation evidence."
375
+ reqs = RequirementRepository(session).get_all()
376
+ gaps, evidence = asyncio.run(Verifier(root).verify_task(task, reqs))
377
+ gap_repo = GapRepository(session)
378
+ ev_repo = EvidenceRepository(session)
379
+ gap_repo.delete_for_task(task.id)
380
+ ev_repo.delete_for_task(task.id)
381
+ for gap in gaps:
382
+ gap_repo.save(gap)
383
+ for ev in evidence:
384
+ if isinstance(ev, CommandResult):
385
+ ev_repo.save_command_result(task.id, ev)
386
+ elif isinstance(ev, DiffCoverageEvidence):
387
+ ev_repo.save_diff_coverage_evidence(ev)
388
+ elif isinstance(ev, DiffEvidence):
389
+ ev_repo.save_diff_evidence(ev)
390
+ elif isinstance(ev, TestEvidence):
391
+ ev_repo.save_test_evidence(ev, task.id)
392
+ blocking = [g for g in gaps if g.blocking]
393
+ task.status = "blocked" if blocking else "verified"
394
+ TaskRepository(session).save(task)
395
+ blocking_actions, _ = split_next_actions(gaps)
396
+ TraceLogger(root).log_event(
397
+ "post_task_verified",
398
+ {"task_id": active_id, "blocking": len(blocking)},
399
+ task_id=active_id,
400
+ summary=f"post_task verification: {task.status}",
401
+ )
402
+ if blocking:
403
+ return (
404
+ f"[yellow]{active_id} is blocked by {len(blocking)} gap(s); "
405
+ f"{len(blocking_actions)} next action(s). Run [bold]dev repair[/bold].[/yellow]"
406
+ )
407
+ return f"[green]{active_id} verified.[/green]"
408
+ except Exception as exc: # never let a hook crash the agent
409
+ return f"[dim]post-task verification skipped: {exc}[/dim]"