commit d16c5de665a16f625ad45c98c01af5c19df61487 Author: Zhongwei Li Date: Sat Nov 29 18:50:12 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..cea1d7c --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "workflow", + "description": "Development workflow enhancements including OpenSpec proposal management, skill auto-activation, and command hooks for session checklists, type checking, commit guards, and branch name validation.", + "version": "0.5.0", + "author": { + "name": "Jace Babin", + "email": "jbabin91@gmail.com", + "url": "https://github.com/jbabin91" + }, + "skills": [ + "./skills" + ], + "commands": [ + "./commands" + ], + "hooks": [ + "./hooks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc2fb99 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# workflow + +Development workflow enhancements including OpenSpec proposal management, skill auto-activation, and command hooks for session checklists, type checking, commit guards, and branch name validation. diff --git a/commands/configure.md b/commands/configure.md new file mode 100644 index 0000000..46e4e56 --- /dev/null +++ b/commands/configure.md @@ -0,0 +1,510 @@ +--- +name: configure +description: Generate or update project-level plugin configuration with smart migration +version: 2.0.0 +--- + +# Configure Plugin Settings + +This command helps you set up `.claude/super-claude-config.json` for customizing plugin behavior (skills and hooks). + +## What This Does + +Creates or updates `.claude/super-claude-config.json` with configuration for: + +- **Skills** - Auto-activation triggers, enable/disable +- **Hooks** - Behavior customization (protected branches, timeouts, etc.) +- **All installed plugins** - Unified configuration in one file + +## Your Task + +Execute this workflow intelligently based on what configuration files exist: + +### Step 1: Detect Existing Configuration + +Check for these files: + +- **New format**: `.claude/super-claude-config.json` +- **Legacy format**: `.claude/skills/skill-rules.json` + +### Step 2: Determine Strategy (Smart Routing) + +**CASE 1: No configuration files exist (Clean Slate)** + +- Generate `.claude/super-claude-config.json` from all plugin defaults +- No questions needed, just create it +- Skip to Step 4 (Generation) + +**CASE 2: Only legacy config exists** + +Use AskUserQuestion: + +```json +{ + "questions": [ + { + "question": "Found legacy skill-rules.json configuration. How should we handle it?", + "header": "Migration", + "multiSelect": false, + "options": [ + { + "label": "Migrate to new format", + "description": "Convert to super-claude-config.json and create backup (.bak file)" + }, + { + "label": "Keep legacy format", + "description": "Create new config separately (both will work, legacy shows deprecation warning)" + } + ] + } + ] +} +``` + +Actions based on answer: + +- **"Migrate to new format"**: Follow Migration workflow (Step 3a) +- **"Keep legacy format"**: Generate new config only, don't touch legacy (Step 4) + +**CASE 3: Only new config exists** + +Check if new plugin defaults are available: + +```typescript +// Pseudo-code logic +const current = loadJson('.claude/super-claude-config.json'); +const allPluginDefaults = discoverPluginDefaults(); // from plugins/*/super-claude-config.json +const newAdditions = computeNewAdditions(current, allPluginDefaults); + +if (newAdditions.isEmpty()) { + // No updates needed + console.log('✓ Configuration is up to date (all plugins configured)'); + return; +} +``` + +If new defaults found, use AskUserQuestion: + +```json +{ + "questions": [ + { + "question": "Found ${count} new configuration options. Add them to your config?", + "header": "Update", + "multiSelect": false, + "options": [ + { + "label": "Add new defaults", + "description": "Add ${summary} while preserving all your customizations" + }, + { + "label": "Skip for now", + "description": "Don't update (you can run /workflow:configure again later)" + } + ] + } + ] +} +``` + +Actions based on answer: + +- **"Add new defaults"**: Merge new defaults (Step 3b) +- **"Skip for now"**: Exit without changes + +**CASE 4: Both configs exist** + +Use AskUserQuestion: + +```json +{ + "questions": [ + { + "question": "Both legacy and new configuration files exist. What should we do?", + "header": "Conflict", + "multiSelect": false, + "options": [ + { + "label": "Merge into new config", + "description": "Import legacy overrides into super-claude-config.json (creates backup)" + }, + { + "label": "Update new config only", + "description": "Ignore legacy file and just update super-claude-config.json" + }, + { + "label": "Keep both separate", + "description": "Don't merge (both will be loaded, legacy shows deprecation warning)" + } + ] + } + ] +} +``` + +Actions based on answer: + +- **"Merge into new config"**: Migration + merge workflow (Step 3a) +- **"Update new config only"**: Update new config, ignore legacy (Step 3b) +- **"Keep both separate"**: Exit without changes + +### Step 3a: Migration Workflow + +**Convert legacy skill-rules.json to new format:** + +```typescript +function migrateLegacyConfig(legacyPath: string): ProjectConfig { + const legacy = JSON.parse(fs.readFileSync(legacyPath, 'utf8')); + const migrated: ProjectConfig = {}; + + // Legacy format has: + // - plugin.namespace (e.g., "meta") + // - skills.{skillName} + // - overrides.disabled (array of "namespace/skillName") + + const namespace = legacy.plugin.namespace; + + if (!migrated[namespace]) { + migrated[namespace] = { skills: {}, hooks: {} }; + } + + // Convert disabled skills + if (legacy.overrides?.disabled) { + for (const fullName of legacy.overrides.disabled) { + const [ns, skillName] = fullName.split('/'); + if (ns === namespace) { + migrated[namespace].skills[skillName] = { enabled: false }; + } + } + } + + // Convert skill overrides (if any custom triggers, priorities, etc.) + for (const [skillName, skillData] of Object.entries(legacy.skills || {})) { + if (!migrated[namespace].skills[skillName]) { + migrated[namespace].skills[skillName] = {}; + } + + // Preserve custom triggers if they exist + if (skillData.promptTriggers) { + migrated[namespace].skills[skillName].triggers = { + keywords: skillData.promptTriggers.keywords || [], + patterns: skillData.promptTriggers.intentPatterns || [], + }; + } + } + + return migrated; +} +``` + +**Workflow:** + +1. Read `.claude/skills/skill-rules.json` +2. Convert to new format using migration logic above +3. Create backup: `cp .claude/skills/skill-rules.json .claude/skills/skill-rules.json.bak` +4. Load all plugin defaults +5. Deep merge: `plugin defaults → migrated overrides → existing new config (if any)` +6. Write to `.claude/super-claude-config.json` + +### Step 3b: Update Existing Config + +**Add only NEW plugins/hooks while preserving ALL user customizations:** + +```typescript +function addNewDefaults( + current: ProjectConfig, + allDefaults: Record, +): ProjectConfig { + const result = { ...current }; + + for (const [pluginName, pluginDefaults] of Object.entries(allDefaults)) { + if (!result[pluginName]) { + // Brand new plugin - add entire config + result[pluginName] = { + skills: pluginDefaults.skills || {}, + hooks: pluginDefaults.hooks || {}, + }; + } else { + // Existing plugin - add only NEW skills/hooks + + // Add new skills + if (pluginDefaults.skills) { + result[pluginName].skills = result[pluginName].skills || {}; + for (const [skillName, skillConfig] of Object.entries( + pluginDefaults.skills, + )) { + if (!result[pluginName].skills[skillName]) { + // Only add if doesn't exist (preserve user customizations) + result[pluginName].skills[skillName] = skillConfig; + } + } + } + + // Add new hooks + if (pluginDefaults.hooks) { + result[pluginName].hooks = result[pluginName].hooks || {}; + for (const [hookName, hookConfig] of Object.entries( + pluginDefaults.hooks, + )) { + if (!result[pluginName].hooks[hookName]) { + // Only add if doesn't exist (preserve user customizations) + result[pluginName].hooks[hookName] = hookConfig; + } + } + } + } + } + + return result; +} +``` + +### Step 4: Generate Configuration + +**Discover all plugin defaults:** + +1. Find all `plugins/*/super-claude-config.json` files in the repository +2. Extract `skills` and `hooks` from each plugin's config +3. Build a map of plugin-name → { skills, hooks } + +**Generate the configuration file:** + +Use this EXACT template structure - do NOT modify the `$schema` path or repo URL: + +```json +{ + "$schema": "../../.claude-plugin/super-claude-config.schema.json", + "_comment": [ + "Super Claude Plugin Configuration", + "", + "This file controls plugin behavior for this project.", + "Configuration priority: this file > plugin defaults > environment variables", + "", + "Common customizations:", + " • Disable a skill: Set 'enabled': false", + " • Change protected branches: Modify workflow.hooks.gitCommitGuard.protectedBranches", + " • Add branch prefixes: Modify workflow.hooks.branchNameValidator.allowedPrefixes", + " • Customize triggers: Add/modify 'keywords' or 'patterns' arrays", + "", + "Changes take effect immediately (no restart needed).", + "Documentation: https://github.com/jbabin91/super-claude" + ], + "workflow": { + "skills": {}, + "hooks": {} + }, + "meta": { + "skills": {}, + "hooks": {} + } +} +``` + +**CRITICAL: Merge discovered defaults into this template structure:** + +1. For each plugin discovered, add a top-level key with that plugin's name +2. Populate `skills` and `hooks` from the discovered defaults +3. Keep the `$schema` and `_comment` exactly as shown above +4. Format with Prettier using these rules: + - **NO blank lines between object properties** + - 2-space indentation + - Double quotes for strings + - Trailing commas where allowed + +**Example after merging (if testing plugin discovered):** + +```json +{ + "$schema": "../../.claude-plugin/super-claude-config.schema.json", + "_comment": [ + "Super Claude Plugin Configuration", + "", + "This file controls plugin behavior for this project.", + "Configuration priority: this file > plugin defaults > environment variables", + "", + "Common customizations:", + " • Disable a skill: Set 'enabled': false", + " • Change protected branches: Modify workflow.hooks.gitCommitGuard.protectedBranches", + " • Add branch prefixes: Modify workflow.hooks.branchNameValidator.allowedPrefixes", + " • Customize triggers: Add/modify 'keywords' or 'patterns' arrays", + "", + "Changes take effect immediately (no restart needed).", + "Documentation: https://github.com/jbabin91/super-claude" + ], + "workflow": { + "skills": {}, + "hooks": { + "gitCommitGuard": { + "enabled": false, + "protectedBranches": ["main", "master"], + "bypassEnvVar": "SKIP_COMMIT_GUARD" + }, + "branchNameValidator": { + "enabled": false, + "allowedPrefixes": [ + "feat", + "fix", + "chore", + "docs", + "test", + "refactor", + "perf", + "build", + "ci", + "revert", + "style" + ], + "allowedBranches": ["main", "master", "develop"] + }, + "typeChecker": { + "enabled": true, + "timeout": 2000 + }, + "sessionChecklist": { + "enabled": true + } + } + }, + "meta": { + "skills": { + "skill-creator": { + "enabled": true, + "triggers": { + "keywords": ["create skill", "new skill", "skill development"], + "patterns": ["(create|add|generate|build).*?skill"] + } + }, + "hook-creator": { + "enabled": true, + "triggers": { + "keywords": ["create hook", "new hook", "hook development"], + "patterns": ["(create|add|generate|build).*?hook"] + } + } + }, + "hooks": {} + }, + "testing": { + "skills": { + "test-runner": { + "enabled": true, + "triggers": { + "keywords": ["run tests", "test"], + "patterns": ["(run|execute).*?tests?"] + } + } + }, + "hooks": {} + } +} +``` + +### Step 5: Show Summary + +Provide clear feedback based on what was done: + +**For clean slate generation:** + +```txt +✓ Created .claude/super-claude-config.json + +Configured ${pluginCount} plugins: + • workflow (${skillCount} skills, ${hookCount} hooks) + • meta (${skillCount} skills, ${hookCount} hooks) + ... + +Next steps: + 1. Open .claude/super-claude-config.json + 2. Customize settings for your project + 3. Changes take effect immediately (no restart needed) + +Common customizations: + • Disable skill: Set "enabled": false + • Change protected branches: Modify workflow.hooks.gitCommitGuard.protectedBranches + • Add branch prefixes: Modify workflow.hooks.branchNameValidator.allowedPrefixes + +Documentation: See docs/guides/plugin-configuration.md +``` + +**For migration:** + +```txt +✓ Migrated legacy configuration + +Actions taken: + • Created backup: .claude/skills/skill-rules.json.bak + • Migrated overrides → .claude/super-claude-config.json + • Merged with plugin defaults + +Your customizations were preserved: + - meta/skill-creator: disabled + - Custom triggers for hook-creator + +You can safely delete .claude/skills/skill-rules.json after verification. +The new config will be loaded automatically. +``` + +**For update:** + +```txt +✓ Updated .claude/super-claude-config.json + +Added new defaults: + + Plugin: testing (3 skills, 2 hooks) + + Hook: workflow/sessionChecklist + + Hook: workflow/typeChecker + +Your existing customizations were preserved: + - workflow/gitCommitGuard.protectedBranches: ["main", "production"] + - meta/skill-creator.enabled: false +``` + +**For up-to-date:** + +```txt +✓ Configuration is up to date + +All ${pluginCount} installed plugins are configured. +No new defaults available. + +To reset to plugin defaults: Delete .claude/super-claude-config.json and run this command again. +``` + +## Error Handling + +1. **Cannot create .claude/ directory** + - Check permissions + - Suggest: `chmod +w .claude` + +2. **Plugin defaults not found** + - List plugins with missing configs + - Suggest: Check plugin installation + +3. **Invalid JSON in existing config** + - Show parse error + - Offer to backup and regenerate + +4. **File write fails** + - Show specific error + - Check disk space and permissions + +## Important Notes + +- **Always preserve user customizations** - Never overwrite unless user explicitly confirms +- **Create backups** - Always create `.bak` files before modifying +- **Deep merge strategy** - User values always win over defaults +- **Idempotent** - Safe to run multiple times +- **No restart needed** - Config loader reads on every hook execution + +## Testing Checklist + +After implementation, verify: + +- [ ] Clean slate generation works +- [ ] Legacy migration creates backup +- [ ] Update adds only new defaults +- [ ] Conflict resolution offers all choices +- [ ] User customizations are never lost +- [ ] AskUserQuestion shows clear options +- [ ] Summary messages are accurate +- [ ] Generated JSON is valid and formatted diff --git a/commands/generate-skill-rules.md b/commands/generate-skill-rules.md new file mode 100644 index 0000000..fc6dadf --- /dev/null +++ b/commands/generate-skill-rules.md @@ -0,0 +1,160 @@ +--- +name: generate-skill-rules +description: Generate skill-rules.json entries from SKILL.md YAML frontmatter (for maintainers) +version: 1.0.0 +--- + +# Generate Skill Rules + +This command parses YAML frontmatter from SKILL.md files and generates `skill-rules.json` entries for the auto-activation system. + +## Usage + +```sh +/generate-skill-rules [options] +``` + +## What It Does + +1. Scans for SKILL.md files in the specified directory +2. Parses YAML frontmatter to extract trigger information +3. Converts to skill-rules.json format +4. Outputs generated JSON to stdout or writes to file + +## Options + +- **--plugin** - Plugin directory to scan (default: current directory) +- **--namespace** - Plugin namespace (required if --write) +- **--write** - Write directly to skill-rules.json +- **--dry-run** - Output to stdout without writing (default) + +## Examples + +### Preview Generated Rules + +```sh +/generate-skill-rules --plugin plugins/tanstack-tools --namespace tanstack +``` + +### Write to skill-rules.json + +```sh +/generate-skill-rules --plugin plugins/api-tools --namespace api --write +``` + +## Instructions for Claude + +When this command is invoked: + +1. **Locate SKILL.md Files** + + ```sh + find {plugin-dir}/skills -name "SKILL.md" -type f + ``` + +2. **Parse Each SKILL.md** + - Read YAML frontmatter (between `---` delimiters) + - Extract these fields: + - `name` → skill ID + - `description` → skill description + - `category` → determines skill type + - `triggers.keywords` → promptTriggers.keywords + - `triggers.patterns` → promptTriggers.intentPatterns + - `priority` (if present) → priority field + +3. **Map Fields to skill-rules.json Schema** + + ```json + { + "plugin": { + "name": "{extracted-from-plugin-json}", + "version": "{extracted-from-plugin-json}", + "namespace": "{from-cli-arg-or-plugin-json}" + }, + "skills": { + "{skill-name}": { + "type": "domain", + "enforcement": "suggest", + "priority": "{from-yaml-or-default-high}", + "description": "{from-yaml}", + "promptTriggers": { + "keywords": ["{from-yaml-triggers-keywords}"], + "intentPatterns": ["{from-yaml-triggers-patterns}"] + } + } + } + } + ``` + +4. **Handle Edge Cases** + - Skills without triggers → create empty arrays + - Skills without priority → default to "high" + - Skills with category "guardrail" → type: "guardrail" + - All other categories → type: "domain" + +5. **Validation** + - Check namespace is valid (lowercase, no spaces) + - Ensure skill names are kebab-case + - Validate regex patterns are valid + - Warn about missing required fields + +6. **Output Format** + - **Dry run**: Print formatted JSON to stdout with guidance + - **Write mode**: Create/update `{plugin-dir}/skills/skill-rules.json` + - Include success message with file location + +## Example YAML → JSON Conversion + +**SKILL.md frontmatter:** + +```yaml +--- +name: skill-creator +description: Generate new Claude Code skills +category: workflow-automation +priority: high +triggers: + keywords: + - create skill + - new skill + - skill template + patterns: + - (create|add|generate).*?skill + - how to.*?create.*?skill +--- +``` + +**Generated skill-rules.json entry:** + +```json +{ + "skill-creator": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "description": "Generate new Claude Code skills", + "promptTriggers": { + "keywords": ["create skill", "new skill", "skill template"], + "intentPatterns": [ + "(create|add|generate).*?skill", + "how to.*?create.*?skill" + ] + } + } +} +``` + +## Success Criteria + +- Successfully parses all SKILL.md files +- Generates valid JSON matching schema +- Handles skills without triggers gracefully +- Provides clear output and error messages +- Works for both dry-run and write modes + +## Notes + +- This is a maintainer tool for initial migration +- After migration, maintain skill-rules.json directly +- YAML triggers in SKILL.md can be removed after migration +- Always validate generated JSON with `/workflow:configure` diff --git a/commands/openspec/apply.md b/commands/openspec/apply.md new file mode 100644 index 0000000..5ce31e9 --- /dev/null +++ b/commands/openspec/apply.md @@ -0,0 +1,28 @@ +--- +name: openspec:apply +description: Implement an approved OpenSpec change and keep tasks in sync. +category: openspec +tags: [openspec, apply] +--- + + + +**Guardrails** + +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. + +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** + +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/commands/openspec/archive.md b/commands/openspec/archive.md new file mode 100644 index 0000000..8eb01f8 --- /dev/null +++ b/commands/openspec/archive.md @@ -0,0 +1,36 @@ +--- +name: openspec:archive +description: Archive a deployed OpenSpec change and update specs. +category: openspec +tags: [openspec, archive] +--- + + + +**Guardrails** + +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** + +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. **Update CHANGELOG.md** - Add entry under appropriate version (Unreleased or next version) and category: + - **Added** - New skills/features + - **Changed** - Modified existing functionality + - **Fixed** - Bug fixes +6. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. + +**Reference** + +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/commands/openspec/checkpoint.md b/commands/openspec/checkpoint.md new file mode 100644 index 0000000..3b5ab1c --- /dev/null +++ b/commands/openspec/checkpoint.md @@ -0,0 +1,162 @@ +--- +name: openspec:checkpoint +description: Save current progress and context to design.md. +category: openspec +tags: [openspec, checkpoint, save] +--- + + + +**Purpose** + +This command saves your current progress as a checkpoint in design.md. It's the equivalent of the Reddit post's "checkpoint" concept - preserving context so you can resume work later without losing your train of thought. + +**When to Use** + +- Before taking a break or ending a session +- After completing a significant subtask +- When you've made important decisions or discoveries +- Before switching to a different change +- Regularly during long work sessions (every 30-60 minutes) + +**Steps** + +1. **Check for active change** + - Read `openspec/active.json` + - If no active change: "No active change. Use /openspec:work to start working on a change first." + - Exit if no active change + +2. **Gather current context** + - Ask user to summarize current progress: + + ```txt + Let's save a checkpoint. Please provide: + + 1. What did you just complete? + 2. What are you working on now? + 3. What's next? + 4. Any blockers or decisions made? + + (You can provide a brief summary or detailed notes) + ``` + +3. **Load existing design.md** + - Path: `openspec/changes//design.md` + - If doesn't exist, create with initial structure: + + ```markdown + # Design: + + ## Overview + + (Brief description of the approach) + + ## Progress Log + + ### Checkpoint: + + (User's progress notes) + ``` + + - If exists, append new checkpoint section + +4. **Append checkpoint to design.md** + - Add new section at the end: + + ```markdown + ### Checkpoint: + + **Completed:** + + - (what user completed) + + **Current:** + + - (what user is working on) + + **Next:** + + - (what's next) + + **Notes:** + + - (any blockers/decisions) + ``` + +5. **Update tasks.md if needed** + - Ask user: "Any tasks to mark complete in tasks.md? (y/n)" + - If yes, ask which task numbers to mark with `[x]` + - Update tasks.md with completed tasks + +6. **Update active.json** + - Update `lastCheckpoint` field to current timestamp: + + ```json + { + "change": "", + "started": "", + "lastCheckpoint": "" + } + ``` + +7. **Confirm checkpoint saved** + + ```txt + ✓ Checkpoint saved to design.md + + Updated: + - design.md: Added progress notes + - tasks.md: Marked tasks complete + - active.json: Updated lastCheckpoint + + You can safely resume this work later. The SessionStart hook will load this context automatically. + + Use /openspec:status to see current progress. + ``` + +**Error Handling** + +- If no active change: Guide user to use /openspec:work first +- If design.md write fails: Show error and suggest checking file permissions +- If tasks.md parsing fails: Skip task updates but still save checkpoint + +**Example Usage** + +**Scenario 1: Quick checkpoint** + +```txt +User: /openspec:checkpoint +Assistant: Let's save a checkpoint. Please provide: + 1. What did you just complete? + 2. What are you working on now? + 3. What's next? +User: Just finished the theme provider. Working on the toggle component now. Next is localStorage persistence. +Assistant: [Appends to design.md] + [Updates active.json] + ✓ Checkpoint saved +``` + +**Scenario 2: Checkpoint with task updates** + +```txt +User: /openspec:checkpoint +Assistant: Let's save a checkpoint... +User: Completed theme context and provider setup +Assistant: Any tasks to mark complete in tasks.md? (y/n) +User: y +Assistant: Which task numbers? (e.g., 1,3,5) +User: 1,2 +Assistant: [Updates tasks.md: marks tasks 1 and 2 complete] + [Appends checkpoint to design.md] + ✓ Checkpoint saved. 2 tasks marked complete. +``` + +**Notes** + +- design.md acts as a "living doc" that grows with the project +- Checkpoints create a timeline of progress and decisions +- The SessionStart hook reads design.md to restore context +- This is inspired by diet103's "context.md" approach from Reddit +- Unlike git commits, checkpoints capture thought process and decisions + + diff --git a/commands/openspec/done.md b/commands/openspec/done.md new file mode 100644 index 0000000..99d62ce --- /dev/null +++ b/commands/openspec/done.md @@ -0,0 +1,149 @@ +--- +name: openspec:done +description: Complete and archive an OpenSpec change. +category: openspec +tags: [openspec, done, archive, complete] +--- + + + +**Purpose** + +This command finalizes your work on an OpenSpec change: + +- Verifies all tasks are complete +- Archives the change proposal +- Updates CHANGELOG.md +- Clears the active change tracker +- Guides you to commit the changes + +**Steps** + +1. **Check for active change** + - Read `openspec/active.json` + - If no active change: "No active change. Use /openspec:work to start working on a change first." + - Exit if no active change + +2. **Verify all tasks complete** + - Read `openspec/changes//tasks.md` + - Count incomplete tasks (lines with `- [ ]`) + - If any incomplete: + + ```txt + ⚠️ Not all tasks are complete! + + Remaining tasks: + - (list first 5 incomplete tasks) + + Options: + 1. Continue anyway and mark as done (not recommended) + 2. Cancel and finish remaining tasks + 3. Save checkpoint and resume later (/openspec:checkpoint) + + What would you like to do? + ``` + + - Wait for user decision + +3. **Final checkpoint** + - Ask user: "Would you like to save a final checkpoint before archiving? (y/n)" + - If yes, run /openspec:checkpoint workflow + - This captures final state in design.md + +4. **Run OpenSpec archive** + - Execute: `openspec archive ` + - This moves the change to archived folder with timestamp + - Updates specs/ folder with approved changes + - Shows success message from OpenSpec CLI + +5. **Clear active change tracker** + - Delete or clear `openspec/active.json`: + + ```json + {} + ``` + + - Or delete the file entirely + +6. **Guide CHANGELOG update** + - Show message: + + ```txt + ✓ Change archived successfully! + + Next steps: + 1. Review the changes made by OpenSpec + 2. Update CHANGELOG.md with this change: + + ## [Unreleased] + + ### Added + - **** - + + 3. Commit all changes: + git add . + git commit -m "feat: " + + 4. The change is now archived in: + openspec/changes/-/ + + Use /openspec:work to start on the next change. + ``` + +7. **Show OpenSpec view** + - Run `openspec view` to show updated dashboard + - Confirms change is archived and specs are updated + +**Error Handling** + +- If no active change: Guide user to use /openspec:work +- If openspec archive fails: Show error and suggest checking change validity +- If CHANGELOG.md doesn't exist: Create it with standard format +- If active.json clear fails: Warn but continue (non-critical) + +**Example Usage** + +**Scenario 1: All tasks complete** + +```txt +User: /openspec:done +Assistant: [Checks tasks.md - all complete ✓] + Would you like to save a final checkpoint? (y/n) +User: y +Assistant: [Runs checkpoint flow] + [Runs openspec archive add-dark-mode] + [Clears active.json] + ✓ Change archived successfully! + [Shows next steps] +``` + +**Scenario 2: Tasks incomplete** + +```txt +User: /openspec:done +Assistant: ⚠️ Not all tasks are complete! + + Remaining tasks: 3 + - [ ] Add localStorage persistence + - [ ] Write tests for theme toggle + - [ ] Update documentation + + Options: + 1. Continue anyway (not recommended) + 2. Cancel and finish tasks + 3. Save checkpoint and resume later + + What would you like to do? +User: 2 +Assistant: Cancelled. Use /openspec:status to see remaining work. +``` + +**Notes** + +- This command wraps `openspec archive` with pre/post checks +- Clears active.json so SessionStart hook doesn't load old context +- Encourages final checkpoint for documentation +- Guides user through commit process +- Ensures clean state for starting next change + + diff --git a/commands/openspec/init.md b/commands/openspec/init.md new file mode 100644 index 0000000..090caa8 --- /dev/null +++ b/commands/openspec/init.md @@ -0,0 +1,145 @@ +--- +name: openspec:init +description: Initialize OpenSpec directory structure in your project without duplicating slash commands. +category: openspec +tags: [openspec, setup, init] +--- + +# OpenSpec Init Command + +Initialize OpenSpec directory structure in your project without duplicating slash commands. + +## Purpose + +Creates the `openspec/` directory structure (specs, changes, AGENTS.md, project.md) while avoiding conflicts with the workflow plugin's enhanced OpenSpec commands. + +## Usage + +This command should be run **once per project** when you first want to use OpenSpec. + +## What This Command Does + +1. **Validates openspec CLI is installed** + - Check if `openspec` command is available + - If not, provide installation instructions + +2. **Runs `openspec init --tools none`** + - Creates `openspec/` directory structure + - Creates `openspec/AGENTS.md` (OpenSpec workflow instructions) + - Creates `openspec/project.md` (project context template) + - Creates `openspec/changes/` (for proposals) + - Creates `openspec/specs/` (for specifications) + - Creates root `AGENTS.md` (agent instructions) + - Does NOT create `.claude/commands/` (prevents duplication) + +3. **Explains next steps** + - How to fill out `openspec/project.md` + - How to use OpenSpec slash commands from this plugin + - How to create first change proposal + +## Why `--tools none`? + +The workflow plugin provides **enhanced OpenSpec commands** with additional context and customization: + +- `/openspec:proposal` - Create new change proposals +- `/openspec:work` - Start working on a proposal with full context loading +- `/openspec:apply` - Implement approved proposals with task tracking +- `/openspec:checkpoint` - Save progress and context +- `/openspec:status` - Show current proposal status +- `/openspec:done` - Complete and prepare for archiving +- `/openspec:archive` - Archive completed changes + +Using `--tools none` prevents OpenSpec CLI from creating duplicate basic commands, ensuring you get the full enhanced experience from this plugin. + +## Implementation + +```bash +# Get current working directory first for cleaner checks +PROJECT_DIR=$(pwd) + +# Check if already initialized - exit early with helpful message +if [ -d "$PROJECT_DIR/openspec" ]; then + echo "✅ OpenSpec is already initialized in this project!" + echo "" + echo "📂 Current structure:" + echo " openspec/" + echo " ├── AGENTS.md" + echo " ├── project.md" + echo " ├── changes/" + echo " └── specs/" + echo "" + echo "💡 Useful commands:" + echo " /openspec:update - Update instruction files to latest" + echo " /openspec:proposal - Create a new change proposal" + echo " /openspec:status - Check current proposal status" + echo "" + echo "🔧 To reinitialize (advanced):" + echo " 1. Remove openspec/ directory manually" + echo " 2. Run /openspec:init again" + echo "" + exit 0 +fi + +# Check if openspec is installed +if ! command -v openspec &> /dev/null; then + echo "Error: openspec CLI not found" + echo "" + echo "Install with:" + echo " npm install -g @jsdocs-io/openspec" + echo " # or" + echo " pnpm add -g @jsdocs-io/openspec" + echo " # or" + echo " yarn global add @jsdocs-io/openspec" + exit 1 +fi + +# Initialize OpenSpec without tool-specific commands +echo "Initializing OpenSpec in: $PROJECT_DIR" +echo "" +openspec init "$PROJECT_DIR" --tools none + +# Check if successful +if [ $? -eq 0 ]; then + echo "" + echo "✅ OpenSpec initialized successfully!" + echo "" + echo "📂 Created structure:" + echo " openspec/" + echo " ├── AGENTS.md # OpenSpec workflow instructions" + echo " ├── project.md # Project context (fill this out!)" + echo " ├── changes/ # Change proposals" + echo " └── specs/ # Specifications" + echo "" + echo "🎯 Next steps:" + echo "" + echo "1. Fill out project context:" + echo " 'Please read openspec/project.md and help me fill it out" + echo " with details about my project, tech stack, and conventions'" + echo "" + echo "2. Create your first proposal:" + echo " 'I want to add [FEATURE]. Please create an OpenSpec change" + echo " proposal using /openspec:proposal'" + echo "" + echo "3. Available commands from workflow plugin:" + echo " /openspec:proposal - Create new proposal" + echo " /openspec:work - Start working on proposal" + echo " /openspec:apply - Implement approved proposal" + echo " /openspec:checkpoint - Save progress" + echo " /openspec:status - Show current status" + echo " /openspec:done - Mark proposal complete" + echo " /openspec:archive - Archive completed proposal" + echo "" +else + echo "" + echo "❌ OpenSpec initialization failed" + echo "Check the error messages above" + exit 1 +fi +``` + +## Notes + +- **Run once per project** - Not needed in every session +- **Safe to re-run** - Will ask before overwriting existing structure +- **No conflicts** - Won't create duplicate commands (uses `--tools none`) +- **Enhanced commands** - You get our customized OpenSpec workflow from the plugin diff --git a/commands/openspec/proposal.md b/commands/openspec/proposal.md new file mode 100644 index 0000000..3e6e8c5 --- /dev/null +++ b/commands/openspec/proposal.md @@ -0,0 +1,32 @@ +--- +name: openspec:proposal +description: Scaffold a new OpenSpec change and validate strictly. +category: openspec +tags: [openspec, change] +--- + + + +**Guardrails** + +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. + +**Steps** + +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behavior; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. + +**Reference** + +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/commands/openspec/status.md b/commands/openspec/status.md new file mode 100644 index 0000000..dad64fd --- /dev/null +++ b/commands/openspec/status.md @@ -0,0 +1,104 @@ +--- +name: openspec:status +description: Show current OpenSpec change status and progress. +category: openspec +tags: [openspec, status, progress] +--- + + + +**Purpose** + +This command shows the current state of your OpenSpec work: + +- Which change you're actively working on +- Progress on tasks (completed vs remaining) +- Time since you started and last checkpoint +- Quick access to the OpenSpec dashboard + +**Steps** + +1. **Check for active change** + - Read `openspec/active.json` + - If file doesn't exist or is empty: "No active change. Use /openspec:work to start working on a change." + - Exit if no active change + +2. **Parse active change data** + - Extract: `change` (change ID), `started` (timestamp), `lastCheckpoint` (timestamp) + - Calculate time elapsed since started and last checkpoint + +3. **Run OpenSpec dashboard** + - Execute `openspec view` to get current state of all changes + - This shows the progress bars and task counts + +4. **Load task progress** + - Read `openspec/changes//tasks.md` + - Count total tasks (lines starting with `- [ ]` or `- [x]`) + - Count completed tasks (lines starting with `- [x]`) + - Calculate percentage: `(completed / total) * 100` + +5. **Display status summary** + + ```txt + 📊 OpenSpec Status + + Active Change: + Started: + Last Checkpoint: + + Progress: + ├─ Tasks: / (%) + ├─ Status: + └─ Next: + + Recent Context (from design.md): + + + Commands: + - /openspec:checkpoint - Save current progress + - /openspec:work - Switch to different change + - /openspec:done - Complete and archive + ``` + +6. **Show OpenSpec dashboard** + - Output result of `openspec view` for full context + +**Error Handling** + +- If active.json exists but change directory missing: "Active change '' not found. It may have been archived. Use /openspec:work to select a new change." +- If tasks.md missing: Show "0/0 tasks (no tasks.md found)" +- If design.md missing: Skip "Recent Context" section + +**Example Usage** + +```txt +User: /openspec:status +Assistant: +📊 OpenSpec Status + +Active Change: add-dark-mode +Started: 2 hours ago +Last Checkpoint: 15 minutes ago + +Progress: +├─ Tasks: 8/12 (67%) +├─ Status: In Progress +└─ Next: Implement theme toggle in settings page + +Recent Context (from design.md): +- Created ThemeContext with light/dark modes +- Added theme provider to root layout +- Implemented CSS variable system for colors +- Need to add persistence with localStorage + +[openspec view output shown] +``` + +**Notes** + +- This command is read-only - it doesn't modify anything +- Useful for quick progress checks without reading all files +- Shows "at a glance" summary before diving into details +- Complements `openspec view` with active change focus + + diff --git a/commands/openspec/update.md b/commands/openspec/update.md new file mode 100644 index 0000000..84e2527 --- /dev/null +++ b/commands/openspec/update.md @@ -0,0 +1,122 @@ +--- +name: openspec:update +description: Update OpenSpec instruction files to the latest version without affecting slash commands. +category: openspec +tags: [openspec, setup, update] +--- + +# OpenSpec Update Command + +Update OpenSpec instruction files to the latest version without affecting slash commands. + +## Purpose + +Refreshes OpenSpec workflow documentation (AGENTS.md files) when the OpenSpec CLI is updated, ensuring you have the latest best practices and patterns. + +## Usage + +Run this command after updating the OpenSpec CLI to get the latest instruction files: + +```bash +# Update OpenSpec CLI first +npm update -g @jsdocs-io/openspec + +# Then refresh instruction files +/openspec-update +``` + +## What This Command Does + +1. **Validates openspec CLI is installed** + - Checks if `openspec` command is available + - Shows current version + +2. **Runs `openspec update`** + - Updates `openspec/AGENTS.md` (OpenSpec workflow instructions) + - Updates root `AGENTS.md` (if it exists) + - **Does NOT touch slash commands** (we maintain our own enhanced versions) + +3. **Shows what was updated** + - Lists files that changed + - Explains what's new (if possible) + +## Safety + +This command is **safe to run repeatedly**: + +- ✅ Updates instruction files only +- ✅ Won't override workflow plugin commands +- ✅ Won't modify your specs or changes +- ✅ Won't affect project configuration + +## When to Run + +- After updating OpenSpec CLI to a new version +- When you want the latest OpenSpec patterns and best practices +- After reading OpenSpec release notes mentioning instruction updates + +## Implementation + +```bash +# Check if openspec is installed +if ! command -v openspec &> /dev/null; then + echo "Error: openspec CLI not found" + echo "" + echo "Install with:" + echo " npm install -g @jsdocs-io/openspec" + echo " # or" + echo " pnpm add -g @jsdocs-io/openspec" + echo " # or" + echo " yarn global add @jsdocs-io/openspec" + exit 1 +fi + +# Show current version +OPENSPEC_VERSION=$(openspec --version 2>&1 || echo "unknown") +echo "OpenSpec CLI version: $OPENSPEC_VERSION" +echo "" + +# Get current working directory +PROJECT_DIR=$(pwd) + +# Check if initialized +if [ ! -d "$PROJECT_DIR/openspec" ]; then + echo "Error: OpenSpec not initialized in this project" + echo "" + echo "Run /openspec-init first to set up OpenSpec" + exit 1 +fi + +# Update instruction files +echo "Updating OpenSpec instruction files in: $PROJECT_DIR" +echo "" +openspec update "$PROJECT_DIR" + +# Check if successful +if [ $? -eq 0 ]; then + echo "" + echo "✅ OpenSpec instruction files updated successfully!" + echo "" + echo "📝 Updated files:" + echo " openspec/AGENTS.md # Latest workflow instructions" + echo " AGENTS.md # Updated agent guidance" + echo "" + echo "💡 Note:" + echo " Slash commands (/openspec:*) are managed by the workflow plugin" + echo " and are NOT affected by this update. You're using enhanced versions" + echo " customized for Claude Code." + echo "" +else + echo "" + echo "❌ Update failed" + echo "Check the error messages above" + exit 1 +fi +``` + +## Notes + +- **Safe to run anytime** - Won't break existing work +- **No command conflicts** - Won't override workflow plugin commands +- **Version aware** - Get instructions matching your CLI version +- **Preserves customization** - Your specs and changes untouched diff --git a/commands/openspec/work.md b/commands/openspec/work.md new file mode 100644 index 0000000..8368fb8 --- /dev/null +++ b/commands/openspec/work.md @@ -0,0 +1,101 @@ +--- +name: openspec:work +description: Start working on an OpenSpec change with full context loading. +category: openspec +tags: [openspec, work, context] +--- + + + +**Purpose** + +This command helps you start or resume work on an OpenSpec change proposal. It: + +- Shows the OpenSpec dashboard with all changes +- Loads the change context (proposal, design, tasks) +- Tracks the active change for session persistence +- Enables automatic context loading on session resume + +**Steps** + +1. **Show OpenSpec dashboard** + - Run `openspec view` to display all changes with progress + - Output the dashboard so user can see status + +2. **Select change to work on** + - Ask user: "Which change would you like to work on? (provide change ID)" + - Change ID is the folder name in `openspec/changes/` + - Example: `add-dark-mode`, `fix-auth-bug`, `refactor-api` + +3. **Verify change exists** + - Check that `openspec/changes//` directory exists + - If not found, show error: "Change '' not found. Run 'openspec view' to see available changes." + - Exit if not found + +4. **Load change context** + - Read and display key files in this order: + - `openspec/changes//proposal.md` - The WHY (goals, motivation) + - `openspec/changes//design.md` - The HOW (living doc with approach) + - `openspec/changes//tasks.md` - The WHAT (checklist of work) + - If design.md doesn't exist, note: "No design.md yet. Create one to track your approach and decisions." + +5. **Update active change tracker** + - Write to `openspec/active.json`: + + ```json + { + "change": "", + "started": "", + "lastCheckpoint": "" + } + ``` + + - Use JavaScript/TypeScript Date: `new Date().toISOString()` + +6. **Confirm and guide next steps** + - Show success message: + + ```txt + ✓ Now working on: + + Context loaded: + - Proposal: + - Tasks: remaining, completed + + Next steps: + 1. Review the proposal and design above + 2. Work through tasks in tasks.md sequentially + 3. Use /openspec:checkpoint to save progress + 4. Use /openspec:status to check progress anytime + 5. Use /openspec:done when all tasks complete + + The SessionStart hook will automatically load this context if you resume later. + ``` + +**Error Handling** + +- If `openspec` CLI not found: "OpenSpec CLI not installed. Run 'npm install -g @fission-codes/openspec'" +- If no changes exist: "No OpenSpec changes found. Create one with /openspec:proposal" +- If `openspec/` directory doesn't exist: Show error (should be created by 'openspec init') +- If active.json write fails: Show error but continue (non-critical) + +**Example Usage** + +```txt +User: /openspec:work +Assistant: [Runs openspec view, shows dashboard] + Which change would you like to work on? (provide change ID) +User: add-dark-mode +Assistant: [Loads proposal.md, design.md, tasks.md] + [Updates active.json] + [Shows success message with context summary] +``` + +**Notes** + +- This command wraps `openspec view` and adds context loading +- The active.json file enables session resume via SessionStart hook +- design.md serves as the "living doc" (Reddit's context.md equivalent) +- Use /openspec:checkpoint frequently to update design.md with progress + + diff --git a/hooks/branch-name-validator.ts b/hooks/branch-name-validator.ts new file mode 100755 index 0000000..d3ccf2f --- /dev/null +++ b/hooks/branch-name-validator.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env bun + +/** + * Branch Name Validator Hook + * + * Enforces conventional commit prefixes for branch names (GitHub Flow). + * Valid patterns: feat/*, fix/*, chore/*, docs/*, test/*, refactor/*, perf/* + * Allows main/master (protected branches). + * + * Performance target: <50ms (ADR-0010) + * + * @see {@link https://github.com/jbabin91/super-claude} for documentation + */ + +import { checkPerformance, formatError, parseStdin } from './utils/index.js'; +import { + checkHookEnabled, + getHookConfig, +} from './utils/super-claude-config-loader.js'; + +/** + * Extract branch name from git checkout command + * + * @param command Git command string + * @returns Branch name or null if not a checkout -b command + */ +function extractBranchName(command: string): string | null { + // Match: git checkout -b + const match = /git\s+checkout\s+-b\s+([^\s]+)/.exec(command); + return match ? match[1] : null; +} + +/** + * Validate branch name follows conventional commit pattern + * + * @param branchName Branch name to validate + * @param allowedPrefixes List of valid prefixes + * @param allowedBranches Set of branches allowed without prefix + * @returns true if valid, false otherwise + */ +function isValidBranchName( + branchName: string, + allowedPrefixes: string[], + allowedBranches: Set, +): boolean { + // Allow protected branches + if (allowedBranches.has(branchName)) { + return true; + } + + // Check if follows pattern: / + const pattern = new RegExp(`^(${allowedPrefixes.join('|')})/[a-z0-9-]+$`); + return pattern.test(branchName); +} + +/** + * Get suggested branch name from invalid name + * + * @param branchName Invalid branch name + * @param allowedPrefixes List of valid prefixes + * @returns Suggested valid branch name + */ +function suggestBranchName( + branchName: string, + allowedPrefixes: string[], +): string { + // Try to extract a valid prefix if present + for (const prefix of allowedPrefixes) { + if (branchName.toLowerCase().includes(prefix)) { + const description = branchName + .toLowerCase() + .replace(new RegExp(`^${prefix}[/-]?`), '') + .replaceAll(/[^a-z0-9-]/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^-|-$/g, ''); + + return description ? `${prefix}/${description}` : `${prefix}/description`; + } + } + + // Default suggestion + return `feat/${branchName.toLowerCase().replaceAll(/[^a-z0-9-]/g, '-')}`; +} + +/** + * Format blocking message + * + * @param branchName Invalid branch name + * @param allowedPrefixes List of valid prefixes + * @returns Formatted error message + */ +function formatBlockMessage( + branchName: string, + allowedPrefixes: string[], +): string { + const suggested = suggestBranchName(branchName, allowedPrefixes); + + return [ + '', + '═'.repeat(70), + '❌ INVALID BRANCH NAME', + '═'.repeat(70), + '', + `Branch name: ${branchName}`, + '', + 'GitHub Flow + Conventional Commits requires:', + ' /', + '', + 'Valid types:', + ` ${allowedPrefixes.join(', ')}`, + '', + 'Description rules:', + ' • Use kebab-case (lowercase with hyphens)', + ' • Be descriptive and concise', + ' • Use letters, numbers, and hyphens only', + '', + '✅ Valid examples:', + ' feat/user-authentication', + ' fix/memory-leak-in-parser', + ' docs/api-reference', + ' test/validate-branch-names', + ' refactor/simplify-hooks', + '', + '❌ Invalid examples:', + ' feature/auth (use "feat" not "feature")', + ' fix_bug (use "/" not "_")', + ' MyFeature (no type prefix)', + ' feat/Fix Bug (use kebab-case)', + '', + `💡 Suggested: git checkout -b ${suggested}`, + '', + '═'.repeat(70), + '', + ].join('\n'); +} + +/** + * Main hook execution + */ +async function main(): Promise { + const startTime = Date.now(); + + try { + const input = await parseStdin(); + + // Check if hook is enabled + checkHookEnabled(input.cwd, 'workflow', 'branchNameValidator'); + + // Get hook configuration + const config = getHookConfig(input.cwd, 'workflow', 'branchNameValidator'); + const allowedPrefixes = (config.allowedPrefixes as string[]) ?? [ + 'feat', + 'fix', + 'chore', + 'docs', + 'test', + 'refactor', + 'perf', + 'build', + 'ci', + 'revert', + 'style', + ]; + const allowedBranchList = (config.allowedBranches as string[]) ?? [ + 'main', + 'master', + 'develop', + ]; + const allowedBranches = new Set(allowedBranchList); + + // Only run for Bash tool + if (input.tool_name !== 'Bash') { + process.exit(0); + } + + // Extract command from tool input + const toolInput = input.tool_input!; + const command = toolInput?.command as string | undefined; + + if (!command) { + process.exit(0); + } + + // Only check git checkout -b commands + if (!command.includes('git checkout -b')) { + process.exit(0); + } + + // Extract branch name + const branchName = extractBranchName(command); + + if (!branchName) { + process.exit(0); + } + + console.error('[DEBUG] branch-name-validator: Checking branch name'); + console.error(`[DEBUG] Branch name: ${branchName}`); + console.error(`[DEBUG] Allowed prefixes: ${allowedPrefixes.join(', ')}`); + console.error( + `[DEBUG] Allowed branches: ${[...allowedBranches].join(', ')}`, + ); + + // Validate branch name + if (!isValidBranchName(branchName, allowedPrefixes, allowedBranches)) { + // Block the operation + console.error('[DEBUG] BLOCKING - invalid branch name'); + + const output = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formatBlockMessage( + branchName, + allowedPrefixes, + ), + }, + }; + + console.log(JSON.stringify(output)); + checkPerformance(startTime, 50, 'branch-name-validator'); + process.exit(0); + } + + // Allow the operation - valid branch name + console.error('[DEBUG] ALLOWING - valid branch name'); + checkPerformance(startTime, 50, 'branch-name-validator'); + process.exit(0); + } catch (error) { + console.error(formatError(error, 'branch-name-validator')); + // On hook error, don't block the operation + process.exit(0); + } +} + +// Execute +await main(); diff --git a/hooks/git-commit-guard.ts b/hooks/git-commit-guard.ts new file mode 100755 index 0000000..22ee3b5 --- /dev/null +++ b/hooks/git-commit-guard.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env bun + +/** + * Git Commit Guard Hook + * + * Prevents direct commits/pushes to protected branches (main). + * Enforces feature branch workflow - commits should happen on feature branches. + * Use bypass environment variable for emergencies: SKIP_COMMIT_GUARD=true + * + * Performance target: <50ms (ADR-0010) + * + * @see {@link https://github.com/jbabin91/super-claude} for documentation + */ + +import { execSync } from 'node:child_process'; + +import { checkPerformance, formatError, parseStdin } from './utils/index.js'; +import { + checkHookEnabled, + getHookConfig, +} from './utils/super-claude-config-loader.js'; + +/** + * Get current git branch + * + * @param cwd Current working directory + * @returns Current branch name or null if not in git repo + */ +function getCurrentBranch(cwd: string): string | null { + try { + const branch = execSync('git branch --show-current', { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + return branch || null; + } catch { + return null; + } +} + +/** + * Detect if Bash command is a git commit or push + * + * @param command Bash command string + * @returns true if commit/push command + */ +function isGitCommitOrPush(command: string): boolean { + const patterns = [ + /\bgit\s+commit\b/i, + /\bgit\s+push\b/i, + /\bgit\s+ci\b/i, // Common commit alias + ]; + + return patterns.some((pattern) => pattern.test(command)); +} + +/** + * Check if command targets a protected branch + * + * @param command Git command + * @param currentBranch Current branch name + * @param protectedBranches List of protected branches + * @returns true if targeting protected branch + */ +function targetsProtectedBranch( + command: string, + currentBranch: string | null, + protectedBranches: string[], +): boolean { + // Check if push command explicitly targets protected branch + for (const branch of protectedBranches) { + if ( + command.includes(`origin ${branch}`) || + command.includes(`origin/${branch}`) + ) { + return true; + } + } + + // Check if current branch is protected + if (currentBranch && protectedBranches.includes(currentBranch)) { + return true; + } + + return false; +} + +/** + * Format blocking message + * + * @param currentBranch Current branch name + * @param command Git command that was blocked + * @param protectedBranches List of protected branches + * @returns Formatted error message + */ +function formatBlockMessage( + currentBranch: string | null, + command: string, + protectedBranches: string[], +): string { + return [ + '', + '═'.repeat(70), + '⚠️ DIRECT COMMIT/PUSH TO PROTECTED BRANCH BLOCKED', + '═'.repeat(70), + '', + `Protected branches: ${protectedBranches.join(', ')}`, + `Current branch: ${currentBranch ?? 'unknown'}`, + `Blocked command: ${command}`, + '', + 'Feature Branch Workflow:', + '', + ' 1. Create a feature branch:', + ' git checkout -b feat/your-feature', + '', + ' 2. Make commits on your feature branch:', + ' git commit -m "feat: add new feature"', + '', + ' 3. Push feature branch:', + ' git push origin feat/your-feature', + '', + ' 4. Create pull request:', + ' gh pr create', + '', + ' 5. After approval, merge via PR', + '', + 'To bypass this guard (emergencies only):', + ' SKIP_COMMIT_GUARD=true git commit -m "..."', + ' SKIP_COMMIT_GUARD=true git push', + '', + '═'.repeat(70), + '', + ].join('\n'); +} + +/** + * Main hook execution + */ +async function main(): Promise { + const startTime = Date.now(); + + try { + const input = await parseStdin(); + + // Check if hook is enabled and get config + checkHookEnabled(input.cwd, 'workflow', 'gitCommitGuard'); + + // Get hook configuration + const config = getHookConfig(input.cwd, 'workflow', 'gitCommitGuard'); + const protectedBranches = (config.protectedBranches as string[]) ?? [ + 'main', + 'master', + ]; + const bypassEnvVar = (config.bypassEnvVar as string) ?? 'SKIP_COMMIT_GUARD'; + + // Check for bypass flag + if (process.env[bypassEnvVar] === 'true') { + console.error(`[DEBUG] ${bypassEnvVar}=true - bypassing guard`); + checkPerformance(startTime, 50, 'git-commit-guard'); + process.exit(0); + } + + // Only run for Bash tool + if (input.tool_name !== 'Bash') { + process.exit(0); + } + + // Extract command from tool input + const toolInput = input.tool_input!; + const command = toolInput?.command as string | undefined; + + if (!command) { + process.exit(0); + } + + // Check for bypass flag in command string + if (command.includes(`${bypassEnvVar}=true`)) { + console.error( + `[DEBUG] ${bypassEnvVar}=true in command - bypassing guard`, + ); + checkPerformance(startTime, 50, 'git-commit-guard'); + process.exit(0); + } + + // Only check git commit/push commands + if (!isGitCommitOrPush(command)) { + process.exit(0); + } + + // Get current branch + const currentBranch = getCurrentBranch(input.cwd); + + console.error('[DEBUG] git-commit-guard: Git operation detected'); + console.error(`[DEBUG] Current branch: ${currentBranch}`); + console.error(`[DEBUG] Command: ${command}`); + console.error( + `[DEBUG] Protected branches: ${protectedBranches.join(', ')}`, + ); + + // Check if targeting protected branch + if (targetsProtectedBranch(command, currentBranch, protectedBranches)) { + // Block the operation + console.error('[DEBUG] BLOCKING - targets protected branch'); + + const output = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formatBlockMessage( + currentBranch, + command, + protectedBranches, + ), + }, + }; + + console.log(JSON.stringify(output)); + checkPerformance(startTime, 50, 'git-commit-guard'); + process.exit(0); + } + + // Allow the operation - not targeting protected branch + console.error('[DEBUG] ALLOWING - not targeting protected branch'); + checkPerformance(startTime, 50, 'git-commit-guard'); + process.exit(0); + } catch (error) { + console.error(formatError(error, 'git-commit-guard')); + // On hook error, don't block the operation + process.exit(0); + } +} + +// Execute +await main(); diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..ca1eae1 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,49 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-checklist.ts" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/type-checker.ts" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/git-commit-guard.ts" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/branch-name-validator.ts" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/skill-activation-prompt.ts" + } + ] + } + ] + }, + "description": "Workflow plugin hooks for session checklists, type checking, commit guards, branch name validation, and skill auto-activation" +} diff --git a/hooks/session-checklist.ts b/hooks/session-checklist.ts new file mode 100755 index 0000000..24c7466 --- /dev/null +++ b/hooks/session-checklist.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env bun + +/** + * Session Checklist Hook + * + * Displays quick project status at session start: + * - Git status (branch, staged files, recent commits) + * - Active OpenSpec changes + * - Quick command reference + * + * Performance target: <100ms (ADR-0010) + * + * @see {@link https://github.com/jbabin91/super-claude} for documentation + */ + +import { execSync } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; + +import { checkPerformance, formatError, parseStdin } from './utils/index.js'; + +/** + * Get git status information + * + * @param cwd Current working directory + * @returns Git status summary or null if not a git repo + */ +function getGitStatus(cwd: string): { + branch: string; + staged: number; + unstaged: number; + untracked: number; +} | null { + try { + // Get branch (also validates git repo) + const branch = execSync('git branch --show-current', { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + // Get status counts + const status = execSync('git status --porcelain', { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let staged = 0; + let unstaged = 0; + let untracked = 0; + + for (const line of status.split('\n')) { + if (!line) continue; + const x = line[0]; + const y = line[1]; + + if (x !== ' ' && x !== '?') staged++; + if (y !== ' ' && y !== '?') unstaged++; + if (x === '?' && y === '?') untracked++; + } + + return { branch, staged, unstaged, untracked }; + } catch { + return null; // Not a git repo or git error + } +} + +/** + * Get recent commits + * + * @param cwd Current working directory + * @param count Number of commits to fetch + * @returns Array of commit summaries + */ +function getRecentCommits(cwd: string, count = 3): string[] { + try { + const log = execSync( + `git log -n ${count} --pretty=format:"%h %s" --no-decorate`, + { cwd, encoding: 'utf8' }, + ); + return log.split('\n').filter((line) => line.trim()); + } catch { + return []; + } +} + +/** + * Get active OpenSpec changes + * + * @param cwd Current working directory + * @returns Array of change names + */ +function getActiveChanges(cwd: string): string[] { + const changesDir = path.join(cwd, 'openspec', 'changes'); + + if (!existsSync(changesDir)) { + return []; + } + + try { + const entries = readdirSync(changesDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory() && entry.name !== 'archive') + .map((entry) => entry.name); + } catch { + return []; + } +} + +/** + * Format checklist output + * + * @param cwd Current working directory + * @returns Formatted checklist string + */ +function formatChecklist(cwd: string): string { + const git = getGitStatus(cwd); + const commits = getRecentCommits(cwd); + const changes = getActiveChanges(cwd); + + const lines = ['═'.repeat(70), '📋 SESSION CHECKLIST', '═'.repeat(70), '']; + + // Git status + if (git) { + lines.push('Git Status:', ` Branch: ${git.branch}`); + + const status = []; + if (git.staged > 0) status.push(`${git.staged} staged`); + if (git.unstaged > 0) status.push(`${git.unstaged} modified`); + if (git.untracked > 0) status.push(`${git.untracked} untracked`); + + if (status.length > 0) { + lines.push(` Status: ${status.join(', ')}`); + } else { + lines.push(' Status: Clean working directory'); + } + + // Recent commits + if (commits.length > 0) { + lines.push('', 'Recent Commits:'); + for (const commit of commits) { + lines.push(` ${commit}`); + } + } + } else { + lines.push('Git: Not a git repository'); + } + + lines.push(''); + + // OpenSpec changes + if (changes.length > 0) { + lines.push('Active Changes:'); + for (const change of changes) { + lines.push(` • ${change}`); + } + } else { + lines.push('OpenSpec: No active changes'); + } + + // Quick reference + lines.push( + '', + '─'.repeat(70), + 'Quick Commands:', + ' openspec list # List active changes', + ' openspec show # View change details', + ' git status # Detailed git status', + ' bun run format # Format code', + ' bun run lint # Lint code', + '═'.repeat(70), + ); + + return lines.join('\n'); +} + +/** + * Main hook execution + */ +async function main(): Promise { + const startTime = Date.now(); + + try { + const input = await parseStdin(); + + // Output checklist + const checklist = formatChecklist(input.cwd); + console.log(checklist); + + // Performance check + checkPerformance(startTime, 100, 'session-checklist'); + + process.exit(0); + } catch (error) { + console.error(formatError(error, 'session-checklist')); + // Don't fail session start - exit cleanly + process.exit(0); + } +} + +// Execute +await main(); diff --git a/hooks/session-start.ts b/hooks/session-start.ts new file mode 100755 index 0000000..54701dc --- /dev/null +++ b/hooks/session-start.ts @@ -0,0 +1,326 @@ +#!/usr/bin/env bun + +/** + * SessionStart Hook for OpenSpec Workflow + * + * This hook automatically loads context from the active OpenSpec change when + * a new Claude Code session starts or resumes. It reads .openspec/active.json + * to find the current change, then loads the proposal, design, and tasks to + * restore context seamlessly. + * + * @see {@link https://github.com/jbabin91/super-claude} for documentation + * + * Runtime: Bun (native TypeScript support) + * Execution: Triggered on session start/resume + * Performance Target: <100ms + */ + +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Active change tracker schema + */ +type ActiveChange = { + change: string; // Change ID (folder name) + started: string; // ISO 8601 timestamp + lastCheckpoint: string; // ISO 8601 timestamp +}; + +/** + * Hook input from Claude Code (via stdin) + */ +type HookInput = { + cwd: string; // Current working directory + [key: string]: unknown; // Other potential fields +}; + +/** + * Parse hook input from stdin. + * + * @returns Parsed HookInput object + * @throws Error if stdin is invalid + */ +async function parseStdin(): Promise { + const stdin = await Bun.stdin.text(); + + if (!stdin || stdin.trim() === '') { + throw new Error('No input received from stdin'); + } + + try { + const input = JSON.parse(stdin) as HookInput; + + if (!input.cwd || typeof input.cwd !== 'string') { + throw new Error('Invalid input: missing or invalid cwd field'); + } + + return input; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('Invalid JSON from stdin: ' + error.message); + } + throw error; + } +} + +/** + * Load active change from openspec/active.json + * + * @param cwd Current working directory + * @returns ActiveChange object or null if not found + */ +function loadActiveChange(cwd: string): ActiveChange | null { + const activePath = path.resolve(cwd, 'openspec/active.json'); + + if (!existsSync(activePath)) { + return null; + } + + try { + const content = readFileSync(activePath, 'utf8'); + const active = JSON.parse(content) as ActiveChange; + + // Validate required fields + if (!active.change || typeof active.change !== 'string') { + console.warn('[WARNING] Invalid active.json: missing change field'); + return null; + } + + return active; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn('[WARNING] Failed to load active.json: ' + msg); + return null; + } +} + +/** + * Load file content safely with fallback + * + * @param path File path + * @param fallback Fallback message if file not found + * @returns File content or fallback + */ +function loadFileContent(path: string, fallback: string): string { + if (!existsSync(path)) { + return fallback; + } + + try { + return readFileSync(path, 'utf8'); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn('[WARNING] Failed to read ' + path + ': ' + msg); + return fallback; + } +} + +/** + * Count completed and total tasks from tasks.md + * + * @param tasksContent Content of tasks.md + * @returns Object with total and completed counts + */ +function countTasks(tasksContent: string): { + total: number; + completed: number; +} { + const lines = tasksContent.split('\n'); + let total = 0; + let completed = 0; + + for (const line of lines) { + if (line.trim().startsWith('- [x]') || line.trim().startsWith('- [X]')) { + completed++; + total++; + } else if (line.trim().startsWith('- [ ]')) { + total++; + } + } + + return { total, completed }; +} + +/** + * Format time elapsed since timestamp + * + * @param timestamp ISO 8601 timestamp + * @returns Human-readable time elapsed + */ +function formatTimeElapsed(timestamp: string): string { + const now = new Date(); + const then = new Date(timestamp); + const diffMs = now.getTime() - then.getTime(); + + const minutes = Math.floor(diffMs / 1000 / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return days + (days === 1 ? ' day ago' : ' days ago'); + if (hours > 0) return hours + (hours === 1 ? ' hour ago' : ' hours ago'); + if (minutes > 0) + return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago'); + return 'just now'; +} + +/** + * Get last N lines from content + * + * @param content File content + * @param n Number of lines + * @returns Last N lines + */ +function getLastLines(content: string, n: number): string { + const lines = content.trim().split('\n'); + const lastN = lines.slice(-n); + return lastN.join('\n'); +} + +/** + * Format context output for Claude + * + * @param active Active change data + * @param cwd Current working directory + * @returns Formatted context string + */ +function formatContext(active: ActiveChange, cwd: string): string { + const changeDir = path.resolve(cwd, 'openspec/changes', active.change); + + // Check if change directory exists + if (!existsSync(changeDir)) { + return ( + '⚠️ Active change "' + + active.change + + '" not found.\n' + + 'It may have been archived. Use /openspec:work to select a new change.' + ); + } + + // Load context files + const proposalPath = path.join(changeDir, 'proposal.md'); + const designPath = path.join(changeDir, 'design.md'); + const tasksPath = path.join(changeDir, 'tasks.md'); + + const proposal = loadFileContent( + proposalPath, + '(No proposal.md - create one with /openspec:proposal)', + ); + const design = loadFileContent( + designPath, + '(No design.md - create one to track your approach)', + ); + const tasks = loadFileContent(tasksPath, '(No tasks.md - no tasks defined)'); + + // Count task progress + const taskCounts = countTasks(tasks); + const percentage = + taskCounts.total > 0 + ? Math.round((taskCounts.completed / taskCounts.total) * 100) + : 0; + + // Format timestamps + const startedAgo = formatTimeElapsed(active.started); + const checkpointAgo = active.lastCheckpoint + ? formatTimeElapsed(active.lastCheckpoint) + : 'never'; + + // Build output + const recentContext = getLastLines(design, 15); + + const lines = [ + '='.repeat(70), + '📋 RESUMING OPENSPEC WORK', + '='.repeat(70), + '', + 'Active Change: ' + active.change, + 'Started: ' + startedAgo, + 'Last Checkpoint: ' + checkpointAgo, + '', + 'Progress:', + ' Tasks: ' + + taskCounts.completed + + '/' + + taskCounts.total + + ' (' + + percentage + + '%)', + '', + '-'.repeat(70), + 'PROPOSAL (WHY)', + '-'.repeat(70), + proposal, + '', + '-'.repeat(70), + 'DESIGN (HOW - Living Doc)', + '-'.repeat(70), + design, + '', + '-'.repeat(70), + 'RECENT CHECKPOINT NOTES', + '-'.repeat(70), + recentContext, + '', + '-'.repeat(70), + 'TASKS (WHAT)', + '-'.repeat(70), + tasks, + '', + '='.repeat(70), + 'COMMANDS:', + ' /openspec:status - Check progress', + ' /openspec:checkpoint - Save progress', + ' /openspec:work - Switch changes', + ' /openspec:done - Complete and archive', + '='.repeat(70), + ]; + + return lines.join('\n'); +} + +/** + * Main hook execution + * + * Workflow: + * 1. Parse stdin + * 2. Load active change + * 3. If active, load context files + * 4. Format and output context + * 5. Exit cleanly + */ +async function main(): Promise { + const startTime = Date.now(); + + try { + // 1. Parse input + const input = await parseStdin(); + + // 2. Load active change + const active = loadActiveChange(input.cwd); + + // If no active change, exit silently (no output) + if (!active) { + process.exit(0); + } + + // 3. Format and output context + const context = formatContext(active, input.cwd); + console.log(context); + + // Performance monitoring + const duration = Date.now() - startTime; + if (duration > 100) { + console.warn('[WARNING] Slow SessionStart hook: ' + duration + 'ms'); + } + + process.exit(0); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[ERROR] SessionStart hook error: ' + msg); + // Don't fail the session start - just exit silently + process.exit(0); + } +} + +// Execute +await main(); diff --git a/hooks/skill-activation-prompt.ts b/hooks/skill-activation-prompt.ts new file mode 100755 index 0000000..ad0509f --- /dev/null +++ b/hooks/skill-activation-prompt.ts @@ -0,0 +1,518 @@ +#!/usr/bin/env bun + +/** + * Skill Auto-Activation Hook for Claude Code + * + * This UserPromptSubmit hook analyzes user prompts and suggests relevant skills + * before Claude responds. It discovers skill-rules.json files across installed + * plugins, merges them with project overrides, and matches against keywords + * and intent patterns. + * + * @see {@link https://github.com/jbabin91/super-claude} for documentation + * + * Runtime: Bun (native TypeScript support) + * Execution: Triggered on every user prompt submission + * Performance Target: <50ms for typical projects (<10 plugins) + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { + HookInput, + MatchedSkill, + PluginSkillRules, + Priority, + ProjectSkillRules, + SkillConfig, +} from '../types/skill-rules.d.ts'; + +/** + * Check if Bun runtime is available. + * This function is mainly for documentation - if we're executing, Bun is available. + */ +function checkBunRuntime(): void { + // If this script is running, Bun is available (shebang ensures it) + // This check is here for clarity and future enhancement + if (typeof Bun === 'undefined') { + console.error('[WARNING] Bun required for skill activation'); + console.error('Install: https://bun.sh'); + process.exit(1); + } +} + +/** + * Parse hook input from stdin. + * + * Claude Code passes hook context as JSON via stdin. + * + * @returns Parsed HookInput object + * @throws Error if stdin is empty or invalid JSON + */ +async function parseStdin(): Promise { + const stdin = await Bun.stdin.text(); + + if (!stdin || stdin.trim() === '') { + throw new Error('No input received from stdin'); + } + + try { + const input = JSON.parse(stdin) as HookInput; + + // Validate required fields + if (!input.prompt || typeof input.prompt !== 'string') { + throw new Error('Invalid input: missing or invalid prompt field'); + } + + if (!input.cwd || typeof input.cwd !== 'string') { + throw new Error('Invalid input: missing or invalid cwd field'); + } + + return input; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('Invalid JSON from stdin: ' + error.message); + } + throw error; + } +} + +/** + * Discover all skill-rules.json files from installed plugins. + * + * Scans ".claude/skills/star/skill-rules.json" for plugin-level rules. + * Handles missing files and invalid JSON gracefully. + * + * @param cwd Current working directory + * @returns Array of validated PluginSkillRules + */ +function discoverPluginRules(cwd: string): PluginSkillRules[] { + const skillsDir = path.resolve(cwd, '.claude/skills'); + const pluginRules: PluginSkillRules[] = []; + + if (!existsSync(skillsDir)) { + return pluginRules; + } + + try { + const entries = readdirSync(skillsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const rulesPath = path.join(skillsDir, entry.name, 'skill-rules.json'); + + if (!existsSync(rulesPath)) continue; + + try { + const content = readFileSync(rulesPath, 'utf8'); + const rules = JSON.parse(content) as PluginSkillRules; + + // Validate required fields + if (!rules.plugin?.name || !rules.plugin?.namespace || !rules.skills) { + console.warn( + '[WARNING] Invalid skill-rules.json in ' + + entry.name + + ': missing required fields', + ); + continue; + } + + pluginRules.push(rules); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn( + '[WARNING] Failed to load skill-rules.json from ' + + entry.name + + ': ' + + msg, + ); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn('[WARNING] Failed to read skills directory: ' + msg); + } + + return pluginRules; +} + +/** + * Load project-level overrides from .claude/skills/skill-rules.json + * + * @param cwd Current working directory + * @returns ProjectSkillRules or null if not found/invalid + */ +function loadProjectOverrides(cwd: string): ProjectSkillRules | null { + const overridesPath = path.resolve(cwd, '.claude/skills/skill-rules.json'); + + if (!existsSync(overridesPath)) { + return null; + } + + try { + const content = readFileSync(overridesPath, 'utf8'); + const overrides = JSON.parse(content) as ProjectSkillRules; + + // Validate schema + if (!overrides.version || typeof overrides.version !== 'string') { + console.warn( + '[WARNING] Invalid project overrides: missing version field', + ); + return null; + } + + // Ensure required fields exist (with defaults) + return { + version: overrides.version, + overrides: overrides.overrides || {}, + disabled: overrides.disabled || [], + global: overrides.global, + }; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn('[WARNING] Failed to load project overrides: ' + msg); + return null; + } +} + +/** + * Merge plugin rules with project overrides. + * + * Precedence: Project overrides > Plugin defaults + * + * Strategy (MVP): + * - Shallow merge for skill configs + * - Apply disabled list + * - Namespace all skill keys as "namespace/skill-name" + * + * @param pluginRules Array of plugin-level rules + * @param projectOverrides Project-level overrides (optional) + * @returns Map of skill ID to merged SkillConfig + */ +function mergeRules( + pluginRules: PluginSkillRules[], + projectOverrides: ProjectSkillRules | null, +): Map { + const merged = new Map(); + + // 1. Load all plugin rules with namespaced keys + for (const plugin of pluginRules) { + const namespace = plugin.plugin.namespace; + + for (const [skillName, config] of Object.entries(plugin.skills)) { + const skillId = namespace + '/' + skillName; + merged.set(skillId, config); + } + } + + // 2. Apply project overrides (shallow merge) + if (projectOverrides) { + for (const [skillId, override] of Object.entries( + projectOverrides.overrides, + )) { + if (!merged.has(skillId)) { + console.warn( + '[WARNING] Override for unknown skill: ' + + skillId + + ' (skill not found in plugins)', + ); + continue; + } + + // Shallow merge: spread operator replaces entire nested objects + const baseConfig = merged.get(skillId)!; + merged.set(skillId, { ...baseConfig, ...override }); + } + + // 3. Remove disabled skills + for (const skillId of projectOverrides.disabled) { + merged.delete(skillId); + } + } + + return merged; +} + +/** + * Match prompt against skill keywords (case-insensitive literal). + * + * @param prompt User's prompt text + * @param keywords Array of keywords to match + * @returns Matched keyword or null + */ +function matchKeywords( + prompt: string, + keywords: string[] | undefined, +): string | null { + if (!keywords || keywords.length === 0) return null; + + const normalizedPrompt = prompt.toLowerCase(); + + for (const keyword of keywords) { + if (normalizedPrompt.includes(keyword.toLowerCase())) { + return keyword; + } + } + + return null; +} + +/** + * Match prompt against intent patterns (regex with case-insensitive flag). + * + * @param prompt User's prompt text + * @param patterns Array of regex patterns + * @returns Matched pattern or null + */ +function matchIntentPatterns( + prompt: string, + patterns: string[] | undefined, +): string | null { + if (!patterns || patterns.length === 0) return null; + + for (const pattern of patterns) { + try { + const regex = new RegExp(pattern, 'i'); + if (regex.test(prompt)) { + return pattern; + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn( + '[WARNING] Invalid regex pattern: ' + pattern + ' (' + msg + ')', + ); + } + } + + return null; +} + +/** + * Find all skills that match the user's prompt. + * + * Uses both keyword and intent pattern matching. + * Filters by priority threshold and disabled list. + * + * @param prompt User's prompt text + * @param skills Map of skill ID to config + * @param globalConfig Global configuration (optional) + * @returns Array of matched skills + */ +function matchSkills( + prompt: string, + skills: Map, + globalConfig: ProjectSkillRules['global'], +): MatchedSkill[] { + const matches: MatchedSkill[] = []; + + for (const [skillId, config] of skills.entries()) { + const [namespace, name] = skillId.split('/'); + + // Try keyword matching + const keywordMatch = matchKeywords(prompt, config.promptTriggers.keywords); + if (keywordMatch) { + matches.push({ + id: skillId, + name, + namespace, + config, + matchType: 'keyword', + matchedBy: keywordMatch, + }); + continue; + } + + // Try intent pattern matching + const patternMatch = matchIntentPatterns( + prompt, + config.promptTriggers.intentPatterns, + ); + if (patternMatch) { + matches.push({ + id: skillId, + name, + namespace, + config, + matchType: 'intent', + matchedBy: patternMatch, + }); + } + } + + // Apply priority threshold filter + let filtered = matches; + if (globalConfig?.priorityThreshold) { + const priorityOrder: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + }; + const threshold = priorityOrder[globalConfig.priorityThreshold]; + + filtered = matches.filter( + (match) => priorityOrder[match.config.priority] <= threshold, + ); + } + + // Sort by priority (critical > high > medium > low) + const priorityOrder: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + }; + + filtered.sort( + (a, b) => + priorityOrder[a.config.priority] - priorityOrder[b.config.priority], + ); + + // Apply maxSkillsPerPrompt limit + if (globalConfig?.maxSkillsPerPrompt) { + filtered = filtered.slice(0, globalConfig.maxSkillsPerPrompt); + } + + return filtered; +} + +/** + * Format matched skills for output. + * + * Groups skills by priority and formats with box drawing and emojis. + * + * @param matches Array of matched skills + * @returns Formatted string for stdout + */ +function formatOutput(matches: MatchedSkill[]): string { + if (matches.length === 0) { + return ''; // No output when no matches + } + + // Group by priority + const byPriority: Record = { + critical: [], + high: [], + medium: [], + low: [], + }; + + for (const match of matches) { + byPriority[match.config.priority].push(match); + } + + // Build sections conditionally + const criticalSection = + byPriority.critical.length > 0 + ? [ + '[CRITICAL] REQUIRED SKILLS:', + ...byPriority.critical.map((match) => ' -> ' + match.name), + '', + ] + : []; + + const highSection = + byPriority.high.length > 0 + ? [ + '[RECOMMENDED] SKILLS:', + ...byPriority.high.map((match) => ' -> ' + match.name), + '', + ] + : []; + + const mediumSection = + byPriority.medium.length > 0 + ? [ + '[OPTIONAL] SKILLS:', + ...byPriority.medium.map((match) => ' -> ' + match.name), + '', + ] + : []; + + const lowSection = + byPriority.low.length > 0 + ? [ + '[SUGGESTED] SKILLS:', + ...byPriority.low.map((match) => ' -> ' + match.name), + '', + ] + : []; + + const lines = [ + '='.repeat(60), + 'SKILL ACTIVATION CHECK', + '='.repeat(60), + '', + ...criticalSection, + ...highSection, + ...mediumSection, + ...lowSection, + 'ACTION: Use Skill tool BEFORE responding', + '='.repeat(60), + ]; + + return lines.join('\n'); +} + +/** + * Main hook execution. + * + * Workflow: + * 1. Check Bun runtime + * 2. Parse stdin + * 3. Discover plugin rules + * 4. Load project overrides + * 5. Merge rules + * 6. Match prompt + * 7. Format output + * 8. Exit cleanly + */ +async function main(): Promise { + const startTime = Date.now(); + + try { + // 1. Check runtime + checkBunRuntime(); + + // 2. Parse input + const input = await parseStdin(); + + // 3. Discover plugin rules + const pluginRules = discoverPluginRules(input.cwd); + + // 4. Load project overrides + const projectOverrides = loadProjectOverrides(input.cwd); + + // 5. Merge rules + const mergedSkills = mergeRules(pluginRules, projectOverrides); + + // 6. Match skills + const matches = matchSkills( + input.prompt, + mergedSkills, + projectOverrides?.global, + ); + + // 7. Format and output + const output = formatOutput(matches); + if (output) { + console.log(output); + } + + // Performance monitoring + const duration = Date.now() - startTime; + if (duration > 50) { + console.warn('[WARNING] Slow hook execution: ' + duration + 'ms'); + } + + process.exit(0); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[ERROR] Hook error: ' + msg); + process.exit(1); + } +} + +// Execute +await main(); diff --git a/hooks/type-checker.ts b/hooks/type-checker.ts new file mode 100755 index 0000000..ef50303 --- /dev/null +++ b/hooks/type-checker.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env bun + +/** + * Type Checker Hook + * + * Pre-validates TypeScript types before Edit/Write operations. + * Uses @jbabin91/tsc-files programmatic API for incremental type checking with tsgo support. + * Informs about type errors but allows file modifications (informative only). + * + * Smart behavior: + * - Skips non-TypeScript files (no performance impact) + * - Skips projects without tsconfig.json + * - Dynamically imports type checker only when needed + * + * Performance: ~100-200ms with tsgo (10x faster than tsc) + * Performance target: <2s (ADR-0010) - typically well under this + * Hook behavior: Informative (does not block execution) + * + * @see {@link https://github.com/jbabin91/super-claude} for documentation + */ + +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import type { CheckResult } from '@jbabin91/tsc-files'; + +import { + checkHookEnabled, + checkPerformance, + ensureBunInstalled, + formatError, + parseStdin, +} from './utils/index.js'; + +/** + * Check if file is TypeScript + * + * @param filePath File path to check + * @returns true if TypeScript file + */ +function isTypeScriptFile(filePath: string): boolean { + return /\.(ts|tsx)$/.test(filePath); +} + +/** + * Check if tsconfig.json exists + * + * @param cwd Current working directory + * @returns true if tsconfig.json found + */ +function hasTsConfig(cwd: string): boolean { + return ( + existsSync(path.join(cwd, 'tsconfig.json')) || + existsSync(path.join(cwd, 'tsconfig.base.json')) + ); +} + +/** + * Run TypeScript type checking on file using programmatic API + * + * @param cwd Current working directory + * @param filePath File path to check + * @returns CheckResult with structured error data + */ +async function checkTypes(cwd: string, filePath: string): Promise { + try { + // Dynamically import checkFiles only when needed + // This prevents loading TypeScript dependencies for non-TS files + const module = await import('@jbabin91/tsc-files'); + + // Defensive check: ensure checkFiles exists after import + if (!module.checkFiles || typeof module.checkFiles !== 'function') { + throw new Error( + 'Type checker module loaded but checkFiles function not found. ' + + 'This may indicate a version mismatch. ' + + 'Try: bun install @jbabin91/tsc-files@latest', + ); + } + + const { checkFiles } = module; + + // Use programmatic API for structured error data + // Automatically uses tsgo if available (10x faster) + const result = await checkFiles([filePath], { + cwd, + skipLibCheck: true, + verbose: false, + throwOnError: false, + }); + + return result; + } catch (error: unknown) { + // Handle different error scenarios with helpful messages + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if this is a module not found error + const isModuleNotFound = + errorMessage.includes('Cannot find package') || + errorMessage.includes('Cannot find module') || + errorMessage.includes('@jbabin91/tsc-files'); + + let helpfulMessage = errorMessage; + if (isModuleNotFound) { + helpfulMessage = + 'Type checker dependency not installed.\n\n' + + 'To enable type checking, install the required package:\n' + + ' bun install @jbabin91/tsc-files\n\n' + + 'Or disable this hook in .claude/super-claude-config.json:\n' + + ' "workflow": { "hooks": { "typeChecker": { "enabled": false } } }\n\n' + + `Original error: ${errorMessage}`; + } + + // Return a failed result with helpful error message + return { + success: false, + errorCount: 1, + warningCount: 0, + errors: [ + { + file: filePath, + line: 0, + column: 0, + message: helpfulMessage, + code: isModuleNotFound ? 'HOOK_ERROR' : 'TS0000', + severity: 'error', + }, + ], + warnings: [], + checkedFiles: [filePath], + duration: 0, + }; + } +} + +/** + * Format type errors for display with categorization + * + * @param result CheckResult from tsc-files API + * @param targetFile The file being edited + * @returns Formatted error message + */ +function formatTypeErrors(result: CheckResult, targetFile: string): string { + // Check if this is a hook error (not a type error) + const hasHookError = result.errors.some((e) => e.code === 'HOOK_ERROR'); + + if (hasHookError) { + // Format hook configuration errors differently + const hookError = result.errors.find((e) => e.code === 'HOOK_ERROR'); + return [ + '', + '═'.repeat(70), + '⚠️ TYPE CHECKER HOOK ERROR', + '═'.repeat(70), + '', + hookError?.message ?? 'Unknown hook error', + '', + '═'.repeat(70), + '', + ].join('\n'); + } + + // Categorize errors by file + const targetFileErrors = result.errors.filter((e) => e.file === targetFile); + const dependencyErrors = result.errors.filter((e) => e.file !== targetFile); + + // Header + const sections: string[] = [ + '', + '═'.repeat(70), + '⚠️ TYPE ERRORS DETECTED - ACTION REQUIRED', + '═'.repeat(70), + '', + ]; + + // Target file errors (critical) + if (targetFileErrors.length > 0) { + sections.push( + '🎯 ERRORS IN THIS FILE:', + ` File: ${targetFile}`, + ' Action: Fix these before proceeding to next task', + '', + ); + + for (const err of targetFileErrors.slice(0, 10)) { + sections.push( + ` ${err.file}:${err.line}:${err.column}`, + ` ${err.code}: ${err.message}`, + '', + ); + } + + if (targetFileErrors.length > 10) { + sections.push( + ` ... and ${targetFileErrors.length - 10} more errors in this file`, + '', + ); + } + } + + // Dependency errors (informational) + if (dependencyErrors.length > 0) { + sections.push( + '─'.repeat(70), + 'ℹ️ ERRORS IN DEPENDENCIES:', + ' These errors are in imported files', + ' Fix them separately or add to your todo list', + '', + ); + + // Group by file + const byFile = new Map(); + for (const err of dependencyErrors) { + if (!byFile.has(err.file)) { + byFile.set(err.file, []); + } + byFile.get(err.file)!.push(err); + } + + let fileCount = 0; + for (const [file, errors] of byFile.entries()) { + if (fileCount >= 5) break; + sections.push(` 📄 ${file} (${errors.length} errors)`); + fileCount++; + } + + if (byFile.size > 5) { + sections.push(` ... and ${byFile.size - 5} more files`); + } + sections.push(''); + } + + // Footer with workflow guidance + sections.push( + '─'.repeat(70), + '🤖 CLAUDE: Type errors detected.', + '', + 'Recommended workflow:', + ' 1. If working on a task: Add "Fix type errors" to your todo list', + ' 2. Complete your current task first', + ' 3. Then fix these type errors before moving to next task', + '', + 'If the type error is directly related to your current edit:', + ' → Fix it immediately as part of this change', + '', + 'User: To disable this hook, add to .claude/settings.json:', + ' { "customHooks": { "typeChecker": { "enabled": false } } }', + '═'.repeat(70), + '', + ); + + return sections.join('\n'); +} + +/** + * Main hook execution + */ +async function main(): Promise { + const startTime = Date.now(); + + try { + // Ensure Bun is installed (fail fast with helpful message) + ensureBunInstalled(); + + const input = await parseStdin(); + + // Check if hook is enabled + checkHookEnabled(input.cwd, 'typeChecker'); + + // Only run for Edit and Write tools + if (input.tool_name !== 'Edit' && input.tool_name !== 'Write') { + process.exit(0); // Not a file modification tool + } + + // Extract file path from tool input + const toolInput = input.tool_input!; + const filePath = toolInput?.file_path as string | undefined; + + if (!filePath) { + process.exit(0); // No file path + } + + // Skip if not TypeScript file + if (!isTypeScriptFile(filePath)) { + process.exit(0); + } + + // Skip if no tsconfig + if (!hasTsConfig(input.cwd)) { + process.exit(0); + } + + // Check types using programmatic API + const result = await checkTypes(input.cwd, filePath); + + if (!result.success) { + // Type errors found - inform but allow operation + const errorMessage = formatTypeErrors(result, filePath); + + // Output hookSpecificOutput to inform (not block) + const output = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', // Informative only + permissionDecisionReason: errorMessage, + }, + }; + + console.log(JSON.stringify(output)); + checkPerformance(startTime, 2000, 'type-checker'); + process.exit(0); // Exit 0 after informing + } + + // Types are valid - allow operation silently + checkPerformance(startTime, 2000, 'type-checker'); + process.exit(0); + } catch (error) { + console.error(formatError(error, 'type-checker')); + // On hook error, don't block the operation + process.exit(0); + } +} + +// Execute +await main(); diff --git a/hooks/utils/config-loader.ts b/hooks/utils/config-loader.ts new file mode 100644 index 0000000..c387d95 --- /dev/null +++ b/hooks/utils/config-loader.ts @@ -0,0 +1,190 @@ +/** + * Hook Configuration Loader + * + * Loads hook configuration from multiple sources with priority: + * 1. Environment variables (CLAUDE_HOOK_{NAME}_ENABLED) - session override + * 2. .claude/settings.local.json (gitignored, personal overrides) + * 3. .claude/settings.json (project, committed) + * 4. .claude/super-claude-config.json (unified plugin config) + * 5. ~/.claude/settings.json (global user defaults) + * 6. Plugin defaults (enabled: true) + * + * Supports both native Claude Code settings (customHooks) and unified plugin config (workflow.hooks). + * + * @see {@link https://docs.claude.com/en/docs/claude-code/settings} for settings hierarchy + */ + +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Hook configuration schema + */ +export type HookConfig = { + enabled: boolean; + [key: string]: unknown; // Allow hook-specific config +}; + +/** + * Settings file schema (partial - only customHooks) + */ +type SettingsFile = { + customHooks?: Record; + [key: string]: unknown; +}; + +/** + * Unified plugin config schema (partial - only workflow.hooks) + */ +type UnifiedConfig = { + workflow?: { + hooks?: Record; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +/** + * Load settings file safely + * + * @param filePath Path to settings.json file + * @returns Parsed settings or null if not found/invalid + */ +function loadSettingsFile(filePath: string): SettingsFile | null { + if (!existsSync(filePath)) { + return null; + } + + try { + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content) as SettingsFile; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`); + return null; + } +} + +/** + * Load unified plugin config file safely + * + * @param filePath Path to super-claude-config.json file + * @returns Parsed config or null if not found/invalid + */ +function loadUnifiedConfig(filePath: string): UnifiedConfig | null { + if (!existsSync(filePath)) { + return null; + } + + try { + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content) as UnifiedConfig; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`); + return null; + } +} + +/** + * Load hook configuration from settings hierarchy + * + * Configuration priority (highest to lowest): + * 1. Environment variables (CLAUDE_HOOK_{HOOK_NAME}_ENABLED) + * 2. Local overrides (.claude/settings.local.json) + * 3. Project settings (.claude/settings.json) + * 4. Unified plugin config (.claude/super-claude-config.json) + * 5. Global settings (~/.claude/settings.json) + * 6. Default (enabled: true) + * + * @param cwd Current working directory + * @param hookName Hook name (e.g., 'gitCommitGuard') + * @returns Hook configuration + * + * @example + * ```ts + * const config = loadHookConfig(cwd, 'gitCommitGuard'); + * if (!config.enabled) { + * console.log('Hook disabled by config'); + * process.exit(0); + * } + * ``` + */ +export function loadHookConfig(cwd: string, hookName: string): HookConfig { + // Load all config files + const localPath = path.join(cwd, '.claude', 'settings.local.json'); + const projectPath = path.join(cwd, '.claude', 'settings.json'); + const unifiedPath = path.join(cwd, '.claude', 'super-claude-config.json'); + const globalPath = path.join( + process.env.HOME ?? process.env.USERPROFILE ?? '~', + '.claude', + 'settings.json', + ); + + const local = loadSettingsFile(localPath); + const project = loadSettingsFile(projectPath); + const unified = loadUnifiedConfig(unifiedPath); + const global = loadSettingsFile(globalPath); + + // Check environment variable override + const envKey = `CLAUDE_HOOK_${hookName.toUpperCase()}_ENABLED`; + const envEnabled = process.env[envKey]; + + // Merge config in reverse priority order (lowest to highest) + const config: HookConfig = { enabled: true }; + + // 1. Start with global settings + if (global?.customHooks?.[hookName]) { + Object.assign(config, global.customHooks[hookName]); + } + + // 2. Override with unified plugin config + if (unified?.workflow?.hooks?.[hookName]) { + Object.assign(config, unified.workflow.hooks[hookName]); + } + + // 3. Override with project settings + if (project?.customHooks?.[hookName]) { + Object.assign(config, project.customHooks[hookName]); + } + + // 4. Override with local settings + if (local?.customHooks?.[hookName]) { + Object.assign(config, local.customHooks[hookName]); + } + + // 5. Override with environment variable (highest priority) + if (envEnabled !== undefined) { + config.enabled = envEnabled === 'true' || envEnabled === '1'; + } + + return config; +} + +/** + * Check if hook is enabled + * + * Convenience function that exits cleanly if hook is disabled. + * Call this at the start of your hook to respect user configuration. + * + * @param cwd Current working directory + * @param hookName Hook name + * @returns true if enabled, never returns if disabled (exits process) + * + * @example + * ```ts + * const input = await parseStdin(); + * checkHookEnabled(input.cwd, 'gitCommitGuard'); // Exits if disabled + * // Continue with hook logic... + * ``` + */ +export function checkHookEnabled(cwd: string, hookName: string): boolean { + const config = loadHookConfig(cwd, hookName); + + if (!config.enabled) { + // Exit cleanly without output (hook disabled) + process.exit(0); + } + + return true; +} diff --git a/hooks/utils/config-types.ts b/hooks/utils/config-types.ts new file mode 100644 index 0000000..d0463bd --- /dev/null +++ b/hooks/utils/config-types.ts @@ -0,0 +1,117 @@ +/** + * Type definitions for super-claude-config.json + * + * Supports both plugin-level defaults and project-level overrides + * with deep merge behavior. + */ + +/** + * Skill auto-activation triggers + */ +export type SkillTriggers = { + /** Literal keywords for case-insensitive matching */ + keywords?: string[]; + /** Regex patterns for intent matching */ + patterns?: string[]; +}; + +/** + * Complete skill configuration (plugin defaults) + */ +export type SkillConfig = { + /** Whether this skill is enabled */ + enabled?: boolean; + /** Auto-activation triggers */ + triggers?: SkillTriggers; + /** Additional skill-specific settings */ + [key: string]: unknown; +}; + +/** + * Partial skill configuration (project overrides) + */ +export type SkillOverride = Partial; + +/** + * Hook configuration with plugin-specific settings + */ +export type HookConfig = { + /** Whether this hook is enabled */ + enabled?: boolean; + /** Additional hook-specific settings */ + [key: string]: unknown; +}; + +/** + * Partial hook configuration (project overrides) + */ +export type HookOverride = Partial; + +/** + * Plugin-level configuration (plugins/{plugin}/super-claude-config.json) + */ +export type PluginConfig = { + /** Plugin identifier matching directory name */ + plugin: string; + /** Skill configurations keyed by skill name */ + skills?: Record; + /** Hook configurations keyed by hook name */ + hooks?: Record; +}; + +/** + * Project-level configuration (.claude/super-claude-config.json) + * + * Structure: { [pluginName]: { skills: {...}, hooks: {...} } } + */ +export type ProjectConfig = Record< + string, + { + skills?: Record; + hooks?: Record; + } +>; + +/** + * Resolved configuration after merging defaults and overrides + */ +export type ResolvedConfig = { + skills: Record; + hooks: Record; +}; + +/** + * Configuration loading options + */ +export type ConfigLoaderOptions = { + /** Current working directory */ + cwd: string; + /** Plugin name to load config for */ + pluginName: string; + /** Whether to cache loaded configuration */ + cache?: boolean; +}; + +/** + * Legacy skill-rules.json format for backwards compatibility + */ +export type LegacySkillRules = { + plugin: { + namespace: string; + name: string; + }; + skills: Record< + string, + { + name: string; + priority?: 'critical' | 'high' | 'medium' | 'low'; + promptTriggers?: { + keywords?: string[]; + intentPatterns?: string[]; + }; + } + >; + overrides?: { + disabled?: string[]; + }; +}; diff --git a/hooks/utils/config-validation.ts b/hooks/utils/config-validation.ts new file mode 100644 index 0000000..99bc43c --- /dev/null +++ b/hooks/utils/config-validation.ts @@ -0,0 +1,181 @@ +/** + * Runtime validation schemas for super-claude-config.json + * + * Uses ArkType for TypeScript-native validation with excellent error messages. + */ + +import { type } from 'arktype'; + +import type { + HookConfig, + LegacySkillRules, + PluginConfig, + ProjectConfig, + SkillConfig, +} from './config-types.js'; + +/** + * Skill configuration validation schema + */ +export const skillConfigSchema = type({ + 'enabled?': 'boolean', + 'triggers?': 'object', +}); + +/** + * Hook configuration validation schema + * + * Allows additional properties for hook-specific settings + */ +export const hookConfigSchema = type({ + 'enabled?': 'boolean', +}); + +/** + * Plugin-level configuration validation schema + */ +export const pluginConfigSchema = type({ + plugin: 'string', + 'skills?': 'object', + 'hooks?': 'object', +}); + +/** + * Legacy skill-rules.json validation schema + */ +export const legacySkillRulesSchema = type({ + plugin: { + namespace: 'string', + name: 'string', + }, + skills: 'object', + 'overrides?': { + 'disabled?': 'string[]', + }, +}); + +/** + * Validation result type + */ +export type ValidationResult = + | { success: true; data: T } + | { success: false; errors: string }; + +/** + * Validate plugin configuration + * + * @param data Configuration data to validate + * @returns Validation result with typed data or errors + */ +export function validatePluginConfig( + data: unknown, +): ValidationResult { + const result = pluginConfigSchema(data); + + if (result instanceof type.errors) { + return { + success: false, + errors: result.summary, + }; + } + + return { + success: true, + data: result as PluginConfig, + }; +} + +/** + * Validate project configuration + * + * @param data Configuration data to validate + * @returns Validation result with typed data or errors + */ +export function validateProjectConfig( + data: unknown, +): ValidationResult { + // Basic object check + if (typeof data !== 'object' || data === null) { + return { + success: false, + errors: 'Project configuration must be an object', + }; + } + + return { + success: true, + data: data as ProjectConfig, + }; +} + +/** + * Validate skill configuration + * + * @param data Skill config data to validate + * @returns Validation result with typed data or errors + */ +export function validateSkillConfig( + data: unknown, +): ValidationResult { + const result = skillConfigSchema(data); + + if (result instanceof type.errors) { + return { + success: false, + errors: result.summary, + }; + } + + return { + success: true, + data: result as SkillConfig, + }; +} + +/** + * Validate hook configuration + * + * @param data Hook config data to validate + * @returns Validation result with typed data or errors + */ +export function validateHookConfig( + data: unknown, +): ValidationResult { + const result = hookConfigSchema(data); + + if (result instanceof type.errors) { + return { + success: false, + errors: result.summary, + }; + } + + return { + success: true, + data: result as HookConfig, + }; +} + +/** + * Validate legacy skill-rules.json format + * + * @param data Legacy config data to validate + * @returns Validation result with typed data or errors + */ +export function validateLegacySkillRules( + data: unknown, +): ValidationResult { + const result = legacySkillRulesSchema(data); + + if (result instanceof type.errors) { + return { + success: false, + errors: result.summary, + }; + } + + return { + success: true, + data: result as LegacySkillRules, + }; +} diff --git a/hooks/utils/hook-input.ts b/hooks/utils/hook-input.ts new file mode 100644 index 0000000..54057d8 --- /dev/null +++ b/hooks/utils/hook-input.ts @@ -0,0 +1,94 @@ +/** + * Hook Input Parsing Utilities + * + * Shared utilities for parsing Claude Code hook input from stdin. + * All hooks receive JSON via stdin with fields like cwd, tool_name, tool_input, etc. + * + * @see {@link https://docs.claude.com/en/docs/claude-code/hooks} for hook input spec + */ + +/** + * Standard hook input schema from Claude Code + */ +export type HookInput = { + cwd: string; // Current working directory + tool_name?: string; // Tool being invoked (PreToolUse/PostToolUse) + tool_input?: Record; // Tool parameters + transcript_path?: string; // Path to conversation transcript JSON + [key: string]: unknown; // Allow additional fields +}; + +/** + * Parse hook input from stdin. + * + * Reads JSON from stdin and validates required fields. + * Throws error for invalid input to fail fast. + * + * @returns Parsed HookInput object + * @throws Error if stdin is invalid or missing required fields + * + * @example + * ```ts + * const input = await parseStdin(); + * console.log('Working directory:', input.cwd); + * ``` + */ +export async function parseStdin(): Promise { + const stdin = await Bun.stdin.text(); + + if (!stdin || stdin.trim() === '') { + throw new Error('No input received from stdin'); + } + + try { + const input = JSON.parse(stdin) as HookInput; + + if (!input.cwd || typeof input.cwd !== 'string') { + throw new Error('Invalid input: missing or invalid cwd field'); + } + + return input; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('Invalid JSON from stdin: ' + error.message); + } + throw error; + } +} + +/** + * Safe error formatting for hook output + * + * Formats error for display to user without leaking implementation details. + * + * @param error Error object or unknown + * @param prefix Optional prefix for error message + * @returns Formatted error string + */ +export function formatError(error: unknown, prefix = 'Hook error'): string { + const msg = error instanceof Error ? error.message : 'Unknown error'; + return `[ERROR] ${prefix}: ${msg}`; +} + +/** + * Performance monitoring helper + * + * Logs warning if execution exceeds target duration. + * Use to enforce ADR-0010 performance requirements (<50ms command hooks). + * + * @param startTime Start time from Date.now() + * @param targetMs Target duration in milliseconds + * @param hookName Name of hook for warning message + */ +export function checkPerformance( + startTime: number, + targetMs: number, + hookName: string, +): void { + const duration = Date.now() - startTime; + if (duration > targetMs) { + console.warn( + `[WARNING] Slow ${hookName} hook: ${duration}ms (target: ${targetMs}ms)`, + ); + } +} diff --git a/hooks/utils/index.ts b/hooks/utils/index.ts new file mode 100644 index 0000000..90204e8 --- /dev/null +++ b/hooks/utils/index.ts @@ -0,0 +1,23 @@ +/** + * Hook Utilities + * + * Shared utilities for Claude Code command hooks. + * Includes input parsing, configuration loading, error handling, and runtime checks. + */ + +export { + checkHookEnabled, + type HookConfig, + loadHookConfig, +} from './config-loader.js'; +export { + checkPerformance, + formatError, + type HookInput, + parseStdin, +} from './hook-input.js'; +export { + checkBunVersion, + ensureBunInstalled, + ensureToolsInstalled, +} from './runtime-check.js'; diff --git a/hooks/utils/runtime-check.ts b/hooks/utils/runtime-check.ts new file mode 100644 index 0000000..45938a3 --- /dev/null +++ b/hooks/utils/runtime-check.ts @@ -0,0 +1,146 @@ +/** + * Runtime Check Utilities + * + * Validates runtime requirements for hooks (e.g., Bun installation). + * Provides clear, actionable error messages when requirements aren't met. + */ + +import { execSync } from 'node:child_process'; + +/** + * Check if Bun is installed and available in PATH + * + * @throws Error with installation instructions if Bun is not found + */ +export function ensureBunInstalled(): void { + try { + // Use 'bun --version' for cross-platform compatibility (works on Windows, macOS, Linux) + execSync('bun --version', { stdio: 'pipe' }); + } catch { + const errorMessage = [ + '', + '═'.repeat(70), + '❌ BUN RUNTIME NOT FOUND', + '═'.repeat(70), + '', + 'This workflow hook requires Bun to be installed.', + '', + '📦 Install Bun:', + '', + ' macOS/Linux:', + ' curl -fsSL https://bun.sh/install | bash', + '', + ' Windows:', + ' powershell -c "irm bun.sh/install.ps1|iex"', + '', + ' Or via npm:', + ' npm install -g bun', + '', + ' Or via Homebrew (macOS):', + ' brew install oven-sh/bun/bun', + '', + '🔗 More info: https://bun.sh', + '', + '⚠️ After installing, restart your terminal and try again.', + '', + '═'.repeat(70), + '', + ].join('\n'); + + console.error(errorMessage); + process.exit(1); + } +} + +/** + * Check Bun version meets minimum requirement + * + * @param minVersion Minimum required version (e.g., "1.0.0") + * @returns true if version is sufficient, false otherwise + */ +export function checkBunVersion(minVersion: string): boolean { + try { + const version = execSync('bun --version', { + encoding: 'utf8', + stdio: 'pipe', + }).trim(); + + return compareVersions(version, minVersion) >= 0; + } catch { + return false; + } +} + +/** + * Strip pre-release and build metadata from semver string + * + * @param version Version string (e.g., "1.0.0-beta", "1.0.0+build") + * @returns Clean version (e.g., "1.0.0") + */ +function stripSemverMetadata(version: string): string { + return version.split(/[-+]/)[0] || version; +} + +/** + * Compare two semantic versions + * + * Strips pre-release and build metadata before comparison. + * Examples: "1.0.0-beta" → "1.0.0", "1.0.0+build" → "1.0.0" + * + * @param a Version string (e.g., "1.2.3", "1.2.3-beta") + * @param b Version string (e.g., "1.0.0", "1.0.0+build") + * @returns -1 if a < b, 0 if equal, 1 if a > b + */ +function compareVersions(a: string, b: string): number { + const aParts = stripSemverMetadata(a).split('.').map(Number); + const bParts = stripSemverMetadata(b).split('.').map(Number); + + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aPart = aParts[i] || 0; + const bPart = bParts[i] || 0; + + if (aPart > bPart) return 1; + if (aPart < bPart) return -1; + } + + return 0; +} + +/** + * Ensure required command-line tools are installed + * + * @param tools Array of required tools (e.g., ['git', 'tsc']) + * @throws Error with installation instructions if any tool is missing + */ +export function ensureToolsInstalled(tools: string[]): void { + const missing: string[] = []; + // Use platform-specific command: 'where' on Windows, 'which' on Unix-like systems + const checkCommand = process.platform === 'win32' ? 'where' : 'which'; + + for (const tool of tools) { + try { + execSync(`${checkCommand} ${tool}`, { stdio: 'pipe' }); + } catch { + missing.push(tool); + } + } + + if (missing.length > 0) { + const errorMessage = [ + '', + '═'.repeat(70), + '❌ MISSING REQUIRED TOOLS', + '═'.repeat(70), + '', + `The following tools are required but not found: ${missing.join(', ')}`, + '', + 'Please install the missing tools and try again.', + '', + '═'.repeat(70), + '', + ].join('\n'); + + console.error(errorMessage); + process.exit(1); + } +} diff --git a/hooks/utils/super-claude-config-loader.ts b/hooks/utils/super-claude-config-loader.ts new file mode 100644 index 0000000..a91f853 --- /dev/null +++ b/hooks/utils/super-claude-config-loader.ts @@ -0,0 +1,430 @@ +/** + * Super Claude Configuration Loader + * + * Loads unified plugin configuration from super-claude-config.json files + * with support for: + * - Plugin defaults (plugins/{plugin}/super-claude-config.json) + * - Project overrides (.claude/super-claude-config.json) + * - Legacy skill-rules.json backwards compatibility + * - Environment variable overrides (highest priority) + * - Deep merge with caching for performance (<50ms) + * + * Loading priority: env vars > project overrides > plugin defaults + * + * @see {@link https://github.com/jbabin91/super-claude} for documentation + */ + +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { + HookConfig, + LegacySkillRules, + PluginConfig, + ProjectConfig, + ResolvedConfig, + SkillConfig, +} from './config-types.js'; +import { + validateLegacySkillRules, + validatePluginConfig, + validateProjectConfig, +} from './config-validation.js'; + +/** + * Configuration cache for performance + * Key: pluginName:cwd + */ +const configCache = new Map(); + +/** + * Deep merge two objects + * + * Arrays are replaced entirely (not merged item-by-item) + * Nested objects are merged recursively + * + * @param target Target object + * @param source Source object with overrides + * @returns Merged object + */ +function deepMerge>( + target: T, + source: Partial, +): T { + const result = { ...target }; + + for (const key in source) { + const sourceValue = source[key]; + const targetValue = result[key]; + + if (sourceValue === undefined) { + continue; + } + + // Arrays: replace entirely + if (Array.isArray(sourceValue)) { + result[key] = sourceValue as T[Extract]; + continue; + } + + // Objects: merge recursively + if ( + typeof sourceValue === 'object' && + sourceValue !== null && + typeof targetValue === 'object' && + targetValue !== null && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record, + ) as T[Extract]; + continue; + } + + // Primitives: replace + result[key] = sourceValue as T[Extract]; + } + + return result; +} + +/** + * Load JSON file safely + * + * @param filePath Path to JSON file + * @returns Parsed JSON or null if not found/invalid + */ +function loadJsonFile(filePath: string): unknown { + if (!existsSync(filePath)) { + return null; + } + + try { + const content = readFileSync(filePath, 'utf8'); + return JSON.parse(content) as unknown; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[ERROR] Failed to load ${filePath}: ${msg}`); + return null; + } +} + +/** + * Find plugin root directory + * + * Searches upward from current file to find plugins directory + * + * @param cwd Current working directory + * @returns Path to plugin root or null if not found + */ +function findPluginRoot(cwd: string): string | null { + // Try to find plugins directory by searching upward + let current = cwd; + const root = path.parse(current).root; + + while (current !== root) { + const pluginsDir = path.join(current, 'plugins'); + if (existsSync(pluginsDir)) { + return current; + } + current = path.dirname(current); + } + + return null; +} + +/** + * Load plugin default configuration + * + * Tries to load from: + * 1. plugins/{plugin}/super-claude-config.json + * 2. plugins/{plugin}/skill-rules.json (legacy, with warning) + * + * @param cwd Current working directory + * @param pluginName Plugin name + * @returns Plugin configuration or empty config + */ +function loadPluginDefaults( + cwd: string, + pluginName: string, +): Partial { + const pluginRoot = findPluginRoot(cwd); + if (!pluginRoot) { + console.error( + `[WARNING] Could not find plugin root from ${cwd}, using empty defaults`, + ); + return { plugin: pluginName, skills: {}, hooks: {} }; + } + + const pluginDir = path.join(pluginRoot, 'plugins', pluginName); + + // Try new format first + const configPath = path.join(pluginDir, 'super-claude-config.json'); + const configData = loadJsonFile(configPath); + + if (configData) { + const result = validatePluginConfig(configData); + + if (!result.success) { + console.error( + `[ERROR] Invalid plugin config in ${pluginName}:\n${result.errors}`, + ); + return { plugin: pluginName, skills: {}, hooks: {} }; + } + + return result.data; + } + + // Try legacy format + const legacyPath = path.join(pluginDir, 'skill-rules.json'); + const legacyData = loadJsonFile(legacyPath); + + if (legacyData) { + console.warn( + `[DEPRECATION] ${pluginName} using deprecated skill-rules.json. Migrate to super-claude-config.json`, + ); + + const result = validateLegacySkillRules(legacyData); + + if (!result.success) { + console.error( + `[ERROR] Invalid legacy config in ${pluginName}:\n${result.errors}`, + ); + return { plugin: pluginName, skills: {}, hooks: {} }; + } + + // Convert legacy format to new format + return convertLegacyFormat(result.data, pluginName); + } + + // No config found, use empty defaults + return { plugin: pluginName, skills: {}, hooks: {} }; +} + +/** + * Convert legacy skill-rules.json to new format + * + * @param legacy Legacy skill rules + * @param pluginName Plugin name + * @returns Plugin configuration + */ +function convertLegacyFormat( + legacy: LegacySkillRules, + pluginName: string, +): Partial { + const skills: Record = {}; + + for (const [skillName, skillData] of Object.entries(legacy.skills)) { + skills[skillName] = { + enabled: !legacy.overrides?.disabled?.includes( + `${legacy.plugin.namespace}/${skillName}`, + ), + triggers: { + keywords: skillData.promptTriggers?.keywords ?? [], + patterns: skillData.promptTriggers?.intentPatterns ?? [], + }, + }; + } + + return { + plugin: pluginName, + skills, + hooks: {}, + }; +} + +/** + * Load project override configuration + * + * Loads from .claude/super-claude-config.json + * + * @param cwd Current working directory + * @returns Project configuration or null + */ +function loadProjectOverrides(cwd: string): ProjectConfig | null { + const overridePath = path.join(cwd, '.claude', 'super-claude-config.json'); + const overrideData = loadJsonFile(overridePath); + + if (!overrideData) { + return null; + } + + const result = validateProjectConfig(overrideData); + + if (!result.success) { + console.error(`[ERROR] Invalid project config:\n${result.errors}`); + return null; + } + + return result.data; +} + +/** + * Apply environment variable overrides + * + * Environment variables have highest priority. + * Format: CLAUDE_HOOK_{HOOK_NAME}_ENABLED=true|false + * + * @param config Configuration to modify + * @param pluginName Plugin name (for logging) + */ +function applyEnvironmentOverrides( + config: ResolvedConfig, + pluginName: string, +): void { + // Hook enabled overrides + for (const hookName of Object.keys(config.hooks)) { + const envKey = `CLAUDE_HOOK_${hookName.replaceAll(/[A-Z]/g, (m) => `_${m}`).toUpperCase()}`; + const envValue = process.env[envKey]; + + if (envValue !== undefined) { + config.hooks[hookName].enabled = envValue === 'true' || envValue === '1'; + console.error( + `[DEBUG] ${pluginName}:${hookName} enabled=${config.hooks[hookName].enabled} (from ${envKey})`, + ); + } + } +} + +/** + * Load and merge configuration for a plugin + * + * Merge order: plugin defaults → project overrides → env vars + * + * @param cwd Current working directory + * @param pluginName Plugin name + * @param useCache Whether to use cached config + * @returns Resolved configuration + */ +export function loadPluginConfig( + cwd: string, + pluginName: string, + useCache = true, +): ResolvedConfig { + const cacheKey = `${pluginName}:${cwd}`; + + // Check cache + if (useCache && configCache.has(cacheKey)) { + return configCache.get(cacheKey)!; + } + + // Load plugin defaults + const pluginDefaults = loadPluginDefaults(cwd, pluginName); + + // Load project overrides + const projectOverrides = loadProjectOverrides(cwd); + + // Start with plugin defaults + const resolved: ResolvedConfig = { + skills: pluginDefaults.skills ?? {}, + hooks: pluginDefaults.hooks ?? {}, + }; + + // Apply project overrides for this plugin + if (projectOverrides?.[pluginName]) { + const pluginOverrides = projectOverrides[pluginName]; + + if (pluginOverrides.skills) { + for (const [skillName, skillOverride] of Object.entries( + pluginOverrides.skills, + )) { + const defaultSkill = resolved.skills[skillName] ?? { + enabled: true, + triggers: { keywords: [], patterns: [] }, + }; + resolved.skills[skillName] = deepMerge(defaultSkill, skillOverride); + } + } + + if (pluginOverrides.hooks) { + for (const [hookName, hookOverride] of Object.entries( + pluginOverrides.hooks, + )) { + const defaultHook = resolved.hooks[hookName] ?? { enabled: true }; + resolved.hooks[hookName] = deepMerge(defaultHook, hookOverride); + } + } + } + + // Apply environment variable overrides (highest priority) + applyEnvironmentOverrides(resolved, pluginName); + + // Cache result + configCache.set(cacheKey, resolved); + + return resolved; +} + +/** + * Get hook configuration + * + * @param cwd Current working directory + * @param pluginName Plugin name + * @param hookName Hook name + * @returns Hook configuration + */ +export function getHookConfig( + cwd: string, + pluginName: string, + hookName: string, +): HookConfig { + const config = loadPluginConfig(cwd, pluginName); + return config.hooks[hookName] ?? { enabled: true }; +} + +/** + * Get skill configuration + * + * @param cwd Current working directory + * @param pluginName Plugin name + * @param skillName Skill name + * @returns Skill configuration + */ +export function getSkillConfig( + cwd: string, + pluginName: string, + skillName: string, +): SkillConfig { + const config = loadPluginConfig(cwd, pluginName); + return ( + config.skills[skillName] ?? { + enabled: true, + triggers: { keywords: [], patterns: [] }, + } + ); +} + +/** + * Check if hook is enabled + * + * Convenience function that exits cleanly if hook is disabled. + * + * @param cwd Current working directory + * @param pluginName Plugin name + * @param hookName Hook name + * @returns true if enabled, exits if disabled + */ +export function checkHookEnabled( + cwd: string, + pluginName: string, + hookName: string, +): boolean { + const config = getHookConfig(cwd, pluginName, hookName); + + if (!config.enabled) { + console.error(`[DEBUG] ${pluginName}:${hookName} disabled by config`); + process.exit(0); + } + + return true; +} + +/** + * Clear configuration cache + * + * Useful for testing or when config files are modified + */ +export function clearConfigCache(): void { + configCache.clear(); +} diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..9099114 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,145 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:jbabin91/super-claude:plugins/workflow", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "6104a34e324b266a4933fee2c1e56dde8d941dea", + "treeHash": "339bc1d8f318fa93d452c5a4dd8b15fd2b4e65c32210a664932f9ce6ee701abf", + "generatedAt": "2025-11-28T10:17:58.189690Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "workflow", + "description": "Development workflow enhancements including OpenSpec proposal management, skill auto-activation, and command hooks for session checklists, type checking, commit guards, and branch name validation.", + "version": "0.5.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "f2c016ffacfe60e4489e885a0c66b38a7e5f527f838888d7798465e64d8d9eb0" + }, + { + "path": "hooks/branch-name-validator.ts", + "sha256": "9d7be48def1f7ef5a08e56eabce6933fe023976271dfc1b9acb32fe47d85c747" + }, + { + "path": "hooks/skill-activation-prompt.ts", + "sha256": "a9ecafcd6b4ae4eb26cf52d5ae66db44719ccf0e0d50023b291de38d40c856b7" + }, + { + "path": "hooks/git-commit-guard.ts", + "sha256": "555c79a6b5140a5810e9dc0c8f37b5118627cb2bf62319cb4468aa2c2c352275" + }, + { + "path": "hooks/hooks.json", + "sha256": "192f7fb820e04adf7a4e40c7254f07e688ef96c589e66c29f129f4a72c8c7194" + }, + { + "path": "hooks/session-checklist.ts", + "sha256": "a101bf32f7056f730813d0129d979b5edd7796dd92954b453e1819beb2191dd3" + }, + { + "path": "hooks/session-start.ts", + "sha256": "5a8655b6480985151a546823b629b260eee88ed936c0b2da53199983247d31e1" + }, + { + "path": "hooks/type-checker.ts", + "sha256": "877f2d5cdcb5ccb7b4ee1404283706b4aea2d43edbf8cdfb4695169e0e3d01c7" + }, + { + "path": "hooks/utils/super-claude-config-loader.ts", + "sha256": "0a590a2a65e0a37fd28911eb5b5f74c320a39b85e6fdae7b65266143556e599c" + }, + { + "path": "hooks/utils/config-types.ts", + "sha256": "b16056fea5f3d7a6fb4b9a7366f7600bac082a9f279e12343655329f1cd0d3e7" + }, + { + "path": "hooks/utils/runtime-check.ts", + "sha256": "496dd5a0a7c233bd8f369db5c518d962709d7acf7f1ec1560fd09652c1c29a5a" + }, + { + "path": "hooks/utils/config-validation.ts", + "sha256": "55388ebbf3298715a4c8074d1eb08780ac8125077e170a38502a0e6c17b6bf26" + }, + { + "path": "hooks/utils/index.ts", + "sha256": "e550830293af79a135675654b397580487509de790f1554162c3ccd103cdb4e4" + }, + { + "path": "hooks/utils/config-loader.ts", + "sha256": "cc3f7aa4bdb66e35caeeb4f4a29dfb6236c744c49db1b973cdbffa95c1ea264b" + }, + { + "path": "hooks/utils/hook-input.ts", + "sha256": "8a402b8192cecd781678ae3dab8c01d02af1df1c24f7a0fee5a74e2f30072f0c" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "d405a243791af16c98a1eb8cd1dba5e134f28883c5ae7457a23557833612781a" + }, + { + "path": "commands/generate-skill-rules.md", + "sha256": "b483e5044ec21cac250a970b97a034998c5c5e9d8e317c59e15c3915e0df7a7a" + }, + { + "path": "commands/configure.md", + "sha256": "76654fd174659b36520ea901d1409ac672e660ff203e323ab23494ad178b34d3" + }, + { + "path": "commands/openspec/archive.md", + "sha256": "c4b4758098d7d515bcaaea05e0b197d11e583d79c52e7e507e3c7fde40a25c25" + }, + { + "path": "commands/openspec/work.md", + "sha256": "0fdff9d945daa3f49ed146d11d52d2da3566106271df678a00716502538a8987" + }, + { + "path": "commands/openspec/status.md", + "sha256": "43250a9afaca3b2eabb767df21898010c460c3ffe8016e233de4c6f00713b1a5" + }, + { + "path": "commands/openspec/apply.md", + "sha256": "93275001081c4ca981ce503dc9b0249eeaa3fed5415dcc7a6ee078360113085c" + }, + { + "path": "commands/openspec/done.md", + "sha256": "085a63f5ccace79886913309c6d225e497605e0e8bcbbf7a987da519f957a60e" + }, + { + "path": "commands/openspec/init.md", + "sha256": "b8d1b4643d23d3c397f392f2d5eb019b53aa3fd0d433b1d1d5141588449ee3d2" + }, + { + "path": "commands/openspec/checkpoint.md", + "sha256": "d0f03fcf0dd05a732f348d37c44939469dace6cd1bb5223d47808833f8cd0680" + }, + { + "path": "commands/openspec/update.md", + "sha256": "b16a933f9187634f7e2623cbf41daf1835fceec85b59d54fe56dbb2794aea75e" + }, + { + "path": "commands/openspec/proposal.md", + "sha256": "721acdbe34d284da66b7491201a9472f4ae46678c9aa1d1ad60c293c2daea354" + }, + { + "path": "skills/.gitkeep", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "dirSha256": "339bc1d8f318fa93d452c5a4dd8b15fd2b4e65c32210a664932f9ce6ee701abf" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29