From 42bc3b9ceadfe16f388eb1eaa430ecce0d61bb0f Mon Sep 17 00:00:00 2001 From: Balaji Janakiram Date: Mon, 13 Apr 2026 11:42:27 +0530 Subject: [PATCH 1/2] =?UTF-8?q?Add=20Superpowers=20plugin=20integration=20?= =?UTF-8?q?=E2=80=94=20seed=20methodology=20decisions=20and=20extract=20fr?= =?UTF-8?q?om=20specs/plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two features that make code-decisions aware of the Superpowers plugin (obra/superpowers): 1. **Auto-seed methodology decisions**: On SessionStart, detect Superpowers via installed_plugins.json and seed 8 decision files documenting the methodology (TDD, design-first, systematic debugging, etc.). Uses a general SeedRegistry pattern extensible to other plugins. Idempotent via .seeded.json manifest — deleted decisions are not re-created. 2. **Extract decisions from specs/plans**: Extend plan-nudge to watch docs/superpowers/specs/ and docs/superpowers/plans/ for decision language. When the agent writes a spec with trade-offs or approach choices, candidates are extracted and surfaced as a nudge on the first implementation edit. Adds _APPROACH_RE for superpowers-style "Approach 1: ..." sections. dispatch.sh carve-out ensures /docs/ skip pattern doesn't block these paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/decision/policy/plan_nudge.py | 72 +++++++-- src/decision/policy/session_init.py | 10 ++ src/decision/seeds/__init__.py | 136 ++++++++++++++++ src/decision/seeds/_superpowers.py | 159 +++++++++++++++++++ src/hooks/dispatch.sh | 2 + tests/conftest.py | 11 ++ tests/test_plan_nudge.py | 126 +++++++++++++++ tests/test_seeds.py | 232 ++++++++++++++++++++++++++++ 8 files changed, 735 insertions(+), 13 deletions(-) create mode 100644 src/decision/seeds/__init__.py create mode 100644 src/decision/seeds/_superpowers.py create mode 100644 tests/test_seeds.py diff --git a/src/decision/policy/plan_nudge.py b/src/decision/policy/plan_nudge.py index abd8c68..749e8fd 100644 --- a/src/decision/policy/plan_nudge.py +++ b/src/decision/policy/plan_nudge.py @@ -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 @@ -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`]+" @@ -38,14 +47,26 @@ 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]] = [] @@ -53,7 +74,6 @@ def _extract_decision_candidates(content: str) -> list[dict[str, str]]: 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 @@ -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 @@ -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) diff --git a/src/decision/policy/session_init.py b/src/decision/policy/session_init.py index 41f53bd..8a3d06a 100644 --- a/src/decision/policy/session_init.py +++ b/src/decision/policy/session_init.py @@ -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) diff --git a/src/decision/seeds/__init__.py b/src/decision/seeds/__init__.py new file mode 100644 index 0000000..5471c7b --- /dev/null +++ b/src/decision/seeds/__init__.py @@ -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 diff --git a/src/decision/seeds/_superpowers.py b/src/decision/seeds/_superpowers.py new file mode 100644 index 0000000..b6c276d --- /dev/null +++ b/src/decision/seeds/_superpowers.py @@ -0,0 +1,159 @@ +"""Seed decisions for the Superpowers plugin (obra/superpowers). + +Each decision captures a methodology choice that Superpowers enforces — +design-first, TDD, systematic debugging, etc. These get written to +.claude/decisions/ so the team's adoption of the methodology is explicit, +searchable, and surfaces in related-context when editing affected files. +""" + +from __future__ import annotations + +from . import SeedDecision + +_PREFIX = "superpowers" + + +def superpowers_seeds() -> list[SeedDecision]: + """Return seed decisions representing the Superpowers methodology.""" + return [ + SeedDecision( + slug=f"{_PREFIX}-design-first", + name=f"{_PREFIX}-design-first", + description="Always brainstorm and validate design before writing any code", + tags=[_PREFIX, "workflow"], + affects=["src/", "docs/"], + title="Design before implementation", + body=( + "No code touches disk until the design is written and approved, " + "because unexamined assumptions are the most expensive kind of rework. " + "Even tasks that seem simple get a written design — those are highest-risk " + "for hidden complexity.\n\n" + "The workflow: explore context, ask clarifying questions, propose 2-3 approaches " + "with trade-offs, write a spec to docs/superpowers/specs/, and get explicit " + "approval before any implementation skill can run." + ), + ), + SeedDecision( + slug=f"{_PREFIX}-tdd", + name=f"{_PREFIX}-tdd", + description="No production code without a failing test first (RED-GREEN-REFACTOR)", + tags=[_PREFIX, "testing"], + affects=["src/", "tests/"], + title="Test-driven development (RED-GREEN-REFACTOR)", + body=( + "Tests written after implementation prove nothing — they're biased by the code " + "that already exists. Instead of verifying behavior, they verify implementation. " + "The RED-GREEN-REFACTOR cycle chosen here catches edge cases during design rather " + "than in production.\n\n" + "The rule: write one minimal failing test (RED), watch it fail to prove it tests " + "the right thing, write the simplest code to pass (GREEN), then clean up (REFACTOR). " + "Code written before tests must be deleted and restarted. No exceptions for " + "'simple' code — simple code breaks, and the test takes 30 seconds to write." + ), + ), + SeedDecision( + slug=f"{_PREFIX}-detailed-plans", + name=f"{_PREFIX}-detailed-plans", + description="Implementation plans must be complete with zero placeholders", + tags=[_PREFIX, "workflow"], + affects=["src/", "docs/"], + title="Implementation plans with zero placeholders", + body=( + "Placeholders like TBD, TODO, and 'similar to Task N' delay the discovery of " + "ambiguity until coding, when it's most expensive to resolve. Plans are written " + "instead for an engineer with no codebase context, because that's effectively what " + "a subagent is.\n\n" + "Each task is 2-5 minutes of work with exact file paths, complete code, and " + "verification steps. RED, verify RED, GREEN, verify GREEN, and COMMIT are " + "separate steps. This granularity prevents multi-hour rabbit holes and makes " + "progress observable." + ), + ), + SeedDecision( + slug=f"{_PREFIX}-subagent-development", + name=f"{_PREFIX}-subagent-development", + description="Fresh subagent per task with two-stage review (spec then quality)", + tags=[_PREFIX, "workflow"], + affects=["src/", "tests/"], + title="Subagent-driven development with two-stage review", + body=( + "A fresh subagent per task prevents context pollution — accumulated state from " + "earlier tasks biases later decisions. Instead of one long session that drifts, " + "each task gets clean context with the full task specification.\n\n" + "Review is two-stage in strict order: spec compliance first (does it match the " + "spec — no more, no less?), then code quality (is it well-written?). This order " + "matters because polishing wrong code is pure waste. Critical issues from review " + "block progress." + ), + ), + SeedDecision( + slug=f"{_PREFIX}-systematic-debugging", + name=f"{_PREFIX}-systematic-debugging", + description="Root cause investigation before any fixes — no random changes", + tags=[_PREFIX, "debugging"], + affects=["src/", "tests/"], + title="Root cause investigation before fixes", + body=( + "Random fixes waste time and create new bugs. The alternative chosen here is " + "systematic debugging: read error messages completely, reproduce consistently, " + "check recent changes, trace data flow backward to find where the bad value " + "originates.\n\n" + "Fixes require a stated hypothesis ('X causes Y because Z') tested one variable " + "at a time. If 3+ fixes have failed, stop — the pattern indicates an architectural " + "problem, not a coding problem. Discuss refactoring instead of attempting another " + "patch." + ), + ), + SeedDecision( + slug=f"{_PREFIX}-git-worktrees", + name=f"{_PREFIX}-git-worktrees", + description="Use git worktrees for feature branch isolation with verified baselines", + tags=[_PREFIX, "git"], + affects=["src/", "tests/"], + title="Git worktrees for feature isolation", + body=( + "Git worktrees provide true filesystem isolation for feature branches instead of " + "stashing or switching branches in-place, which risks contaminating the working " + "directory. This was chosen over simple branching because subagent-driven development " + "benefits from a clean, independent working copy.\n\n" + "The worktree directory must be gitignored (verified before creation). After checkout, " + "project setup runs automatically and tests are verified to pass at baseline. A failing " + "baseline is a hard stop — you can't distinguish new bugs from pre-existing ones." + ), + ), + SeedDecision( + slug=f"{_PREFIX}-verify-before-complete", + name=f"{_PREFIX}-verify-before-complete", + description="No completion claims without fresh verification evidence", + tags=[_PREFIX, "quality"], + affects=["src/", "tests/"], + title="Verification evidence before completion claims", + body=( + "Confidence is subjective, evidence is objective. Claiming 'tests pass' without " + "running the test suite, or 'build succeeds' without checking the exit code, is " + "not verification — it's guessing. This rule exists because unverified claims erode " + "trust and hide real failures.\n\n" + "Before any completion claim: identify the verification command, run it fresh, " + "read the full output, and confirm the output matches the claim. Partial checks " + "(linter passed but compiler not run) prove nothing. Words like 'should' and " + "'probably' are red flags that evidence is missing." + ), + ), + SeedDecision( + slug=f"{_PREFIX}-spec-before-quality", + name=f"{_PREFIX}-spec-before-quality", + description="Review spec compliance before code quality — wrong code polished is waste", + tags=[_PREFIX, "quality"], + affects=["src/", "tests/"], + title="Spec compliance review before code quality review", + body=( + "Code quality review before spec compliance review risks polishing the wrong " + "implementation — optimizing code that doesn't match the spec is pure waste. " + "By checking spec compliance first, wrong implementations are caught before any " + "effort goes into making them elegant.\n\n" + "The spec compliance check asks: does the code do exactly what was specified, " + "no more, no less? Over-building is as much a spec violation as under-building. " + "Only after spec compliance passes does code quality review begin." + ), + ), + ] diff --git a/src/hooks/dispatch.sh b/src/hooks/dispatch.sh index 24e3e8f..9ed32c1 100755 --- a/src/hooks/dispatch.sh +++ b/src/hooks/dispatch.sh @@ -81,6 +81,8 @@ if [[ "$event" == "PostToolUse" ]]; then done # Carve-out: always run Python for decision file writes (index-update policy) if [[ "$_skip" == "true" && "$_fp" == *"/decisions/"*".md" ]]; then _skip=false; fi + # Carve-out: always run Python for superpowers spec/plan writes (plan-nudge policy) + if [[ "$_skip" == "true" && "$_fp" == *"/superpowers/"*".md" ]]; then _skip=false; fi if [[ "$_skip" == "true" ]]; then printf '{}\n'; exit 0; fi # Dedup: skip Python if this file_path was already dispatched this session. diff --git a/tests/conftest.py b/tests/conftest.py index d5957ee..8f529fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +23,17 @@ def _isolate_state_dir(tmp_path): yield +@pytest.fixture(autouse=True) +def _isolate_seed_registry(tmp_path): + """Point PLUGINS_JSON to a nonexistent file so seeder is a no-op in all tests.""" + import decision.seeds as seeds_mod + + seeds_mod._registry = None # reset singleton so registrations don't leak + with patch.object(seeds_mod, "PLUGINS_JSON", tmp_path / "no-plugins.json"): + yield + seeds_mod._registry = None + + @pytest.fixture def dispatch_env(): """Env dict for subprocess hook tests.""" diff --git a/tests/test_plan_nudge.py b/tests/test_plan_nudge.py index 77d3e06..efc7957 100644 --- a/tests/test_plan_nudge.py +++ b/tests/test_plan_nudge.py @@ -241,3 +241,129 @@ def test_is_plan_file(): assert not _is_plan_file("/home/user/.claude/decisions/some.md") assert not _is_plan_file("/home/user/.claude/plans/data.json") assert not _is_plan_file("src/plans/readme.md") # no .claude/ prefix + + +# ── Superpowers spec/plan support ─────────────────────────────────── + +SUPERPOWERS_SPEC = """\ +# Auth System Design + +## Context +We need user authentication for the API. + +## Approach 1: JWT tokens +Stateless, scales horizontally, no session storage needed. + +## Approach 2: Session cookies +Simpler to implement, but requires Redis for session storage. + +## Decision +Chose JWT over session cookies because stateless auth scales +better across our multi-region deployment without shared state. + +Instead of bcrypt we opted for argon2 for password hashing +because it's more resistant to GPU-based attacks. + +## Files to Change +### New: `src/auth/jwt.py` +### Modify: `src/models/user.py` +""" + +SUPERPOWERS_PLAN = """\ +# Auth Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development + +## Task 1: Create JWT module +Decided to use PyJWT rather than python-jose because it has +fewer dependencies and covers our use case. + +## Files to Change +### New: `src/auth/jwt.py` +### New: `tests/test_jwt.py` +""" + + +def test_is_plan_file_superpowers_spec(): + from decision.policy.plan_nudge import _is_plan_file + + assert _is_plan_file("/project/docs/superpowers/specs/2026-04-13-auth-design.md") + assert _is_plan_file("/project/docs/superpowers/plans/2026-04-13-auth-plan.md") + assert not _is_plan_file("/project/docs/superpowers/specs/notes.txt") + assert not _is_plan_file("/project/docs/other/specs/design.md") + + +def test_extracts_candidates_from_superpowers_spec(tmp_path): + """Writing a superpowers spec extracts decision candidates.""" + _, store = make_store(tmp_path) + from decision.policy.plan_nudge import _load_candidates, _plan_nudge_condition + + state = make_session_state("sp-spec", store=store) + result = _plan_nudge_condition( + _plan_write("docs/superpowers/specs/2026-04-13-auth-design.md", SUPERPOWERS_SPEC), + state, + ) + assert result is None # Phase 1 never nudges + + candidates = _load_candidates(state) + assert len(candidates) >= 2 + titles = " ".join(c["title"].lower() for c in candidates) + assert "chose" in titles or "jwt" in titles or "approach" in titles + + +def test_extracts_approach_sections(tmp_path): + """_extract_decision_candidates picks up approach/option patterns.""" + from decision.policy.plan_nudge import _extract_decision_candidates + + candidates = _extract_decision_candidates(SUPERPOWERS_SPEC) + titles = " ".join(c["title"].lower() for c in candidates) + # Should find both decision language AND approach sections + assert "chose" in titles or "opted" in titles or "instead" in titles + assert "approach" in titles or "jwt" in titles + + +def test_superpowers_spec_nudge_on_impl_edit(tmp_path): + """After superpowers spec write, first code edit triggers nudge.""" + _, store = make_store(tmp_path) + from decision.policy.plan_nudge import _plan_nudge_condition + + state = make_session_state("sp-nudge", store=store) + + # Phase 1: write spec + _plan_nudge_condition( + _plan_write("docs/superpowers/specs/2026-04-13-auth-design.md", SUPERPOWERS_SPEC), + state, + ) + + # Phase 2: first code edit + result = _plan_nudge_condition(_code_write("src/auth/jwt.py"), state) + assert result is not None + assert result.matched is True + assert "superpowers spec" in result.system_message + assert "auth-design" in result.system_message + + +def test_superpowers_plan_nudge_message(tmp_path): + """Superpowers plan nudge references 'plan' not 'spec'.""" + _, store = make_store(tmp_path) + from decision.policy.plan_nudge import _plan_nudge_condition + + state = make_session_state("sp-plan-msg", store=store) + + _plan_nudge_condition( + _plan_write("docs/superpowers/plans/2026-04-13-auth-plan.md", SUPERPOWERS_PLAN), + state, + ) + result = _plan_nudge_condition(_code_write("src/auth/jwt.py"), state) + assert result is not None + assert "superpowers plan" in result.system_message + assert "auth-plan" in result.system_message + + +def test_superpowers_spec_affects_extracted(tmp_path): + """Superpowers spec file paths are extracted as affects.""" + from decision.policy.plan_nudge import _extract_plan_affects + + affects = _extract_plan_affects(SUPERPOWERS_SPEC) + assert "src/auth/jwt.py" in affects + assert "src/models/user.py" in affects diff --git a/tests/test_seeds.py b/tests/test_seeds.py new file mode 100644 index 0000000..f50d7f9 --- /dev/null +++ b/tests/test_seeds.py @@ -0,0 +1,232 @@ +"""Tests for plugin-aware decision seeding.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from conftest import make_store + +from decision.core.decision import Decision +from decision.seeds import MANIFEST_FILE, SeedDecision, SeedRegistry, _load_manifest + + +# ── Seed validation ────────────────────────────────────────────────── + + +def test_superpowers_seeds_pass_validation(): + """Every superpowers seed decision must pass Decision.validate().""" + from decision.seeds._superpowers import superpowers_seeds + + for seed in superpowers_seeds(): + frontmatter = ( + f"---\n" + f'name: "{seed.name}"\n' + f'description: "{seed.description}"\n' + f'date: "2026-04-13"\n' + f"tags:\n" + ) + for tag in seed.tags: + frontmatter += f' - "{tag}"\n' + if seed.affects: + frontmatter += "affects:\n" + for a in seed.affects: + frontmatter += f' - "{a}"\n' + frontmatter += "---\n" + text = f"{frontmatter}\n# {seed.title}\n\n{seed.body}\n" + d = Decision.from_text(text) + errors = d.validate() + assert errors == [], f"Seed {seed.slug} failed validation: {errors}" + + +def test_superpowers_seeds_have_unique_slugs(): + """All slugs must be unique.""" + from decision.seeds._superpowers import superpowers_seeds + + slugs = [s.slug for s in superpowers_seeds()] + assert len(slugs) == len(set(slugs)) + + +def test_superpowers_seeds_prefixed(): + """All slugs should be prefixed with 'superpowers-'.""" + from decision.seeds._superpowers import superpowers_seeds + + for seed in superpowers_seeds(): + assert seed.slug.startswith("superpowers-"), f"{seed.slug} missing prefix" + + +# ── Detection ──────────────────────────────────────────────────────── + + +def _make_plugins_json(tmp_path, plugin_keys): + """Create a mock installed_plugins.json.""" + plugins = {} + for key in plugin_keys: + plugins[key] = [{"scope": "user", "installPath": str(tmp_path / "cache" / key)}] + data = {"version": 2, "plugins": plugins} + path = tmp_path / "installed_plugins.json" + path.write_text(json.dumps(data)) + return path + + +def test_detect_superpowers_installed(tmp_path): + """Registry detects superpowers when present in installed_plugins.json.""" + plugins_json = _make_plugins_json(tmp_path, ["superpowers@superpowers-marketplace"]) + registry = SeedRegistry() + registry.register("superpowers", lambda: []) + + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + detected = registry.detect_installed_plugins() + + assert "superpowers" in detected + + +def test_detect_superpowers_official_marketplace(tmp_path): + """Registry detects superpowers from the official marketplace key.""" + plugins_json = _make_plugins_json(tmp_path, ["superpowers@claude-plugins-official"]) + registry = SeedRegistry() + registry.register("superpowers", lambda: []) + + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + detected = registry.detect_installed_plugins() + + assert "superpowers" in detected + + +def test_detect_no_superpowers(tmp_path): + """Registry returns empty when superpowers is not installed.""" + plugins_json = _make_plugins_json(tmp_path, ["other-plugin@marketplace"]) + registry = SeedRegistry() + registry.register("superpowers", lambda: []) + + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + detected = registry.detect_installed_plugins() + + assert detected == set() + + +def test_detect_missing_plugins_json(tmp_path): + """Missing installed_plugins.json returns empty set.""" + registry = SeedRegistry() + registry.register("superpowers", lambda: []) + + with patch("decision.seeds.PLUGINS_JSON", tmp_path / "nonexistent.json"): + detected = registry.detect_installed_plugins() + + assert detected == set() + + +def test_detect_corrupt_plugins_json(tmp_path): + """Corrupt installed_plugins.json returns empty set.""" + bad_json = tmp_path / "installed_plugins.json" + bad_json.write_text("not json {{{") + registry = SeedRegistry() + registry.register("superpowers", lambda: []) + + with patch("decision.seeds.PLUGINS_JSON", bad_json): + detected = registry.detect_installed_plugins() + + assert detected == set() + + +# ── Seeding ────────────────────────────────────────────────────────── + + +def _make_registry_with_seeds(plugins_json): + """Create a registry with superpowers seeds and mocked detection.""" + from decision.seeds._superpowers import superpowers_seeds + + registry = SeedRegistry() + registry.register("superpowers", superpowers_seeds) + return registry + + +def test_seed_creates_files(tmp_path): + """seed_decisions creates decision files when superpowers is detected.""" + plugins_json = _make_plugins_json(tmp_path, ["superpowers@marketplace"]) + _, store = make_store(tmp_path) + + registry = _make_registry_with_seeds(plugins_json) + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + count = registry.seed_decisions(store) + + assert count == 8 + assert (store.decisions_dir / "superpowers-tdd.md").is_file() + assert (store.decisions_dir / "superpowers-design-first.md").is_file() + + +def test_seed_idempotent(tmp_path): + """Second call to seed_decisions creates 0 new files.""" + plugins_json = _make_plugins_json(tmp_path, ["superpowers@marketplace"]) + _, store = make_store(tmp_path) + + registry = _make_registry_with_seeds(plugins_json) + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + first = registry.seed_decisions(store) + second = registry.seed_decisions(store) + + assert first == 8 + assert second == 0 + + +def test_seed_respects_deletion(tmp_path): + """Deleted decision file is not re-created on subsequent seed.""" + plugins_json = _make_plugins_json(tmp_path, ["superpowers@marketplace"]) + _, store = make_store(tmp_path) + + registry = _make_registry_with_seeds(plugins_json) + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + registry.seed_decisions(store) + + # Delete one decision + tdd_file = store.decisions_dir / "superpowers-tdd.md" + assert tdd_file.exists() + tdd_file.unlink() + + # Seed again — should not recreate + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + count = registry.seed_decisions(store) + + assert count == 0 + assert not tdd_file.exists() + + +def test_seed_writes_manifest(tmp_path): + """Seeding creates a .seeded.json manifest.""" + plugins_json = _make_plugins_json(tmp_path, ["superpowers@marketplace"]) + _, store = make_store(tmp_path) + + registry = _make_registry_with_seeds(plugins_json) + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + registry.seed_decisions(store) + + manifest = _load_manifest(store.decisions_dir) + assert "superpowers" in manifest["seeded"] + assert len(manifest["seeded"]["superpowers"]["slugs"]) == 8 + + +def test_seed_noop_when_not_installed(tmp_path): + """No files created when superpowers is not installed.""" + plugins_json = _make_plugins_json(tmp_path, ["other-plugin@marketplace"]) + _, store = make_store(tmp_path) + + registry = _make_registry_with_seeds(plugins_json) + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + count = registry.seed_decisions(store) + + assert count == 0 + assert not (store.decisions_dir / MANIFEST_FILE).exists() + + +def test_seeded_files_are_valid_decisions(tmp_path): + """Every file created by seeding must parse and validate as a Decision.""" + plugins_json = _make_plugins_json(tmp_path, ["superpowers@marketplace"]) + _, store = make_store(tmp_path) + + registry = _make_registry_with_seeds(plugins_json) + with patch("decision.seeds.PLUGINS_JSON", plugins_json): + registry.seed_decisions(store) + + for md_file in store.decisions_dir.glob("superpowers-*.md"): + d = Decision.from_file(md_file) + errors = d.validate() + assert errors == [], f"{md_file.name} failed validation: {errors}" From 7cb494d03a9d3dea24573b6820bff6cc10df6333 Mon Sep 17 00:00:00 2001 From: Balaji Janakiram Date: Mon, 13 Apr 2026 11:43:55 +0530 Subject: [PATCH 2/2] Add Superpowers integration section to README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 4d2843c..bb5efb7 100644 --- a/README.md +++ b/README.md @@ -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