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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- 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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
"
|
|
694
|
-
|
|
695
|
-
_hook_command(project_root, "claude", "
|
|
696
|
-
"devcouncil-
|
|
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
|
-
"
|
|
701
|
-
|
|
702
|
-
_hook_command(project_root, "claude", "
|
|
703
|
-
"devcouncil-
|
|
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
|
-
"
|
|
751
|
+
"UserPromptSubmit",
|
|
708
752
|
"",
|
|
709
|
-
_hook_command(project_root, "claude", "
|
|
710
|
-
"devcouncil-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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", "
|
|
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
|
|
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")
|