diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 96aa4fe..a7705c5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -56,6 +56,13 @@ "version": "1.0.0", "keywords": ["skills", "quality", "patterns"] }, + { + "name": "devflow-ambient", + "source": "./plugins/devflow-ambient", + "description": "Ambient mode — proportional quality enforcement without explicit commands", + "version": "1.0.0", + "keywords": ["ambient", "routing", "quality", "tdd"] + }, { "name": "devflow-audit-claude", "source": "./plugins/devflow-audit-claude", diff --git a/CLAUDE.md b/CLAUDE.md index 0f9d108..0151279 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ DevFlow enhances Claude Code with intelligent development workflows. Modificatio ## Architecture Overview -Plugin marketplace with 8 self-contained plugins, each following the Claude plugins format (`.claude-plugin/plugin.json`, `commands/`, `agents/`, `skills/`). +Plugin marketplace with 9 self-contained plugins, each following the Claude plugins format (`.claude-plugin/plugin.json`, `commands/`, `agents/`, `skills/`). | Plugin | Purpose | Teams Variant | |--------|---------|---------------| @@ -22,6 +22,7 @@ Plugin marketplace with 8 self-contained plugins, each following the Claude plug | `devflow-resolve` | Review issue resolution | Optional | | `devflow-debug` | Competing hypothesis debugging | Optional | | `devflow-self-review` | Self-review (Simplifier + Scrutinizer) | No | +| `devflow-ambient` | Ambient mode — proportional quality enforcement | No | | `devflow-core-skills` | Auto-activating quality enforcement | No | | `devflow-audit-claude` | Audit CLAUDE.md files (optional) | No | @@ -35,13 +36,13 @@ Commands with Teams Variant ship as `{name}.md` (parallel subagents) and `{name} ``` devflow/ -├── shared/skills/ # 24 skills (single source of truth) +├── shared/skills/ # 26 skills (single source of truth) ├── shared/agents/ # 10 shared agents (single source of truth) -├── plugins/devflow-*/ # 8 self-contained plugins +├── plugins/devflow-*/ # 9 self-contained plugins ├── docs/reference/ # Detailed reference documentation ├── scripts/ # Helper scripts (statusline, docs-helpers) -│ └── hooks/ # Working Memory hooks (stop, session-start, pre-compact) -├── src/cli/ # TypeScript CLI (init, list, uninstall) +│ └── hooks/ # Working Memory + ambient hooks (stop, session-start, pre-compact, ambient-prompt) +├── src/cli/ # TypeScript CLI (init, list, uninstall, ambient) └── .claude-plugin/ # Marketplace registry ``` @@ -92,6 +93,7 @@ All generated docs live under `.docs/` in the project root: - `/resolve` — N Resolver agents + Git - `/debug` — Agent Teams competing hypotheses - `/self-review` — Simplifier then Scrutinizer (sequential) +- `/ambient` — Intent classification + proportional skill loading (no agents, main session only) - `/audit-claude` — CLAUDE.md audit (optional plugin) **Shared agents** (10): git, synthesizer, skimmer, simplifier, coder, reviewer, resolver, shepherd, scrutinizer, validator diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 834ad79..8aeb645 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,9 +24,9 @@ After setup, DevFlow commands (`/code-review`, `/implement`, etc.) are available ``` devflow/ -├── shared/skills/ # 24 skills (single source of truth) +├── shared/skills/ # 26 skills (single source of truth) ├── shared/agents/ # 10 shared agents (single source of truth) -├── plugins/devflow-*/ # 8 self-contained plugins +├── plugins/devflow-*/ # 9 self-contained plugins ├── scripts/hooks/ # Working Memory hooks ├── src/cli/ # TypeScript CLI (init, list, uninstall) ├── tests/ # Test suite (Vitest) diff --git a/README.md b/README.md index 908bd76..9f8a1cb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ DevFlow adds structured commands that handle the full lifecycle: specify feature - **Full-lifecycle implementation** — spec, explore, plan, code, validate, refine in one command - **Automatic session memory** — survives restarts, `/clear`, and context compaction - **Parallel debugging** — competing hypotheses investigated simultaneously -- **24 quality skills** — 11 auto-activating, plus specialized review and agent skills +- **26 quality skills** — 12 auto-activating, plus specialized review and agent skills ## Quick Start @@ -48,6 +48,7 @@ Then in Claude Code: | `devflow-resolve` | `/resolve` | Process review issues — fix or defer to tech debt | | `devflow-debug` | `/debug` | Parallel hypothesis debugging | | `devflow-self-review` | `/self-review` | Self-review workflow (Simplifier + Scrutinizer) | +| `devflow-ambient` | `/ambient` | Ambient mode — proportional quality enforcement | | `devflow-core-skills` | (auto) | Auto-activating quality enforcement skills | ## Command Details @@ -116,6 +117,7 @@ The `devflow-core-skills` plugin provides quality enforcement skills that activa | `git-safety` | Rebasing, force-pushing, merge conflicts | | `git-workflow` | Staging files, creating commits, PRs | | `github-patterns` | GitHub API operations, PR comments, releases | +| `test-driven-development` | Implementing new features (RED-GREEN-REFACTOR) | | `test-patterns` | Writing or modifying tests | | `input-validation` | Creating API endpoints | | `typescript` | Working in TypeScript codebases | @@ -209,6 +211,8 @@ Session context is saved and restored automatically via Working Memory hooks — | `npx devflow-kit init` | Install all plugins | | `npx devflow-kit init --plugin=` | Install specific plugin(s) | | `npx devflow-kit list` | List available plugins | +| `npx devflow-kit ambient --enable` | Enable always-on ambient mode | +| `npx devflow-kit ambient --disable` | Disable ambient mode | | `npx devflow-kit uninstall` | Remove DevFlow | ### Init Options diff --git a/plugins/devflow-ambient/.claude-plugin/plugin.json b/plugins/devflow-ambient/.claude-plugin/plugin.json new file mode 100644 index 0000000..9e2c237 --- /dev/null +++ b/plugins/devflow-ambient/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "devflow-ambient", + "description": "Ambient mode — proportional quality enforcement without explicit commands", + "version": "1.0.0", + "agents": [], + "skills": ["ambient-router"] +} diff --git a/plugins/devflow-ambient/README.md b/plugins/devflow-ambient/README.md new file mode 100644 index 0000000..a398ad8 --- /dev/null +++ b/plugins/devflow-ambient/README.md @@ -0,0 +1,49 @@ +# devflow-ambient + +Ambient mode — proportional quality enforcement without explicit command invocation. + +## Command + +### `/ambient` + +Classify user intent and apply proportional skill enforcement to any prompt. + +```bash +/ambient add a login form # BUILD/STANDARD — loads TDD + implementation-patterns +/ambient fix the auth error # DEBUG/STANDARD — loads test-patterns + core-patterns +/ambient where is the config? # EXPLORE/QUICK — responds normally, zero overhead +/ambient refactor the auth system # BUILD/ESCALATE — suggests /implement +``` + +## Always-On Mode + +Enable ambient classification on every prompt without typing `/ambient`: + +```bash +devflow ambient --enable # Register UserPromptSubmit hook +devflow ambient --disable # Remove hook +devflow ambient --status # Check if enabled +``` + +When enabled, a `UserPromptSubmit` hook injects a classification preamble before every prompt. Slash commands (`/implement`, `/code-review`, etc.) and short confirmations ("yes", "ok") are skipped automatically. + +## How It Works + +1. **Classify intent** — BUILD, DEBUG, REVIEW, PLAN, EXPLORE, or CHAT +2. **Classify depth** — QUICK (zero overhead), STANDARD (2-3 skills), or ESCALATE (workflow nudge) +3. **Apply proportionally**: + - QUICK: respond normally + - STANDARD: load relevant skills, enforce TDD for BUILD + - ESCALATE: respond + recommend full workflow command + +## Depth Tiers + +| Depth | When | Overhead | +|-------|------|----------| +| QUICK | Chat, exploration, < 20 words, no file refs | ~0 tokens | +| STANDARD | BUILD/DEBUG/REVIEW/PLAN, 1-5 file scope | ~500-1000 tokens (skill reads) | +| ESCALATE | Multi-file, architectural, system-wide scope | ~0 extra tokens (nudge only) | + +## Skills + +- `ambient-router` — Intent + depth classification, skill selection matrix diff --git a/plugins/devflow-ambient/commands/ambient.md b/plugins/devflow-ambient/commands/ambient.md new file mode 100644 index 0000000..8db3d9d --- /dev/null +++ b/plugins/devflow-ambient/commands/ambient.md @@ -0,0 +1,110 @@ +--- +description: Ambient mode — classify intent and apply proportional quality enforcement to any prompt +--- + +# Ambient Command + +Classify user intent and apply proportional quality enforcement. No agents spawned — enhances the main session only. + +## Usage + +``` +/ambient Classify and respond with skill enforcement +/ambient Show usage +``` + +## Phases + +### Phase 1: Load Router + +Read the `ambient-router` skill: +- `~/.claude/skills/ambient-router/SKILL.md` + +### Phase 2: Classify + +Apply the ambient-router classification to `$ARGUMENTS`: + +1. **Intent:** BUILD | DEBUG | REVIEW | PLAN | EXPLORE | CHAT +2. **Depth:** QUICK | STANDARD | ESCALATE + +If no arguments provided, output: + +``` +## Ambient Mode + +Classify intent and apply proportional quality enforcement. + +Usage: /ambient + +Examples: + /ambient add a login form → BUILD/STANDARD (loads TDD + implementation-patterns) + /ambient fix the auth error → DEBUG/STANDARD (loads test-patterns + core-patterns) + /ambient where is the config? → EXPLORE/QUICK (responds normally) + /ambient refactor the auth system → BUILD/ESCALATE (suggests /implement) + +Always-on: devflow ambient --enable +``` + +Then stop. + +### Phase 3: State Classification + +- **QUICK:** Skip this phase entirely. Respond directly in Phase 4. +- **STANDARD:** Output one line: `Ambient: {INTENT}/{DEPTH}. Loading: {skill1}, {skill2}.` +- **ESCALATE:** Skip — recommendation happens in Phase 4. + +### Phase 4: Apply + +**QUICK:** +Respond to the user's prompt normally. Zero skill loading. Zero overhead. + +**STANDARD:** +Read the selected skills based on the ambient-router's skill selection matrix: + +| Intent | Primary Skills | Secondary (conditional) | +|--------|---------------|------------------------| +| BUILD | test-driven-development, implementation-patterns | typescript (.ts), react (.tsx), frontend-design (CSS/UI), input-validation (forms/API), security-patterns (auth/crypto) | +| DEBUG | test-patterns, core-patterns | git-safety (if git ops) | +| REVIEW | self-review, core-patterns | test-patterns | +| PLAN | implementation-patterns | core-patterns | + +Read up to 3 skills from `~/.claude/skills/{name}/SKILL.md`. Apply their patterns and constraints when responding to the user's prompt. + +For BUILD intent: enforce RED-GREEN-REFACTOR from test-driven-development. Write failing tests before production code. + +**ESCALATE:** +Respond to the user's prompt with your best effort, then append: + +> This task spans multiple files/systems. Consider `/implement` for full lifecycle management (exploration → planning → implementation → review). + +## Architecture + +``` +/ambient (main session, no agents) +│ +├─ Phase 1: Load ambient-router skill +├─ Phase 2: Classify intent + depth +├─ Phase 3: State classification (STANDARD only) +└─ Phase 4: Apply + ├─ QUICK → respond directly + ├─ STANDARD → load 2-3 skills, apply patterns, respond + └─ ESCALATE → respond + workflow nudge +``` + +## Edge Cases + +| Case | Handling | +|------|----------| +| No arguments | Show usage and stop | +| Single word ("help") | Classify — likely CHAT/QUICK | +| Prompt references `/implement` etc. | Classify as normal — user chose /ambient intentionally | +| Mixed intent ("fix and add test") | Use higher-overhead intent (BUILD > DEBUG) | +| User says "no enforcement" | Respect immediately — treat as QUICK | + +## Principles + +1. **No agents** — Ambient enhances the main session, never spawns subagents +2. **Proportional** — QUICK gets zero overhead, STANDARD gets 2-3 skills, ESCALATE gets a nudge +3. **Transparent** — State classification for STANDARD/ESCALATE, silent for QUICK +4. **Respectful** — Never over-classify; when in doubt, go one tier lower +5. **TDD for BUILD** — STANDARD depth BUILD tasks enforce test-first workflow diff --git a/plugins/devflow-core-skills/.claude-plugin/plugin.json b/plugins/devflow-core-skills/.claude-plugin/plugin.json index 3b1e706..d6e4055 100644 --- a/plugins/devflow-core-skills/.claude-plugin/plugin.json +++ b/plugins/devflow-core-skills/.claude-plugin/plugin.json @@ -21,6 +21,7 @@ "github-patterns", "input-validation", "react", + "test-driven-development", "test-patterns", "typescript" ] diff --git a/scripts/hooks/ambient-prompt.sh b/scripts/hooks/ambient-prompt.sh new file mode 100755 index 0000000..9c8889f --- /dev/null +++ b/scripts/hooks/ambient-prompt.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Ambient Mode: UserPromptSubmit Hook +# Injects a classification preamble before every user prompt so Claude applies +# proportional quality enforcement via the ambient-router skill. +# Zero file I/O beyond stdin — static injection only. + +set -euo pipefail + +# jq is required to parse hook input JSON — silently no-op if missing +if ! command -v jq &>/dev/null; then exit 0; fi + +INPUT=$(cat) + +CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) +if [ -z "$CWD" ]; then + exit 0 +fi + +PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null) + +# Skip slash commands — they have their own orchestration +if [[ "$PROMPT" == /* ]]; then + exit 0 +fi + +# Skip short confirmations (< 3 words) +WORD_COUNT=$(echo "$PROMPT" | wc -w | tr -d ' ') +if [ "$WORD_COUNT" -lt 3 ]; then + exit 0 +fi + +# Inject classification preamble +PREAMBLE="AMBIENT MODE ACTIVE: Before responding, silently classify this prompt: +Intent: BUILD | DEBUG | REVIEW | PLAN | EXPLORE | CHAT +Depth: QUICK (no overhead) | STANDARD (load skills) | ESCALATE (suggest /command) + +If STANDARD+: Read the ambient-router skill for classification details and skill selection matrix. For BUILD tasks, also load test-driven-development skill and enforce RED-GREEN-REFACTOR. + +If QUICK: Respond normally without stating classification. +Only state classification aloud for STANDARD/ESCALATE." + +jq -n --arg ctx "$PREAMBLE" '{ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": $ctx + } +}' diff --git a/scripts/hooks/background-memory-update.sh b/scripts/hooks/background-memory-update.sh index 0343d5b..2fa3951 100755 --- a/scripts/hooks/background-memory-update.sh +++ b/scripts/hooks/background-memory-update.sh @@ -102,11 +102,44 @@ fi # Build instruction if [ -n "$EXISTING_MEMORY" ]; then - INSTRUCTION="Update the file $MEMORY_FILE with working memory from this session. The file already has content — possibly from a concurrent session that just wrote it moments ago. Merge this session's context with the existing content to produce a single unified working memory snapshot. Both this session and the existing content represent fresh, concurrent work — integrate both fully. Working memory captures what's active now, not a changelog. Deduplicate overlapping information. Keep under 100 lines total. Use the same structure: ## Now, ## Decisions, ## Modified Files, ## Context, ## Session Log. + PATTERNS_INSTRUCTION="" +PATTERNS_FILE="$CWD/.docs/patterns.md" +EXISTING_PATTERNS="" +if [ -f "$PATTERNS_FILE" ]; then + EXISTING_PATTERNS=$(cat "$PATTERNS_FILE") + PATTERNS_INSTRUCTION=" + +Also update $PATTERNS_FILE by APPENDING any new recurring patterns discovered during this session. Do NOT overwrite existing entries — only add new ones. Skip if no new patterns were observed. Format each entry as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD) + +Existing patterns: +$EXISTING_PATTERNS" +else + PATTERNS_INSTRUCTION=" + +If recurring patterns were observed during this session (coding conventions, architectural decisions, team preferences, tooling quirks), create $PATTERNS_FILE with entries formatted as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD). Only create this file if genuine patterns were observed — do not fabricate entries." +fi + +INSTRUCTION="Update the file $MEMORY_FILE with working memory from this session. The file already has content — possibly from a concurrent session that just wrote it moments ago. Merge this session's context with the existing content to produce a single unified working memory snapshot. Both this session and the existing content represent fresh, concurrent work — integrate both fully. Working memory captures what's active now, not a changelog. Deduplicate overlapping information. Keep under 100 lines total. Use the same structure: ## Now, ## Decisions, ## Modified Files, ## Context, ## Session Log.${PATTERNS_INSTRUCTION} Existing content: $EXISTING_MEMORY" else + PATTERNS_INSTRUCTION="" + PATTERNS_FILE="$CWD/.docs/patterns.md" + if [ -f "$PATTERNS_FILE" ]; then + EXISTING_PATTERNS=$(cat "$PATTERNS_FILE") + PATTERNS_INSTRUCTION=" + +Also update $PATTERNS_FILE by APPENDING any new recurring patterns discovered during this session. Do NOT overwrite existing entries — only add new ones. Skip if no new patterns were observed. Format each entry as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD) + +Existing patterns: +$EXISTING_PATTERNS" + else + PATTERNS_INSTRUCTION=" + +If recurring patterns were observed during this session (coding conventions, architectural decisions, team preferences, tooling quirks), create $PATTERNS_FILE with entries formatted as: - **Pattern name**: Brief description (discovered: YYYY-MM-DD). Only create this file if genuine patterns were observed — do not fabricate entries." + fi + INSTRUCTION="Create the file $MEMORY_FILE with working memory from this session. Keep under 100 lines. Use this structure: # Working Memory @@ -129,7 +162,7 @@ else ### This Week -" +${PATTERNS_INSTRUCTION}" fi # Resume session headlessly to perform the update diff --git a/scripts/hooks/session-start-memory.sh b/scripts/hooks/session-start-memory.sh index 9998696..e9facec 100644 --- a/scripts/hooks/session-start-memory.sh +++ b/scripts/hooks/session-start-memory.sh @@ -31,6 +31,13 @@ fi MEMORY_CONTENT=$(cat "$MEMORY_FILE") +# Read accumulated patterns if they exist +PATTERNS_FILE="$CWD/.docs/patterns.md" +PATTERNS_CONTENT="" +if [ -f "$PATTERNS_FILE" ]; then + PATTERNS_CONTENT=$(cat "$PATTERNS_FILE") +fi + # Compute staleness warning if stat --version &>/dev/null 2>&1; then FILE_MTIME=$(stat -c %Y "$MEMORY_FILE") @@ -62,7 +69,18 @@ fi # Build context string CONTEXT="${STALE_WARNING}--- WORKING MEMORY (from previous session) --- -${MEMORY_CONTENT} +${MEMORY_CONTENT}" + +# Insert accumulated patterns between working memory and git state +if [ -n "$PATTERNS_CONTENT" ]; then + CONTEXT="${CONTEXT} + +--- PROJECT PATTERNS (accumulated) --- + +${PATTERNS_CONTENT}" +fi + +CONTEXT="${CONTEXT} --- CURRENT GIT STATE --- Branch: ${GIT_BRANCH} diff --git a/shared/skills/ambient-router/SKILL.md b/shared/skills/ambient-router/SKILL.md new file mode 100644 index 0000000..c121be5 --- /dev/null +++ b/shared/skills/ambient-router/SKILL.md @@ -0,0 +1,89 @@ +--- +name: ambient-router +description: >- + Classify user intent and response depth for ambient mode. Routes to proportional + quality enforcement without explicit command invocation. Used by /ambient command + and always-on UserPromptSubmit hook. +user-invocable: false +allowed-tools: Read, Grep, Glob +--- + +# Ambient Router + +Classify user intent and select proportional quality enforcement. Zero overhead for simple requests, skill injection for substantive work, workflow nudges for complex tasks. + +## Iron Law + +> **PROPORTIONAL RESPONSE** +> +> Match effort to intent. Never apply heavyweight processes to lightweight requests. +> A chat question gets zero overhead. A 3-file feature gets 2-3 skills. A system +> refactor gets a nudge toward `/implement`. Misclassification in either direction +> is a failure. + +--- + +## Step 1: Classify Intent + +Determine what the user is trying to do from their prompt. + +| Intent | Signal Words / Patterns | Examples | +|--------|------------------------|---------| +| **BUILD** | "add", "create", "implement", "build", "write", "make" | "add a login form", "create an API endpoint" | +| **DEBUG** | "fix", "bug", "broken", "failing", "error", "why does" | "fix the auth error", "why is this test failing" | +| **REVIEW** | "check", "look at", "review", "is this ok", "any issues" | "check this function", "any issues with this?" | +| **PLAN** | "how should", "design", "architecture", "approach", "strategy" | "how should I structure auth?", "what's the approach for caching?" | +| **EXPLORE** | "what is", "where is", "find", "show me", "explain", "how does" | "where is the config?", "explain this function" | +| **CHAT** | greetings, meta-questions, confirmations, short responses | "thanks", "yes", "what can you do?" | + +**Ambiguous prompts:** Default to the lowest-overhead classification. "Update the README" → BUILD (but QUICK depth since it's a single file, simple edit). + +## Step 2: Classify Depth + +Determine how much enforcement the prompt warrants. + +| Depth | Criteria | Action | +|-------|----------|--------| +| **QUICK** | CHAT or EXPLORE intent, OR prompt < 20 words with no file references, OR single-file trivial edit | Respond normally. Zero overhead. Do not state classification. | +| **STANDARD** | BUILD/DEBUG/REVIEW/PLAN intent with 1-5 file scope | Read and apply 2-3 relevant skills from the selection matrix below. State classification briefly. | +| **ESCALATE** | Multi-file architectural change, "refactor all", system-wide scope, > 5 files | Respond at best effort + recommend: "This looks like it would benefit from `/implement` for full lifecycle management." | + +## Step 3: Select Skills (STANDARD depth only) + +Based on classified intent, read the following skills to inform your response. + +| Intent | Primary Skills | Secondary (if file type matches) | +|--------|---------------|----------------------------------| +| **BUILD** | test-driven-development, implementation-patterns | typescript (.ts), react (.tsx/.jsx), frontend-design (CSS/UI), input-validation (forms/API), security-patterns (auth/crypto) | +| **DEBUG** | test-patterns, core-patterns | git-safety (if git operations involved) | +| **REVIEW** | self-review, core-patterns | test-patterns | +| **PLAN** | implementation-patterns | core-patterns | + +**Excluded from ambient** (review-command-only): review-methodology, complexity-patterns, consistency-patterns, database-patterns, dependencies-patterns, documentation-patterns, regression-patterns, architecture-patterns, accessibility. + +See `references/skill-catalog.md` for the full skill-to-intent mapping with file pattern triggers. + +## Step 4: Apply + +- **QUICK:** Respond directly. No preamble, no classification statement. +- **STANDARD:** State classification briefly: `Ambient: BUILD/STANDARD. Loading: test-driven-development, implementation-patterns.` Then read the selected skills and apply their patterns to your response. For BUILD intent, enforce RED-GREEN-REFACTOR from test-driven-development. +- **ESCALATE:** Respond with your best effort, then append: `> This task spans multiple files/systems. Consider \`/implement\` for full lifecycle (exploration → planning → implementation → review).` + +--- + +## Transparency Rules + +1. **QUICK → silent.** No classification output. +2. **STANDARD → brief statement.** One line: intent, depth, skills loaded. +3. **ESCALATE → recommendation.** Best-effort response + workflow nudge. +4. **Never lie about classification.** If uncertain, say so. +5. **Never over-classify.** When in doubt, go one tier lower. + +## Edge Cases + +| Case | Handling | +|------|----------| +| Mixed intent ("fix this bug and add a test") | Use the higher-overhead intent (BUILD > DEBUG) | +| Continuation of previous conversation | Inherit previous classification unless prompt clearly shifts | +| User explicitly requests no enforcement | Respect immediately — classify as QUICK | +| Prompt references specific DevFlow command | Skip ambient — the command has its own orchestration | diff --git a/shared/skills/ambient-router/references/skill-catalog.md b/shared/skills/ambient-router/references/skill-catalog.md new file mode 100644 index 0000000..b46ab74 --- /dev/null +++ b/shared/skills/ambient-router/references/skill-catalog.md @@ -0,0 +1,64 @@ +# Ambient Router — Skill Catalog + +Full mapping of DevFlow skills to ambient intents and file-type triggers. The ambient-router SKILL.md references this for detailed selection logic. + +## Skills Available for Ambient Loading + +These skills may be loaded during STANDARD-depth ambient routing. + +### BUILD Intent + +| Skill | When to Load | File Patterns | +|-------|-------------|---------------| +| test-driven-development | Always for BUILD | `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.py` | +| implementation-patterns | Always for BUILD | Any code file | +| typescript | TypeScript files in scope | `*.ts`, `*.tsx` | +| react | React components in scope | `*.tsx`, `*.jsx` | +| frontend-design | UI/styling work | `*.css`, `*.scss`, `*.tsx` with styling keywords | +| input-validation | Forms, APIs, user input | Files with form/input/validation keywords | +| security-patterns | Auth, crypto, secrets | Files with auth/token/crypto/password keywords | + +### DEBUG Intent + +| Skill | When to Load | File Patterns | +|-------|-------------|---------------| +| test-patterns | Always for DEBUG | Any test-related context | +| core-patterns | Always for DEBUG | Any code file | +| git-safety | Git operations involved | User mentions git, rebase, merge, etc. | + +### REVIEW Intent + +| Skill | When to Load | File Patterns | +|-------|-------------|---------------| +| self-review | Always for REVIEW | Any code file | +| core-patterns | Always for REVIEW | Any code file | +| test-patterns | Test files in scope | `*.test.*`, `*.spec.*` | + +### PLAN Intent + +| Skill | When to Load | File Patterns | +|-------|-------------|---------------| +| implementation-patterns | Always for PLAN | Any planning context | +| core-patterns | Architectural planning | System design discussions | + +## Skills Excluded from Ambient + +These skills are loaded only by explicit DevFlow commands (primarily `/code-review`): + +- review-methodology — Full review process (6-step, 3-category classification) +- complexity-patterns — Cyclomatic complexity, deep nesting analysis +- consistency-patterns — Naming convention, pattern deviation detection +- database-patterns — Index analysis, query optimization, migration safety +- dependencies-patterns — CVE detection, license audit, outdated packages +- documentation-patterns — Doc drift, stale comments, missing API docs +- regression-patterns — Lost functionality, broken exports, behavioral changes +- architecture-patterns — SOLID analysis, coupling detection, layering issues +- accessibility — WCAG compliance, ARIA roles, keyboard navigation +- performance-patterns — N+1 queries, memory leaks, caching opportunities + +## Selection Limits + +- **Maximum 3 skills** per ambient response (primary + up to 2 secondary) +- **Primary skills** are always loaded for the classified intent +- **Secondary skills** are loaded only when file patterns match conversation context +- If more than 3 skills seem relevant, this is an ESCALATE signal diff --git a/shared/skills/test-driven-development/SKILL.md b/shared/skills/test-driven-development/SKILL.md new file mode 100644 index 0000000..2214c81 --- /dev/null +++ b/shared/skills/test-driven-development/SKILL.md @@ -0,0 +1,139 @@ +--- +name: test-driven-development +description: >- + Enforce RED-GREEN-REFACTOR cycle during implementation. Write failing tests before + production code. Distinct from test-patterns (which reviews test quality) — this + skill enforces the TDD workflow during code generation. +user-invocable: false +allowed-tools: Read, Grep, Glob +activation: + file-patterns: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" + - "**/*.py" + exclude: + - "node_modules/**" + - "dist/**" + - "**/*.test.*" + - "**/*.spec.*" +--- + +# Test-Driven Development + +Enforce the RED-GREEN-REFACTOR cycle for all implementation work. Tests define the design. Code satisfies the tests. Refactoring improves the design without changing behavior. + +## Iron Law + +> **TESTS FIRST, ALWAYS** +> +> Write the failing test before the production code. No exceptions. If you catch +> yourself writing production code without a failing test, stop immediately, delete +> the production code, write the test, watch it fail, then write the minimum code +> to make it pass. The test IS the specification. + +--- + +## The Cycle + +### Step 1: RED — Write a Failing Test + +Write a test that describes the behavior you want. Run it. Watch it fail. The failure message IS your specification. + +``` +Describe what the code SHOULD do, not how it does it. +One behavior per test. One assertion per test (ideally). +Name tests as sentences: "returns error when email is invalid" +``` + +**Checkpoint:** The test MUST fail before proceeding. A test that passes immediately proves nothing. + +### Step 2: GREEN — Write Minimum Code to Pass + +Write the simplest production code that makes the failing test pass. No more, no less. + +``` +Hardcode first if that's simplest. Generalize when the next test forces it. +Don't write code "you'll need later." Write code the test demands NOW. +Don't optimize. Don't refactor. Don't clean up. Just pass the test. +``` + +**Checkpoint:** All tests pass. If any test fails, fix it before moving on. + +### Step 3: REFACTOR — Improve Without Changing Behavior + +Now clean up. Extract helpers, rename variables, simplify logic. Tests stay green throughout. + +``` +Run tests after every refactoring step. +If a test breaks during refactor, undo immediately — you changed behavior. +Apply DRY, extract patterns, improve readability. +``` + +**Checkpoint:** All tests still pass. Code is clean. Repeat from Step 1 for next behavior. + +--- + +## Rationalization Prevention + +These are the excuses developers use to skip TDD. Recognize and reject them. + +| Excuse | Why It Feels Right | Why It's Wrong | Correct Action | +|--------|-------------------|---------------|----------------| +| "I'll write tests after" | Need to see the shape first | Tests ARE the shape — they define the interface before implementation exists | Write the test first | +| "Too simple to test" | It's just a getter/setter | Getters break, defaults change, edge cases hide in "simple" code | Write it — takes 30 seconds | +| "I'll refactor later" | Just get it working now | "Later" never comes; technical debt compounds silently | Refactor now in Step 3 | +| "Test is too hard to write" | Setup is complex, mocking is painful | Hard-to-test code = bad design; the test is telling you the interface is wrong | Simplify the interface first | +| "Need to see the whole picture" | Can't test what I haven't designed yet | TDD IS design; each test reveals the next piece of the interface | Let the test guide the design | +| "Tests slow me down" | Faster to just write the code | Faster until the first regression; TDD is faster for anything > 50 lines | Trust the cycle | + +See `references/rationalization-prevention.md` for extended examples with code. + +--- + +## Process Enforcement + +When implementing any feature under ambient BUILD/STANDARD: + +1. **Identify the first behavior** — What is the simplest thing this feature must do? +2. **Write the test** — Describe that behavior as a failing test +3. **Run the test** — Confirm it fails (RED) +4. **Write minimum code** — Just enough to pass (GREEN) +5. **Refactor** — Clean up while tests stay green (REFACTOR) +6. **Repeat** — Next behavior, next test, next cycle + +### File Organization + +- Test file lives next to production file: `user.ts` → `user.test.ts` +- Follow project's existing test conventions (Jest, Vitest, pytest, etc.) +- Import the module under test, not internal helpers + +### What to Test + +| Test | Don't Test | +|------|-----------| +| Public API behavior | Private implementation details | +| Error conditions and edge cases | Framework internals | +| Integration points (boundaries) | Third-party library correctness | +| State transitions | Getter/setter plumbing (unless non-trivial) | + +--- + +## When TDD Does Not Apply + +- **QUICK depth** — Ambient classified as QUICK (chat, exploration, trivial edits) +- **Non-code tasks** — Documentation, configuration, CI changes +- **Exploratory prototyping** — User explicitly says "just spike this" or "prototype" +- **Existing test suite changes** — Modifying tests themselves (test-patterns skill applies instead) + +When skipping TDD, never rationalize. State clearly: "Skipping TDD because: [specific reason from list above]." + +--- + +## Integration with Ambient Mode + +- **BUILD/STANDARD** → TDD enforced. Every new function/method gets test-first treatment. +- **BUILD/QUICK** → TDD skipped (trivial single-file edit). +- **BUILD/ESCALATE** → TDD mentioned in nudge toward `/implement`. +- **DEBUG/STANDARD** → TDD applies to the fix: write a test that reproduces the bug first, then fix. diff --git a/shared/skills/test-driven-development/references/rationalization-prevention.md b/shared/skills/test-driven-development/references/rationalization-prevention.md new file mode 100644 index 0000000..b3beb7e --- /dev/null +++ b/shared/skills/test-driven-development/references/rationalization-prevention.md @@ -0,0 +1,111 @@ +# TDD Rationalization Prevention — Extended Examples + +Detailed code examples showing how each rationalization leads to worse outcomes. + +## "I'll write tests after" + +### What happens: + +```typescript +// Developer writes production code first +function calculateDiscount(price: number, tier: string): number { + if (tier === 'gold') return price * 0.8; + if (tier === 'silver') return price * 0.9; + return price; +} + +// Then "writes tests after" — but only for the happy path they remember +test('gold tier gets 20% off', () => { + expect(calculateDiscount(100, 'gold')).toBe(80); +}); +// Missing: negative prices, unknown tiers, zero prices, NaN handling +``` + +### What TDD would have caught: + +```typescript +// Test first — forces you to think about the contract +test('returns error for negative price', () => { + expect(calculateDiscount(-100, 'gold')).toEqual({ ok: false, error: 'NEGATIVE_PRICE' }); +}); +// Now the interface includes error handling from the start +``` + +## "Too simple to test" + +### What happens: + +```typescript +// "It's just a config getter, no test needed" +function getMaxRetries(): number { + return parseInt(process.env.MAX_RETRIES || '3'); +} +// 6 months later: someone sets MAX_RETRIES="three" and prod crashes with NaN retries +``` + +### What TDD would have caught: + +```typescript +test('returns default when env var is not a number', () => { + process.env.MAX_RETRIES = 'three'; + expect(getMaxRetries()).toBe(3); // Forces validation logic +}); +``` + +## "Test is too hard to write" + +### What happens: + +```typescript +// "I can't test this easily because it needs database + email + filesystem" +async function processOrder(orderId: string) { + const db = new Database(); + const order = await db.find(orderId); + await sendEmail(order.customerEmail, 'Your order is processing'); + await fs.writeFile(`/invoices/${orderId}.pdf`, generateInvoice(order)); + await db.update(orderId, { status: 'processing' }); +} +// Result: untestable monolith, test would need real DB + email + filesystem +``` + +### What TDD forces: + +```typescript +// Hard-to-test = bad design. TDD forces dependency injection: +async function processOrder( + orderId: string, + deps: { db: OrderRepository; emailer: Emailer; invoices: InvoiceStore } +): Promise> { + // Now trivially testable with mocks +} +``` + +## "I'll refactor later" + +### What happens: + +```typescript +// Sprint 1: "just get it working" +function handleRequest(req: any) { + if (req.type === 'create') { /* 50 lines */ } + else if (req.type === 'update') { /* 50 lines */ } + else if (req.type === 'delete') { /* 30 lines */ } + // Sprint 2-10: more conditions added, function grows to 500 lines + // "Refactor later" never comes because nobody wants to touch it +} +``` + +### What TDD enforces: + +Step 3 (REFACTOR) happens every cycle. The function never grows beyond what's clean because you clean it every 5-10 minutes. + +## "Tests slow me down" + +### The math: + +| Approach | Time to write | Time to first bug | Time to fix bug | Total (1 month) | +|----------|:---:|:---:|:---:|:---:| +| No TDD | 2h | 4h | 3h (no repro test) | 9h+ | +| TDD | 3h | Caught in test | 15min (test pinpoints) | 3h 15min | + +TDD is slower for the first 30 minutes. It's faster for everything after that. diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 9b85d1f..aa580d5 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -7,6 +7,7 @@ import { dirname, join } from 'path'; import { initCommand } from './commands/init.js'; import { uninstallCommand } from './commands/uninstall.js'; import { listCommand } from './commands/list.js'; +import { ambientCommand } from './commands/ambient.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -23,12 +24,13 @@ program .description('Agentic Development Toolkit for Claude Code\n\nEnhance your AI-assisted development with intelligent commands and workflows.') .version(packageJson.version, '-v, --version', 'Display version number') .helpOption('-h, --help', 'Display help information') - .addHelpText('after', '\nExamples:\n $ devflow init Install all DevFlow plugins\n $ devflow init --plugin=implement Install specific plugin\n $ devflow init --plugin=implement,review Install multiple plugins\n $ devflow list List available plugins\n $ devflow uninstall Remove DevFlow from Claude Code\n $ devflow --version Show version\n $ devflow --help Show help\n\nDocumentation:\n https://github.com/dean0x/devflow#readme'); + .addHelpText('after', '\nExamples:\n $ devflow init Install all DevFlow plugins\n $ devflow init --plugin=implement Install specific plugin\n $ devflow init --plugin=implement,review Install multiple plugins\n $ devflow list List available plugins\n $ devflow ambient --enable Enable always-on ambient mode\n $ devflow uninstall Remove DevFlow from Claude Code\n $ devflow --version Show version\n $ devflow --help Show help\n\nDocumentation:\n https://github.com/dean0x/devflow#readme'); // Register commands program.addCommand(initCommand); program.addCommand(uninstallCommand); program.addCommand(listCommand); +program.addCommand(ambientCommand); // Handle no command program.action(() => { diff --git a/src/cli/commands/ambient.ts b/src/cli/commands/ambient.ts new file mode 100644 index 0000000..c68db9d --- /dev/null +++ b/src/cli/commands/ambient.ts @@ -0,0 +1,182 @@ +import { Command } from 'commander'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; +import { getClaudeDirectory } from '../utils/paths.js'; + +/** + * The hook entry structure used by Claude Code settings.json. + */ +interface HookEntry { + type: string; + command: string; + timeout?: number; +} + +interface HookMatcher { + hooks: HookEntry[]; +} + +interface Settings { + hooks?: Record; + [key: string]: unknown; +} + +const AMBIENT_HOOK_MARKER = 'ambient-prompt.sh'; + +/** + * Add the ambient UserPromptSubmit hook to settings JSON. + * Idempotent — returns unchanged JSON if hook already exists. + */ +export function addAmbientHook(settingsJson: string, devflowDir: string): string { + const settings: Settings = JSON.parse(settingsJson); + + if (hasAmbientHook(settingsJson)) { + return settingsJson; + } + + if (!settings.hooks) { + settings.hooks = {}; + } + + const hookCommand = path.join(devflowDir, 'scripts', 'hooks', AMBIENT_HOOK_MARKER); + + const newEntry: HookMatcher = { + hooks: [ + { + type: 'command', + command: hookCommand, + timeout: 5, + }, + ], + }; + + if (!settings.hooks.UserPromptSubmit) { + settings.hooks.UserPromptSubmit = []; + } + + settings.hooks.UserPromptSubmit.push(newEntry); + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Remove the ambient UserPromptSubmit hook from settings JSON. + * Idempotent — returns unchanged JSON if hook not present. + * Preserves other UserPromptSubmit hooks. Cleans empty arrays/objects. + */ +export function removeAmbientHook(settingsJson: string): string { + const settings: Settings = JSON.parse(settingsJson); + + if (!settings.hooks?.UserPromptSubmit) { + return settingsJson; + } + + settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter( + (matcher) => !matcher.hooks.some((h) => h.command.includes(AMBIENT_HOOK_MARKER)), + ); + + if (settings.hooks.UserPromptSubmit.length === 0) { + delete settings.hooks.UserPromptSubmit; + } + + if (settings.hooks && Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Check if the ambient hook is registered in settings JSON. + */ +export function hasAmbientHook(settingsJson: string): boolean { + const settings: Settings = JSON.parse(settingsJson); + + if (!settings.hooks?.UserPromptSubmit) { + return false; + } + + return settings.hooks.UserPromptSubmit.some((matcher) => + matcher.hooks.some((h) => h.command.includes(AMBIENT_HOOK_MARKER)), + ); +} + +export const ambientCommand = new Command('ambient') + .description('Enable or disable ambient mode (always-on quality enforcement)') + .option('--enable', 'Register UserPromptSubmit hook for ambient mode') + .option('--disable', 'Remove ambient mode hook') + .option('--status', 'Check if ambient mode is enabled') + .action(async (options) => { + const hasFlag = options.enable || options.disable || options.status; + if (!hasFlag) { + p.intro(color.bgMagenta(color.white(' Ambient Mode '))); + p.note( + `${color.cyan('devflow ambient --enable')} Register always-on hook\n` + + `${color.cyan('devflow ambient --disable')} Remove always-on hook\n` + + `${color.cyan('devflow ambient --status')} Check current state`, + 'Usage', + ); + p.outro(color.dim('Or use /ambient for one-shot classification')); + return; + } + + const claudeDir = getClaudeDirectory(); + const settingsPath = path.join(claudeDir, 'settings.json'); + + let settingsContent: string; + try { + settingsContent = await fs.readFile(settingsPath, 'utf-8'); + } catch { + if (options.status) { + p.log.info('Ambient mode: disabled (no settings.json found)'); + return; + } + // Create minimal settings.json + settingsContent = '{}'; + } + + if (options.status) { + const enabled = hasAmbientHook(settingsContent); + p.log.info(`Ambient mode: ${enabled ? color.green('enabled') : color.dim('disabled')}`); + return; + } + + // Resolve devflow scripts directory from settings.json hooks or default + let devflowDir: string; + try { + const settings = JSON.parse(settingsContent); + // Try to extract devflowDir from existing hooks (e.g., Stop hook path) + const stopHook = settings.hooks?.Stop?.[0]?.hooks?.[0]?.command; + if (stopHook) { + // e.g., /Users/dean/.devflow/scripts/hooks/stop-update-memory.sh → /Users/dean/.devflow + devflowDir = path.resolve(stopHook, '..', '..', '..'); + } else { + devflowDir = path.join(process.env.HOME || '~', '.devflow'); + } + } catch { + devflowDir = path.join(process.env.HOME || '~', '.devflow'); + } + + if (options.enable) { + const updated = addAmbientHook(settingsContent, devflowDir); + if (updated === settingsContent) { + p.log.info('Ambient mode already enabled'); + return; + } + await fs.writeFile(settingsPath, updated, 'utf-8'); + p.log.success('Ambient mode enabled — UserPromptSubmit hook registered'); + p.log.info(color.dim('Every prompt will now be classified for proportional quality enforcement')); + } + + if (options.disable) { + const updated = removeAmbientHook(settingsContent); + if (updated === settingsContent) { + p.log.info('Ambient mode already disabled'); + return; + } + await fs.writeFile(settingsPath, updated, 'utf-8'); + p.log.success('Ambient mode disabled — hook removed'); + } + }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index a70ee20..4d5d3aa 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -20,9 +20,11 @@ import { import { DEVFLOW_PLUGINS, LEGACY_SKILL_NAMES, LEGACY_COMMAND_NAMES, buildAssetMaps, type PluginDefinition } from '../plugins.js'; import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../utils/safe-delete.js'; import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile } from '../utils/safe-delete-install.js'; +import { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; // Re-export pure functions for tests (canonical source is post-install.ts) export { substituteSettingsTemplate, computeGitignoreAppend, applyTeamsConfig, stripTeamsConfig, mergeDenyList } from '../utils/post-install.js'; +export { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -87,6 +89,7 @@ interface InitOptions { verbose?: boolean; plugin?: string; teams?: boolean; + ambient?: boolean; } export const initCommand = new Command('init') @@ -96,6 +99,8 @@ export const initCommand = new Command('init') .option('--plugin ', 'Install specific plugin(s), comma-separated (e.g., implement,code-review)') .option('--teams', 'Enable Agent Teams (peer debate, adversarial review)') .option('--no-teams', 'Disable Agent Teams (use parallel subagents instead)') + .option('--ambient', 'Enable ambient mode (always-on proportional quality enforcement)') + .option('--no-ambient', 'Disable ambient mode') .action(async (options: InitOptions) => { // Get package version const packageJsonPath = path.resolve(__dirname, '../../package.json'); @@ -199,6 +204,24 @@ export const initCommand = new Command('init') teamsEnabled = teamsChoice; } + // Ambient mode selection + let ambientEnabled: boolean; + if (options.ambient !== undefined) { + ambientEnabled = options.ambient; + } else if (!process.stdin.isTTY) { + ambientEnabled = false; + } else { + const ambientChoice = await p.confirm({ + message: 'Enable ambient mode? (proportional quality enforcement on every prompt)', + initialValue: false, + }); + if (p.isCancel(ambientChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + ambientEnabled = ambientChoice; + } + // Security deny list placement (user scope + TTY only) let securityMode: SecurityMode = 'user'; if (scope === 'user' && process.stdin.isTTY) { @@ -375,6 +398,21 @@ export const initCommand = new Command('init') } } await installSettings(claudeDir, rootDir, devflowDir, verbose, teamsEnabled, effectiveSecurityMode); + + // Install ambient hook if enabled + if (ambientEnabled) { + const settingsPath = path.join(claudeDir, 'settings.json'); + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const updated = addAmbientHook(content, devflowDir); + if (updated !== content) { + await fs.writeFile(settingsPath, updated, 'utf-8'); + if (verbose) { + p.log.success('Ambient mode hook installed'); + } + } + } catch { /* settings.json may not exist yet */ } + } } const fileExtras = selectedExtras.filter(e => e !== 'settings' && e !== 'safe-delete'); diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 0c1f855..7960275 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -9,6 +9,7 @@ import { getInstallationPaths, getClaudeDirectory, getManagedSettingsPath } from import { getGitRoot } from '../utils/git.js'; import { isClaudeCliAvailable } from '../utils/cli.js'; import { DEVFLOW_PLUGINS, getAllSkillNames, LEGACY_SKILL_NAMES, type PluginDefinition } from '../plugins.js'; +import { removeAmbientHook } from './ambient.js'; import { detectShell, getProfilePath } from '../utils/safe-delete.js'; import { isAlreadyInstalled, removeFromProfile } from '../utils/safe-delete-install.js'; import { removeManagedSettings } from '../utils/post-install.js'; @@ -196,6 +197,21 @@ export const uninstallCommand = new Command('uninstall') if (!usedCli) { if (isSelectiveUninstall) { await removeSelectedPlugins(claudeDir, selectedPlugins, verbose); + + // Clean up ambient hook if ambient plugin is being removed + if (selectedPlugins.some(sp => sp.name === 'devflow-ambient')) { + const settingsPath = path.join(claudeDir, 'settings.json'); + try { + const settings = await fs.readFile(settingsPath, 'utf-8'); + const updated = removeAmbientHook(settings); + if (updated !== settings) { + await fs.writeFile(settingsPath, updated, 'utf-8'); + if (verbose) { + p.log.success('Ambient mode hook removed from settings.json'); + } + } + } catch { /* settings.json may not exist */ } + } } else { await removeAllDevFlow(claudeDir, devflowScriptsDir, verbose); } @@ -280,7 +296,18 @@ export const uninstallCommand = new Command('uninstall') try { const paths = await getInstallationPaths(scope); const settingsPath = path.join(paths.claudeDir, 'settings.json'); - const settingsContent = await fs.readFile(settingsPath, 'utf-8'); + let settingsContent = await fs.readFile(settingsPath, 'utf-8'); + + // Always remove ambient hook on full uninstall (idempotent) + const withoutAmbient = removeAmbientHook(settingsContent); + if (withoutAmbient !== settingsContent) { + await fs.writeFile(settingsPath, withoutAmbient, 'utf-8'); + settingsContent = withoutAmbient; + if (verbose) { + p.log.success(`Ambient mode hook removed from settings.json (${scope})`); + } + } + const settings = JSON.parse(settingsContent); if (settings.hooks) { diff --git a/src/cli/plugins.ts b/src/cli/plugins.ts index 132861c..8f4c4b3 100644 --- a/src/cli/plugins.ts +++ b/src/cli/plugins.ts @@ -24,7 +24,7 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ description: 'Auto-activating quality enforcement (foundation layer)', commands: [], agents: [], - skills: ['accessibility', 'core-patterns', 'docs-framework', 'frontend-design', 'git-safety', 'git-workflow', 'github-patterns', 'input-validation', 'react', 'test-patterns', 'typescript'], + skills: ['accessibility', 'core-patterns', 'docs-framework', 'frontend-design', 'git-safety', 'git-workflow', 'github-patterns', 'input-validation', 'react', 'test-driven-development', 'test-patterns', 'typescript'], }, { name: 'devflow-specify', @@ -68,6 +68,13 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ agents: ['simplifier', 'scrutinizer', 'validator'], skills: ['self-review', 'core-patterns'], }, + { + name: 'devflow-ambient', + description: 'Ambient mode — proportional quality enforcement', + commands: ['/ambient'], + agents: [], + skills: ['ambient-router'], + }, { name: 'devflow-audit-claude', description: 'Audit CLAUDE.md files against Anthropic best practices', diff --git a/tests/ambient.test.ts b/tests/ambient.test.ts new file mode 100644 index 0000000..7550000 --- /dev/null +++ b/tests/ambient.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import { addAmbientHook, removeAmbientHook, hasAmbientHook } from '../src/cli/commands/ambient.js'; + +describe('addAmbientHook', () => { + it('adds hook to empty settings', () => { + const result = addAmbientHook('{}', '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('ambient-prompt.sh'); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].timeout).toBe(5); + }); + + it('adds alongside existing hooks', () => { + const input = JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'stop.sh' }] }], + }, + }); + const result = addAmbientHook(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + }); + + it('adds alongside existing UserPromptSubmit hooks', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'other-hook.sh' }] }], + }, + }); + const result = addAmbientHook(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(2); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toBe('other-hook.sh'); + expect(settings.hooks.UserPromptSubmit[1].hooks[0].command).toContain('ambient-prompt.sh'); + }); + + it('is idempotent — does not add duplicate hooks', () => { + const first = addAmbientHook('{}', '/home/user/.devflow'); + const second = addAmbientHook(first, '/home/user/.devflow'); + + expect(second).toBe(first); + }); + + it('preserves other settings', () => { + const input = JSON.stringify({ + statusLine: { type: 'command', command: 'statusline.sh' }, + env: { SOME_VAR: '1' }, + }); + const result = addAmbientHook(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.statusLine.command).toBe('statusline.sh'); + expect(settings.env.SOME_VAR).toBe('1'); + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + }); + + it('uses correct devflowDir path in command', () => { + const result = addAmbientHook('{}', '/custom/path/.devflow'); + const settings = JSON.parse(result); + const command = settings.hooks.UserPromptSubmit[0].hooks[0].command; + + expect(command).toContain('/custom/path/.devflow/scripts/hooks/ambient-prompt.sh'); + }); +}); + +describe('removeAmbientHook', () => { + it('removes ambient hook', () => { + const withHook = addAmbientHook('{}', '/home/user/.devflow'); + const result = removeAmbientHook(withHook); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('preserves other UserPromptSubmit hooks', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'other-hook.sh' }] }, + { hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] }, + ], + }, + }); + const result = removeAmbientHook(input); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toBe('other-hook.sh'); + }); + + it('cleans empty hooks object when last hook removed', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] }, + ], + }, + }); + const result = removeAmbientHook(input); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('preserves other hook event types', () => { + const input = JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'stop.sh' }] }], + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] }, + ], + }, + }); + const result = removeAmbientHook(input); + const settings = JSON.parse(result); + + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit).toBeUndefined(); + }); + + it('is idempotent — safe to call when not present', () => { + const input = JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'stop.sh' }] }] } }); + const result = removeAmbientHook(input); + + expect(result).toBe(input); + }); + + it('preserves other settings', () => { + const input = JSON.stringify({ + statusLine: { type: 'command' }, + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] }, + ], + }, + }); + const result = removeAmbientHook(input); + const settings = JSON.parse(result); + + expect(settings.statusLine).toEqual({ type: 'command' }); + }); +}); + +describe('hasAmbientHook', () => { + it('returns true when present', () => { + const withHook = addAmbientHook('{}', '/home/user/.devflow'); + expect(hasAmbientHook(withHook)).toBe(true); + }); + + it('returns false when absent', () => { + expect(hasAmbientHook('{}')).toBe(false); + }); + + it('returns false for non-ambient UserPromptSubmit hooks', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'other-hook.sh' }] }, + ], + }, + }); + expect(hasAmbientHook(input)).toBe(false); + }); + + it('returns true when ambient hook is among other hooks', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'other-hook.sh' }] }, + { hooks: [{ type: 'command', command: '/path/to/ambient-prompt.sh' }] }, + ], + }, + }); + expect(hasAmbientHook(input)).toBe(true); + }); +}); diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts index f8f89bf..500e771 100644 --- a/tests/init-logic.test.ts +++ b/tests/init-logic.test.ts @@ -10,6 +10,9 @@ import { applyTeamsConfig, stripTeamsConfig, mergeDenyList, + addAmbientHook, + removeAmbientHook, + hasAmbientHook, } from '../src/cli/commands/init.js'; import { getManagedSettingsPath } from '../src/cli/utils/paths.js'; import { installViaFileCopy, type Spinner } from '../src/cli/utils/installer.js'; @@ -319,6 +322,20 @@ describe('mergeDenyList', () => { }); }); +describe('ambient hook re-exports from init', () => { + it('re-exports addAmbientHook from ambient.ts', () => { + expect(typeof addAmbientHook).toBe('function'); + }); + + it('re-exports removeAmbientHook from ambient.ts', () => { + expect(typeof removeAmbientHook).toBe('function'); + }); + + it('re-exports hasAmbientHook from ambient.ts', () => { + expect(typeof hasAmbientHook).toBe('function'); + }); +}); + describe('installViaFileCopy cleanup (isPartialInstall)', () => { let tmpDir: string; let claudeDir: string;