devcouncil 0.2.0 → 0.3.1

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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  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 +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. package/uv.lock +1 -1
@@ -199,12 +199,24 @@ _PENDING_RAW_CONFIG: dict | None = None
199
199
  @contextmanager
200
200
  def _batched_raw_config(project_root: Path):
201
201
  global _PENDING_RAW_CONFIG
202
+ # Re-entrant: if a batch is already active, participate in it instead of
203
+ # starting a nested load/save (which would otherwise reset the shared
204
+ # buffer to None on inner exit and drop the outer batch's mutations).
205
+ if _PENDING_RAW_CONFIG is not None:
206
+ yield
207
+ return
202
208
  _PENDING_RAW_CONFIG = _load_raw_config(project_root)
203
209
  try:
204
210
  yield
205
- _save_raw_config(project_root, _PENDING_RAW_CONFIG)
206
211
  finally:
212
+ # Persist whatever mutations accumulated, even if an inner installer raised
213
+ # partway through — matching the old per-installer save, which committed each
214
+ # installer's change immediately rather than dropping the whole batch on a
215
+ # mid-loop failure.
216
+ pending = _PENDING_RAW_CONFIG
207
217
  _PENDING_RAW_CONFIG = None
218
+ if pending is not None:
219
+ _save_raw_config(project_root, pending)
208
220
 
209
221
 
210
222
  def _mutate_raw_config(project_root: Path, mutate) -> None:
@@ -649,13 +661,14 @@ def _install_cursor_hooks(project_root: Path) -> list[Path]:
649
661
  _hook_command(project_root, "cursor", "post-tool-use"),
650
662
  )
651
663
  _save_json(path, settings)
652
- config = _load_raw_config(project_root)
653
- integrations = config.setdefault("integrations", {})
654
- cursor = integrations.setdefault("cursor", {})
655
- cursor.update({
656
- "hooks_path": str(path.relative_to(project_root)),
657
- })
658
- _save_raw_config(project_root, config)
664
+
665
+ def mutate(config: dict) -> None:
666
+ cursor = config.setdefault("integrations", {}).setdefault("cursor", {})
667
+ cursor.update({
668
+ "hooks_path": str(path.relative_to(project_root)),
669
+ })
670
+
671
+ _mutate_raw_config(project_root, mutate)
659
672
  return [path]
660
673
 
661
674
 
@@ -684,35 +697,304 @@ def _install_opencode_hooks(project_root: Path) -> list[Path]:
684
697
  return [destination, path]
685
698
 
686
699
 
687
- def _install_claude_hooks(project_root: Path) -> list[Path]:
700
+ def _install_claude_hooks(project_root: Path, *, write_gate: bool = False) -> list[Path]:
701
+ """Install DevCouncil's Claude Code hooks into .claude/settings.local.json.
702
+
703
+ By default this installs only the *assistive* lifecycle hooks (status injection on
704
+ SessionStart/UserPromptSubmit, the live-review Stop signal, and the SessionEnd/
705
+ PreCompact/SubagentStop/Notification trace hooks). These never block a tool call.
706
+
707
+ The blocking pre-action **write-gate** (PreToolUse/PostToolUse, which denies any
708
+ Bash/Write/Edit not authorized by an active task lease) is installed ONLY when
709
+ ``write_gate`` is True. It is meant for autonomous executor runs, not interactive
710
+ human sessions — in an interactive session there is no task lease, so the gate would
711
+ fail-closed and deny every command. (``dev run --executor claude`` does its own
712
+ post-hoc scope enforcement and does not depend on this hook, so leaving it off by
713
+ default loses no containment.)"""
688
714
  path = project_root / ".claude" / "settings.local.json"
689
715
  settings = _load_json(path)
690
716
  matcher = "Bash|Write|Edit|MultiEdit"
717
+ if write_gate:
718
+ _upsert_hook(
719
+ settings,
720
+ "PreToolUse",
721
+ matcher,
722
+ _hook_command(project_root, "claude", "pre-tool-use"),
723
+ "devcouncil-pre-tool-use",
724
+ )
725
+ _upsert_hook(
726
+ settings,
727
+ "PostToolUse",
728
+ matcher,
729
+ _hook_command(project_root, "claude", "post-tool-use"),
730
+ "devcouncil-post-tool-use",
731
+ )
691
732
  _upsert_hook(
692
733
  settings,
693
- "PreToolUse",
694
- matcher,
695
- _hook_command(project_root, "claude", "pre-tool-use"),
696
- "devcouncil-pre-tool-use",
734
+ "Stop",
735
+ "",
736
+ _hook_command(project_root, "claude", "agent-response"),
737
+ "devcouncil-agent-response-ready",
697
738
  )
739
+ # Lifecycle events: status-on-start/prompt, teardown, compaction, subagent finish,
740
+ # and notifications. These complete DevCouncil's coverage of the documented Claude
741
+ # Code hook surface beyond the pre/post/stop gate.
698
742
  _upsert_hook(
699
743
  settings,
700
- "PostToolUse",
701
- matcher,
702
- _hook_command(project_root, "claude", "post-tool-use"),
703
- "devcouncil-post-tool-use",
744
+ "SessionStart",
745
+ "startup|resume",
746
+ _hook_command(project_root, "claude", "session-start"),
747
+ "devcouncil-session-start",
704
748
  )
705
749
  _upsert_hook(
706
750
  settings,
707
- "Stop",
751
+ "UserPromptSubmit",
708
752
  "",
709
- _hook_command(project_root, "claude", "agent-response"),
710
- "devcouncil-agent-response-ready",
753
+ _hook_command(project_root, "claude", "user-prompt-submit"),
754
+ "devcouncil-user-prompt-submit",
755
+ )
756
+ _upsert_hook(
757
+ settings,
758
+ "SessionEnd",
759
+ "",
760
+ _hook_command(project_root, "claude", "session-end"),
761
+ "devcouncil-session-end",
762
+ )
763
+ _upsert_hook(
764
+ settings,
765
+ "PreCompact",
766
+ "",
767
+ _hook_command(project_root, "claude", "pre-compact"),
768
+ "devcouncil-pre-compact",
769
+ )
770
+ _upsert_hook(
771
+ settings,
772
+ "SubagentStop",
773
+ "",
774
+ _hook_command(project_root, "claude", "subagent-stop"),
775
+ "devcouncil-subagent-stop",
776
+ )
777
+ _upsert_hook(
778
+ settings,
779
+ "Notification",
780
+ "",
781
+ _hook_command(project_root, "claude", "notification"),
782
+ "devcouncil-notification",
711
783
  )
712
784
  _save_json(path, settings)
713
785
  return [path]
714
786
 
715
787
 
788
+ def _devcouncil_version() -> str:
789
+ """Package version for plugin manifests, or a stable placeholder when uninstalled."""
790
+ import importlib.metadata
791
+
792
+ try:
793
+ return importlib.metadata.version("devcouncil")
794
+ except importlib.metadata.PackageNotFoundError:
795
+ return "0.0.0"
796
+
797
+
798
+ # Read-only DevCouncil commands the generated slash commands / hooks shell out to. Adding
799
+ # them to the Claude permissions allow-list keeps the integration from prompting on every
800
+ # `dev status`/`dev report` the slash commands run.
801
+ _CLAUDE_PERMISSION_ALLOW = [
802
+ "Bash(dev status:*)",
803
+ "Bash(dev report:*)",
804
+ "Bash(dev tasks:*)",
805
+ "Bash(dev verify:*)",
806
+ "Bash(dev repair:*)",
807
+ "Bash(dev plan:*)",
808
+ "Bash(dev watch:*)",
809
+ "Bash(devcouncil mcp-server)",
810
+ ]
811
+
812
+
813
+ def _install_claude_settings(project_root: Path) -> tuple[Path, bool]:
814
+ """Write the statusLine, MCP enablement, and permission allow-list into Claude settings.
815
+
816
+ Merges into .claude/settings.local.json without clobbering existing user entries.
817
+ Returns (path, changed); only rewrites the file when the merge changes something so
818
+ re-running integration is a true no-op."""
819
+ path = project_root / ".claude" / "settings.local.json"
820
+ settings = _load_json(path)
821
+ before = json.dumps(settings, sort_keys=True)
822
+
823
+ settings["statusLine"] = {
824
+ "type": "command",
825
+ "command": "devcouncil hook claude-statusline",
826
+ }
827
+ # Auto-enable the project-scoped DevCouncil MCP server so a teammate cloning the repo
828
+ # doesn't have to approve it interactively.
829
+ enabled = settings.setdefault("enabledMcpjsonServers", [])
830
+ if isinstance(enabled, list) and "devcouncil" not in enabled:
831
+ enabled.append("devcouncil")
832
+
833
+ permissions = settings.setdefault("permissions", {})
834
+ if isinstance(permissions, dict):
835
+ allow = permissions.setdefault("allow", [])
836
+ if isinstance(allow, list):
837
+ for rule in _CLAUDE_PERMISSION_ALLOW:
838
+ if rule not in allow:
839
+ allow.append(rule)
840
+
841
+ changed = json.dumps(settings, sort_keys=True) != before
842
+ if changed:
843
+ _save_json(path, settings)
844
+ return path, changed
845
+
846
+
847
+ def _selected_skill_assets(project_root: Path):
848
+ """Scaffold the applicable skills and return them as GeneratedAsset-like records.
849
+
850
+ Returns (written_paths, skill_assets) where skill_assets carry (path, content) for the
851
+ plugin bundler so the plugin ships the same skill bodies that land in .claude/skills/."""
852
+ from devcouncil.integrations.claude_assets import GeneratedAsset
853
+ from devcouncil.skills.registry import scaffold_skills, select_skills
854
+
855
+ skills = select_skills("", project_root)
856
+ written = scaffold_skills(project_root, skills)
857
+ assets: list[GeneratedAsset] = []
858
+ skills_root = project_root / ".claude" / "skills"
859
+ for skill in skills:
860
+ target = skills_root / skill.name / "SKILL.md"
861
+ if target.exists():
862
+ assets.append(GeneratedAsset(target, target.read_text(encoding="utf-8")))
863
+ return written, assets
864
+
865
+
866
+ def _install_claude_assets(project_root: Path) -> list[Path]:
867
+ """Generate the static Claude Code asset surface (commands, agents, output style,
868
+ statusline, permissions) and scaffold the applicable skills. Idempotent."""
869
+ from devcouncil.integrations import claude_assets
870
+
871
+ written: list[Path] = []
872
+ assets: list[claude_assets.GeneratedAsset] = []
873
+ assets += claude_assets.build_slash_commands(project_root)
874
+ assets += claude_assets.build_subagents(project_root)
875
+ assets += claude_assets.build_output_style(project_root)
876
+ for asset in assets:
877
+ if asset.write_if_changed():
878
+ written.append(asset.path)
879
+
880
+ skills_written, _ = _selected_skill_assets(project_root)
881
+ written.extend(skills_written)
882
+ settings_path, settings_changed = _install_claude_settings(project_root)
883
+ if settings_changed:
884
+ written.append(settings_path)
885
+ return written
886
+
887
+
888
+ def _install_claude_plugin(project_root: Path, *, write_gate: bool = False) -> list[Path]:
889
+ """Build the self-contained Claude Code plugin + single-repo marketplace bundle.
890
+
891
+ Bundles the commands, agents, applicable skills, hooks, and MCP config so the entire
892
+ DevCouncil integration installs with one `/plugin install`. Assist-mode hooks by
893
+ default; pass write_gate=True to bundle the blocking containment gate."""
894
+ from devcouncil.integrations import claude_assets
895
+
896
+ _, skill_assets = _selected_skill_assets(project_root)
897
+ bundle = claude_assets.build_plugin_bundle(
898
+ project_root, version=_devcouncil_version(), skill_assets=skill_assets, write_gate=write_gate
899
+ )
900
+ return [asset.path for asset in bundle if asset.write_if_changed()]
901
+
902
+
903
+ def _uninstall_claude(project_root: Path) -> list[str]:
904
+ """Remove everything DevCouncil installed into a Claude Code project. Idempotent.
905
+
906
+ Strips DevCouncil's hooks (every event), the DevCouncil statusLine, the MCP enablement
907
+ and permission rules from .claude/settings.local.json (leaving any user-authored
908
+ entries untouched), deletes the generated commands/subagents/output-style files, and
909
+ best-effort de-registers the MCP server via `claude mcp remove`. Returns a list of the
910
+ changes made. The recoverable, in-band counterpart to a fail-closed write-gate."""
911
+ removed: list[str] = []
912
+ path = project_root / ".claude" / "settings.local.json"
913
+ settings = _load_json(path)
914
+ before = json.dumps(settings, sort_keys=True)
915
+
916
+ # Hooks: drop any entry whose command invokes `devcouncil hook`, then prune empties.
917
+ hooks = settings.get("hooks")
918
+ if isinstance(hooks, dict):
919
+ for event in list(hooks):
920
+ groups = hooks.get(event)
921
+ if not isinstance(groups, list):
922
+ continue
923
+ kept_groups = []
924
+ for group in groups:
925
+ inner = group.get("hooks", []) if isinstance(group, dict) else []
926
+ inner_kept = [
927
+ h for h in inner
928
+ if "devcouncil hook" not in str(h.get("command", ""))
929
+ ]
930
+ if inner_kept:
931
+ group["hooks"] = inner_kept
932
+ kept_groups.append(group)
933
+ if kept_groups:
934
+ hooks[event] = kept_groups
935
+ else:
936
+ hooks.pop(event)
937
+ if not hooks:
938
+ settings.pop("hooks")
939
+ removed.append(f"hooks in {path.name}")
940
+
941
+ # statusLine: only remove ours.
942
+ status = settings.get("statusLine")
943
+ if isinstance(status, dict) and "devcouncil" in str(status.get("command", "")):
944
+ settings.pop("statusLine")
945
+ removed.append("statusLine")
946
+
947
+ enabled = settings.get("enabledMcpjsonServers")
948
+ if isinstance(enabled, list) and "devcouncil" in enabled:
949
+ enabled.remove("devcouncil")
950
+ if not enabled:
951
+ settings.pop("enabledMcpjsonServers")
952
+ removed.append("enabledMcpjsonServers entry")
953
+
954
+ permissions = settings.get("permissions")
955
+ if isinstance(permissions, dict) and isinstance(permissions.get("allow"), list):
956
+ kept = [r for r in permissions["allow"] if r not in _CLAUDE_PERMISSION_ALLOW]
957
+ if len(kept) != len(permissions["allow"]):
958
+ permissions["allow"] = kept
959
+ removed.append("permission allow-rules")
960
+ if not permissions.get("allow"):
961
+ permissions.pop("allow", None)
962
+ if not permissions:
963
+ settings.pop("permissions")
964
+
965
+ if json.dumps(settings, sort_keys=True) != before:
966
+ if settings:
967
+ _save_json(path, settings)
968
+ elif path.exists():
969
+ path.unlink()
970
+ removed.append(f"deleted empty {path.name}")
971
+
972
+ # Generated asset files.
973
+ targets = [
974
+ project_root / ".claude" / "commands" / "devcouncil",
975
+ project_root / ".claude" / "output-styles" / "devcouncil.md",
976
+ ]
977
+ targets += [
978
+ project_root / ".claude" / "agents" / f"{name}.md"
979
+ for name in ("devcouncil-implementer", "devcouncil-verifier", "devcouncil-reviewer")
980
+ ]
981
+ for target in targets:
982
+ if target.is_dir():
983
+ shutil.rmtree(target)
984
+ removed.append(str(target.relative_to(project_root)))
985
+ elif target.exists():
986
+ target.unlink()
987
+ removed.append(str(target.relative_to(project_root)))
988
+
989
+ # De-register the MCP server (best-effort; only if the claude CLI is present).
990
+ if shutil.which("claude"):
991
+ code = _run(["claude", "mcp", "remove", "devcouncil"])
992
+ if code == 0:
993
+ removed.append("claude mcp server registration")
994
+
995
+ return removed
996
+
997
+
716
998
  def _preview_hook_paths(project_root: Path, tool: str) -> list[tuple[str, Path]]:
717
999
  paths = {
718
1000
  "codex": [project_root / ".codex" / "hooks.json", project_root / ".codex" / "config.toml"],
@@ -731,7 +1013,9 @@ def _preview_hook_paths(project_root: Path, tool: str) -> list[tuple[str, Path]]
731
1013
  return [(client, path) for client in selected for path in paths.get(client, [])]
732
1014
 
733
1015
 
734
- def _configure_native_hooks(project_root: Path, tool: str = "all", apply: bool = False) -> None:
1016
+ def _configure_native_hooks(
1017
+ project_root: Path, tool: str = "all", apply: bool = False, *, claude_write_gate: bool = False
1018
+ ) -> None:
735
1019
  allowed = {"all", *SUPPORTED_HOOK_TOOLS, "opencode"}
736
1020
  if tool not in allowed:
737
1021
  console.print("[red]--tool must be one of: all, codex, gemini, claude, cursor, opencode.[/red]")
@@ -754,17 +1038,22 @@ def _configure_native_hooks(project_root: Path, tool: str = "all", apply: bool =
754
1038
  installers = {
755
1039
  "codex": _install_codex_hooks,
756
1040
  "gemini": _install_gemini_hooks,
757
- "claude": _install_claude_hooks,
1041
+ # Claude's blocking write-gate is opt-in (assist-mode default); the other clients
1042
+ # install their native pre/post hooks unconditionally as before.
1043
+ "claude": lambda root: _install_claude_hooks(root, write_gate=claude_write_gate),
758
1044
  "cursor": _install_cursor_hooks,
759
1045
  "opencode": _install_opencode_hooks,
760
1046
  }
761
- for client in selected:
762
- try:
763
- written = installers[client](project_root)
764
- except (ValueError, FileNotFoundError) as exc:
765
- console.print(f"[red]{client} hook setup failed: {exc}[/red]")
766
- raise typer.Exit(code=1) from exc
767
- console.print(f"[green]{client} native hooks configured:[/green] {', '.join(str(path) for path in written)}")
1047
+ # Batch the per-installer config.yaml record updates (cursor/opencode)
1048
+ # into one load/save instead of re-parsing YAML per tool.
1049
+ with _batched_raw_config(project_root):
1050
+ for client in selected:
1051
+ try:
1052
+ written = installers[client](project_root)
1053
+ except (ValueError, FileNotFoundError) as exc:
1054
+ console.print(f"[red]{client} hook setup failed: {exc}[/red]")
1055
+ raise typer.Exit(code=1) from exc
1056
+ console.print(f"[green]{client} native hooks configured:[/green] {', '.join(str(path) for path in written)}")
768
1057
 
769
1058
 
770
1059
  def _print_command(tool: str, command: list[str], apply: bool):
@@ -811,7 +1100,10 @@ def overview(ctx: typer.Context):
811
1100
  table.add_column("Notes")
812
1101
  table.add_row("Codex CLI", f"{PREFERRED_COMMAND} codex --apply", "Adds DevCouncil as a stdio MCP server.")
813
1102
  table.add_row("Gemini CLI", f"{PREFERRED_COMMAND} gemini --apply", "Adds DevCouncil as a project-scoped stdio MCP server.")
814
- table.add_row("Claude Code", f"{PREFERRED_COMMAND} claude --apply", "Adds DevCouncil as a Claude Code MCP server.")
1103
+ table.add_row("Claude Code", f"{PREFERRED_COMMAND} claude --apply", "MCP + assistive hooks + slash commands, subagents, output style, skills, statusline. Add --write-gate for blocking containment.")
1104
+ table.add_row("Claude assets", f"{PREFERRED_COMMAND} claude-assets --apply", "Slash commands, subagents, output style, statusline, permissions, skills (no MCP/hooks).")
1105
+ table.add_row("Claude plugin", f"{PREFERRED_COMMAND} claude-plugin --apply", "Self-contained Claude Code plugin + marketplace bundling everything for /plugin install.")
1106
+ table.add_row("Claude uninstall", f"{PREFERRED_COMMAND} claude --uninstall", "Remove DevCouncil hooks, statusline, MCP enablement, and generated assets from .claude/.")
815
1107
  table.add_row("Cursor", f"{PREFERRED_COMMAND} cursor --apply", "Writes project .cursor/mcp.json for Cursor editor and cursor-agent.")
816
1108
  table.add_row("OpenCode", f"{PREFERRED_COMMAND} opencode --apply", "Adds DevCouncil as a project-scoped OpenCode MCP server and executor.")
817
1109
  table.add_row("Google Antigravity CLI", f"{PREFERRED_COMMAND} antigravity --apply", "Writes project .agents/mcp_config.json and enables the agy executor.")
@@ -922,21 +1214,148 @@ def claude(
922
1214
  apply: bool = typer.Option(False, "--apply", help="Run the setup command instead of printing it."),
923
1215
  project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
924
1216
  scope: str = typer.Option("local", "--scope", help="Claude MCP config scope: local, project, or user."),
1217
+ write_gate: bool = typer.Option(
1218
+ False,
1219
+ "--write-gate/--no-write-gate",
1220
+ "--contain/--no-contain",
1221
+ help="Also install the blocking PreToolUse/PostToolUse write-gate (containment). "
1222
+ "Off by default — it denies any tool call not authorized by an active task lease, "
1223
+ "which fail-closes an interactive session. Use it for autonomous executor runs.",
1224
+ ),
1225
+ uninstall: bool = typer.Option(
1226
+ False,
1227
+ "--uninstall",
1228
+ help="Remove DevCouncil's Claude hooks, statusline, MCP enablement, and generated assets.",
1229
+ ),
925
1230
  ):
926
1231
  """
927
- Set up DevCouncil MCP tools for Claude Code.
1232
+ Set up DevCouncil for Claude Code: MCP server + assistive hooks + slash commands,
1233
+ subagents, output style, skills, and statusline. The blocking write-gate is opt-in
1234
+ via --write-gate. Use --uninstall to remove everything DevCouncil installed.
928
1235
  """
1236
+ root = _project_root(project_root)
1237
+ if uninstall:
1238
+ removed = _uninstall_claude(root)
1239
+ if removed:
1240
+ console.print(f"[green]Removed DevCouncil Claude integration[/green] ({len(removed)} change(s)):")
1241
+ for item in removed:
1242
+ console.print(f" {item}")
1243
+ else:
1244
+ console.print("[dim]Nothing to remove — DevCouncil Claude integration not found.[/dim]")
1245
+ return
1246
+
929
1247
  if scope not in {"local", "project", "user"}:
930
1248
  console.print("[red]--scope must be 'local', 'project', or 'user'.[/red]")
931
1249
  raise typer.Exit(code=2)
932
1250
 
933
- root = _project_root(project_root)
934
1251
  command = _claude_command(root, scope)
935
1252
  ok = _configure("Claude Code", command, apply)
1253
+ if apply:
1254
+ # One-shot: MCP server + assistive hooks (write-gate only with --write-gate) + the
1255
+ # static asset surface (slash commands, subagents, output style, skills, statusline).
1256
+ try:
1257
+ written = _install_claude_hooks(root, write_gate=write_gate)
1258
+ written += _install_claude_assets(root)
1259
+ except (ValueError, FileNotFoundError, OSError) as exc:
1260
+ console.print(f"[red]Claude asset setup failed: {exc}[/red]")
1261
+ raise typer.Exit(code=1) from exc
1262
+ mode = "with write-gate (containment)" if write_gate else "assist mode (no write-gate)"
1263
+ console.print(
1264
+ f"[green]Claude Code integration installed[/green] ({len(written)} file(s), {mode}): "
1265
+ "MCP, hooks, slash commands, subagents, output style, skills, statusline, permissions."
1266
+ )
1267
+ if not write_gate:
1268
+ console.print(
1269
+ "[dim]Add pre-action containment for autonomous runs with[/dim] "
1270
+ f"[dim]{PREFERRED_COMMAND} claude --apply --write-gate[/dim]"
1271
+ )
1272
+ console.print(
1273
+ "Bundle everything as an installable plugin with: "
1274
+ f"[dim]{PREFERRED_COMMAND} claude-plugin --apply[/dim]"
1275
+ )
936
1276
  if not ok and apply:
937
1277
  raise typer.Exit(code=1)
938
1278
 
939
1279
 
1280
+ @app.command("claude-assets")
1281
+ def claude_assets_cmd(
1282
+ apply: bool = typer.Option(False, "--apply", help="Write the Claude asset files instead of previewing them."),
1283
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1284
+ ):
1285
+ """
1286
+ Generate the Claude Code asset surface: slash commands, subagents, output style,
1287
+ statusline, permissions, and scaffolded skills (no MCP/hook registration).
1288
+ """
1289
+ from devcouncil.integrations import claude_assets as _assets
1290
+
1291
+ root = _project_root(project_root)
1292
+ if not apply:
1293
+ console.print("[bold]Claude Code assets (preview)[/bold]")
1294
+ preview = (
1295
+ _assets.build_slash_commands(root)
1296
+ + _assets.build_subagents(root)
1297
+ + _assets.build_output_style(root)
1298
+ )
1299
+ for asset in preview:
1300
+ console.print(f" {asset.path}", soft_wrap=True)
1301
+ console.print(" .claude/settings.local.json (statusLine + permissions + enabledMcpjsonServers)")
1302
+ console.print(" .claude/skills/<applicable>/SKILL.md")
1303
+ console.print("[yellow]Preview only. Rerun with --apply to write the files.[/yellow]")
1304
+ return
1305
+
1306
+ try:
1307
+ written = _install_claude_assets(root)
1308
+ except (ValueError, FileNotFoundError, OSError) as exc:
1309
+ console.print(f"[red]Claude asset setup failed: {exc}[/red]")
1310
+ raise typer.Exit(code=1) from exc
1311
+ console.print(f"[green]Wrote {len(written)} Claude asset file(s).[/green]")
1312
+ for path in written:
1313
+ try:
1314
+ console.print(f" {path.relative_to(root).as_posix()}")
1315
+ except ValueError:
1316
+ console.print(f" {path}")
1317
+
1318
+
1319
+ @app.command("claude-plugin")
1320
+ def claude_plugin_cmd(
1321
+ apply: bool = typer.Option(False, "--apply", help="Write the plugin bundle instead of previewing it."),
1322
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1323
+ write_gate: bool = typer.Option(
1324
+ False,
1325
+ "--write-gate/--no-write-gate",
1326
+ "--contain/--no-contain",
1327
+ help="Bundle Claude's blocking write-gate in the plugin hooks (off by default).",
1328
+ ),
1329
+ ):
1330
+ """
1331
+ Build a self-contained Claude Code plugin + single-repo marketplace bundling the
1332
+ DevCouncil commands, subagents, skills, hooks, and MCP server under
1333
+ .devcouncil/claude-plugin/ for one-command `/plugin install`.
1334
+ """
1335
+ from devcouncil.integrations.claude_assets import PLUGIN_ROOT_REL
1336
+
1337
+ root = _project_root(project_root)
1338
+ market_dir = root / PLUGIN_ROOT_REL
1339
+ if not apply:
1340
+ console.print("[bold]Claude Code plugin bundle (preview)[/bold]")
1341
+ console.print(f"Marketplace + plugin root: [dim]{market_dir}[/dim]")
1342
+ console.print("Install after --apply with:")
1343
+ console.print(f" [dim]/plugin marketplace add {market_dir}[/dim]")
1344
+ console.print(" [dim]/plugin install devcouncil@devcouncil-local[/dim]")
1345
+ console.print("[yellow]Preview only. Rerun with --apply to write the bundle.[/yellow]")
1346
+ return
1347
+
1348
+ try:
1349
+ written = _install_claude_plugin(root, write_gate=write_gate)
1350
+ except (ValueError, FileNotFoundError, OSError) as exc:
1351
+ console.print(f"[red]Claude plugin build failed: {exc}[/red]")
1352
+ raise typer.Exit(code=1) from exc
1353
+ console.print(f"[green]Built Claude plugin bundle[/green] ({len(written)} file(s)) at {market_dir}")
1354
+ console.print("Install it in Claude Code with:")
1355
+ console.print(f" [dim]/plugin marketplace add {market_dir}[/dim]")
1356
+ console.print(" [dim]/plugin install devcouncil@devcouncil-local[/dim]")
1357
+
1358
+
940
1359
  @app.command("cursor")
941
1360
  def cursor(
942
1361
  apply: bool = typer.Option(False, "--apply", help="Write project Cursor MCP config instead of printing it."),
@@ -1142,6 +1561,12 @@ def all_tools(
1142
1561
  gemini_scope: str = typer.Option("project", "--gemini-scope", help="Gemini MCP config scope: project or user."),
1143
1562
  claude_scope: str = typer.Option("local", "--claude-scope", help="Claude MCP config scope: local, project, or user."),
1144
1563
  hooks: bool = typer.Option(True, "--hooks/--no-hooks", help="Include native Codex, Gemini, and Claude hook setup."),
1564
+ write_gate: bool = typer.Option(
1565
+ False,
1566
+ "--write-gate/--no-write-gate",
1567
+ "--contain/--no-contain",
1568
+ help="Install Claude's blocking write-gate too (off by default; for autonomous executor runs).",
1569
+ ),
1145
1570
  strict: bool = typer.Option(
1146
1571
  False,
1147
1572
  "--strict",
@@ -1167,6 +1592,7 @@ def all_tools(
1167
1592
  strict=strict,
1168
1593
  gemini_scope=gemini_scope,
1169
1594
  claude_scope=claude_scope,
1595
+ claude_write_gate=write_gate,
1170
1596
  )
1171
1597
  if not report.ok:
1172
1598
  console.print(report.to_json())
@@ -1328,19 +1754,51 @@ def hooks(
1328
1754
  apply: bool = typer.Option(False, "--apply", help="Write native hook config files instead of previewing paths."),
1329
1755
  project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1330
1756
  tool: str = typer.Option("all", "--tool", help="Hook target: all, codex, gemini, claude, cursor, or opencode."),
1757
+ write_gate: bool = typer.Option(
1758
+ False,
1759
+ "--write-gate/--no-write-gate",
1760
+ "--contain/--no-contain",
1761
+ help="Install Claude's blocking PreToolUse/PostToolUse write-gate too (off by default; "
1762
+ "fail-closes an interactive session without a task lease).",
1763
+ ),
1331
1764
  ):
1332
1765
  """
1333
1766
  Install DevCouncil hook configuration for Codex, Gemini, Claude, Cursor, and OpenCode.
1767
+
1768
+ Claude installs only assistive hooks by default; add --write-gate for pre-action
1769
+ containment (intended for autonomous executor runs).
1334
1770
  """
1335
1771
  root = _project_root(project_root)
1336
1772
  if apply and tool == "all":
1337
- report = apply_integration_target(root, "hooks")
1773
+ report = apply_integration_target(root, "hooks", claude_write_gate=write_gate)
1338
1774
  if not report.ok:
1339
1775
  console.print(report.to_json())
1340
1776
  raise typer.Exit(code=1)
1341
1777
  console.print("[green]Native hooks configured.[/green]")
1342
1778
  return
1343
- _configure_native_hooks(root, tool, apply)
1779
+ _configure_native_hooks(root, tool, apply, claude_write_gate=write_gate)
1780
+
1781
+
1782
+ @app.command("uninstall")
1783
+ def uninstall(
1784
+ project_root: Path | None = typer.Option(None, "--project-root", help="Repository root containing .devcouncil/."),
1785
+ target: str = typer.Option("claude", "--target", help="What to uninstall. Currently: claude."),
1786
+ ):
1787
+ """
1788
+ Remove a DevCouncil integration. Reverses `dev integrate claude` — hooks, statusline,
1789
+ MCP enablement, permission rules, and the generated commands/subagents/output style.
1790
+ """
1791
+ root = _project_root(project_root)
1792
+ if target != "claude":
1793
+ console.print("[red]--target must be 'claude'.[/red]")
1794
+ raise typer.Exit(code=2)
1795
+ removed = _uninstall_claude(root)
1796
+ if removed:
1797
+ console.print(f"[green]Removed DevCouncil Claude integration[/green] ({len(removed)} change(s)):")
1798
+ for item in removed:
1799
+ console.print(f" {item}")
1800
+ else:
1801
+ console.print("[dim]Nothing to remove — DevCouncil Claude integration not found.[/dim]")
1344
1802
 
1345
1803
 
1346
1804
  @app.command("check")