Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ Just code normally. The plugin runs in the background:

See the [Guide](GUIDE.md) for all commands, `affects` matching rules, team adoption, and more.

## Superpowers integration

If you use [Superpowers](https://github.com/obra/superpowers), Code Decisions detects it automatically and does two things:

**Seeds methodology decisions.** On first session, the plugin creates 8 decision files documenting the Superpowers methodology — TDD, design-before-code, systematic debugging, subagent-driven development, and more. These are searchable, tagged `superpowers`, and committed to git so your whole team inherits them.

**Extracts decisions from specs and plans.** When Superpowers writes a design spec (`docs/superpowers/specs/`) or implementation plan (`docs/superpowers/plans/`), Code Decisions scans the content for trade-offs and approach choices. On the first implementation edit, it nudges the agent to capture those choices as decisions — so they surface automatically when teammates edit the affected files later.

No configuration needed. Install both plugins and they work together.

## Development

```sh
Expand Down
72 changes: 59 additions & 13 deletions src/decision/policy/plan_nudge.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""NUDGE policy — extract decision candidates from Claude Code plan files.
"""NUDGE policy — extract decision candidates from plan and spec files.

Two-phase behavior:
Phase 1 (plan file Write): Scan content, extract candidates, store in state.
Phase 1 (plan/spec file Write): Scan content, extract candidates, store in state.
Phase 2 (first non-plan PostToolUse): Nudge with candidate list.

Supports both Claude Code plans (.claude/plans/) and Superpowers specs/plans
(docs/superpowers/specs/, docs/superpowers/plans/).
"""

from __future__ import annotations
Expand All @@ -23,7 +26,13 @@
re.IGNORECASE,
)

# File paths in "Files to Change" sections
# Approach/option sections in superpowers specs — richer decision content
_APPROACH_RE = re.compile(
r"(?:approach|option|alternative)\s*\d?\s*[:—\-]\s*(.{10,120})",
re.IGNORECASE,
)

# File paths in "Files to Change" / "File Structure" sections
_PLAN_FILE_PATH_RE = re.compile(
r"(?:New|Modify|Change|Update|Create):\s*`?("
r"(?:src|lib|app|tests?|pkg|internal|cmd)/[^\s`]+"
Expand All @@ -38,22 +47,33 @@


def _is_plan_file(file_path: str) -> bool:
"""Return True if file_path is a Claude Code plan file."""
return ".claude/plans/" in file_path and file_path.endswith(".md")
"""Return True if file_path is a plan or spec file we should scan."""
if not file_path.endswith(".md"):
return False
return (
".claude/plans/" in file_path
or "docs/superpowers/specs/" in file_path
or "docs/superpowers/plans/" in file_path
)


def _is_superpowers_file(file_path: str) -> bool:
"""Return True if file_path is a superpowers spec or plan."""
return "docs/superpowers/" in file_path


def _extract_decision_candidates(content: str) -> list[dict[str, str]]:
"""Scan plan markdown for decision signals.
"""Scan plan/spec markdown for decision signals.

Returns list of {"title": ..., "reasoning": ...} dicts.
Matches explicit decision language and approach/option sections.
"""
content = content[:20_000] # cap scan size
candidates: list[dict[str, str]] = []
seen_titles: set[str] = set()

for m in _PLAN_DECISION_RE.finditer(content):
snippet = m.group(0).strip()
# Derive a short title from the first ~60 chars
title = snippet[:60].rstrip(".,;:!? ")
if title in seen_titles:
continue
Expand All @@ -62,6 +82,18 @@ def _extract_decision_candidates(content: str) -> list[dict[str, str]]:
if len(candidates) >= PLAN_CANDIDATE_MAX:
break

# Supplement with approach/option sections (common in superpowers specs)
if len(candidates) < PLAN_CANDIDATE_MAX:
for m in _APPROACH_RE.finditer(content):
snippet = m.group(1).strip()
title = snippet[:60].rstrip(".,;:!? ")
if title in seen_titles:
continue
seen_titles.add(title)
candidates.append({"title": title, "reasoning": snippet[:120]})
if len(candidates) >= PLAN_CANDIDATE_MAX:
break

return candidates


Expand Down Expand Up @@ -163,11 +195,25 @@ def _plan_nudge_condition(data: dict[str, Any], state: SessionState) -> PolicyRe
pass
affects_hint = f"\nAffects: {', '.join(plan_affects[:5])}" if plan_affects else ""

msg = (
f"The implementation plan contains {n} decision-worthy choice{'s' if n != 1 else ''}:\n"
f"{titles_str}{more}\n"
"Capture these as you implement — reasoning is freshest now:\n"
f"`/decision we chose X because Y`{affects_hint}"
)
# Tailor message to source type
plan_path = state.load_data(_KEY_PLAN_PATH) or ""
if _is_superpowers_file(plan_path):
# Extract readable name from path: docs/superpowers/specs/2026-04-13-auth-design.md → "auth-design"
source_name = plan_path.rsplit("/", 1)[-1].removesuffix(".md")
source_type = "spec" if "/specs/" in plan_path else "plan"
msg = (
f'The superpowers {source_type} "{source_name}" contains'
f" {n} decision-worthy choice{'s' if n != 1 else ''}:\n"
f"{titles_str}{more}\n"
"Capture these as decisions — they'll surface when teammates edit affected files:\n"
f"`/decision we chose X because Y`{affects_hint}"
)
else:
msg = (
f"The implementation plan contains {n} decision-worthy choice{'s' if n != 1 else ''}:\n"
f"{titles_str}{more}\n"
"Capture these as you implement — reasoning is freshest now:\n"
f"`/decision we chose X because Y`{affects_hint}"
)

return PolicyResult(matched=True, ok=True, system_message=msg)
10 changes: 10 additions & 0 deletions src/decision/policy/session_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ def _session_init_condition(data: dict[str, Any], state: SessionState) -> Policy
store = state.get_store()
store.ensure_dir()

# Seed decisions from detected plugins (idempotent, fast)
try:
from ..seeds import get_registry

seeded = get_registry().seed_decisions(store)
if seeded:
print(f" ◆ seeded {seeded} decisions from superpowers methodology", file=sys.stderr)
except Exception as exc:
print(f"decision: seed error: {exc}", file=sys.stderr)

# Rebuild rules index if decision files changed outside Claude Code
_rebuild_index_if_stale(state)

Expand Down
136 changes: 136 additions & 0 deletions src/decision/seeds/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Plugin-aware decision seeding — detect installed plugins, seed methodology decisions."""

from __future__ import annotations

import dataclasses
import json
from datetime import date
from pathlib import Path
from typing import Any, Callable

from ..store.store import DecisionStore
from ..utils.frontmatter import _format_yaml_frontmatter

MANIFEST_FILE = ".seeded.json"
PLUGINS_JSON = Path.home() / ".claude" / "plugins" / "installed_plugins.json"


@dataclasses.dataclass
class SeedDecision:
"""Template for a decision to seed from a detected plugin."""

slug: str
name: str
description: str
tags: list[str]
affects: list[str]
title: str
body: str


class SeedRegistry:
"""Registry of decision seeds keyed by plugin identity."""

def __init__(self) -> None:
self._seeds: dict[str, Callable[[], list[SeedDecision]]] = {}

def register(self, plugin_pattern: str, factory: Callable[[], list[SeedDecision]]) -> None:
"""Register a seed factory for a plugin name pattern."""
self._seeds[plugin_pattern] = factory

def detect_installed_plugins(self) -> set[str]:
"""Read installed_plugins.json and return matched plugin patterns."""
try:
data = json.loads(PLUGINS_JSON.read_text())
except (OSError, json.JSONDecodeError):
return set()

plugin_keys = list(data.get("plugins", {}).keys())
matched: set[str] = set()
for pattern in self._seeds:
for key in plugin_keys:
if pattern in key:
matched.add(pattern)
break
return matched

def seed_decisions(self, store: DecisionStore) -> int:
"""Seed decisions for detected plugins. Returns count of newly created files."""
detected = self.detect_installed_plugins()
if not detected:
return 0

manifest = _load_manifest(store.decisions_dir)
count = 0
today = date.today().isoformat()

for pattern in detected:
factory = self._seeds[pattern]
seeds = factory()
already_seeded = set(manifest.get("seeded", {}).get(pattern, {}).get("slugs", []))

new_slugs: list[str] = []
for seed in seeds:
if seed.slug in already_seeded:
continue # previously seeded (possibly deleted by user)

dest = store.decisions_dir / f"{seed.slug}.md"
if dest.exists():
# File exists (maybe user-created) — record in manifest but don't overwrite
new_slugs.append(seed.slug)
continue

frontmatter = _format_yaml_frontmatter(
{
"name": seed.name,
"description": seed.description,
"date": today,
"tags": seed.tags,
"affects": seed.affects,
}
)
content = f"{frontmatter}\n\n# {seed.title}\n\n{seed.body}\n"
dest.write_text(content)
new_slugs.append(seed.slug)
count += 1

if new_slugs:
seeded_section = manifest.setdefault("seeded", {})
entry = seeded_section.setdefault(pattern, {"slugs": [], "seeded_at": today})
entry["slugs"] = sorted(set(entry["slugs"]) | set(new_slugs))

if count:
_save_manifest(store.decisions_dir, manifest)

return count


def _load_manifest(decisions_dir: Path) -> dict[str, Any]:
"""Load the seeding manifest from .seeded.json."""
path = decisions_dir / MANIFEST_FILE
try:
result: dict[str, Any] = json.loads(path.read_text())
return result
except (OSError, json.JSONDecodeError):
return {"version": 1, "seeded": {}}


def _save_manifest(decisions_dir: Path, manifest: dict[str, Any]) -> None:
"""Save the seeding manifest to .seeded.json."""
manifest["version"] = 1
path = decisions_dir / MANIFEST_FILE
path.write_text(json.dumps(manifest, indent=2, sort_keys=False) + "\n")


_registry: SeedRegistry | None = None


def get_registry() -> SeedRegistry:
"""Return the singleton seed registry, lazily populated."""
global _registry
if _registry is None:
_registry = SeedRegistry()
from ._superpowers import superpowers_seeds

_registry.register("superpowers", superpowers_seeds)
return _registry
Loading