From 571aba11924fe6b0a7cea60c596b2607f946a743 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 00:05:40 -0600 Subject: [PATCH 01/10] docs(blueprints): add harness feature parity audit blueprint Establishes Claude Code 2.1.228 as the upstream baseline and documents the re-run procedure so the audit is repeatable rather than a one-off. --- blueprints/harness-feature-parity.md | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 blueprints/harness-feature-parity.md diff --git a/blueprints/harness-feature-parity.md b/blueprints/harness-feature-parity.md new file mode 100644 index 000000000..9079da034 --- /dev/null +++ b/blueprints/harness-feature-parity.md @@ -0,0 +1,48 @@ +# Harness Feature Parity + +Tracks han's coverage of the upstream harness feature surface it integrates +with. The primary harness is Claude Code; the bridge plugins map the same +surface onto OpenCode, Gemini CLI, Kiro, Codex, and Antigravity. + +**Upstream baseline: Claude Code 2.1.228** (npm `@anthropic-ai/claude-code` +`latest` dist-tag). Re-run the audit whenever the baseline moves. + +## How to re-run this audit + +1. Read the npm `latest` dist-tag for `@anthropic-ai/claude-code`. That version + is the baseline, not the version referenced in source comments. +2. Read the upstream reference pages, which are the schema source of truth: + - `https://code.claude.com/docs/en/hooks.md` + - `https://code.claude.com/docs/en/plugins-reference.md` + - `https://code.claude.com/docs/en/plugin-marketplaces.md` + - `https://code.claude.com/docs/en/plugin-dependencies.md` + - `https://code.claude.com/docs/en/plugin-relevance.md` + - `https://code.claude.com/docs/en/changelog.md` +3. Diff each table below against the docs, then close every gap before + updating the baseline line above. + +## Hook events + +Source of truth in han: `packages/han/lib/hooks/hook-config.ts` +(`HookEventType`, `VALID_EVENT_TYPES`). + +Status is filled in by the audit; see the gap register below for open items. + +## Hook handler types + +Upstream supports five handler types: `command`, `http`, `mcp_tool`, `prompt`, +and `agent`. + +## Plugin manifest schema + +Upstream `.claude-plugin/plugin.json` fields, and han's `han-plugin.yml` +superset. + +## Marketplace schema + +Upstream `.claude-plugin/marketplace.json` fields. + +## Gap register + +Open gaps are tracked in this section and closed in the same change that +records them. From 4a0e4fa14a7c560a5dc33dfd5ce6abfac8c44239 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 00:54:07 -0600 Subject: [PATCH 02/10] fix(cli): resolve plugins through the marketplace manifest, fix dispatch Plugin directory lookup probed a fixed jutsu/, do/, hashi/, root list in six places. Plugins live under plugins// now, so that list matched nothing: every plugin in a directory or development marketplace failed to resolve, and with it every plugin hook, MCP server, and memory provider. Make hooks/plugin-discovery.ts the single resolver and read .claude-plugin/marketplace.json first, which is the only place that knows both the current layout and all 179 published legacy aliases. Scanning for han-plugin.yml stays as the fallback for plugins the manifest omits, and it no longer misses agent-only or MCP-only plugins. Sources that escape the marketplace root are rejected. Also drops the hashi- prefix gate on MCP discovery, which matched nothing under the current short plugin names. Restoring resolution made three latent dispatch defects reachable, so they are fixed here rather than shipped live: - hooks.json timeout is seconds per the Claude Code schema, but dispatch passed it straight to execSync, which takes milliseconds. Every hook was being killed after 5 to 15 milliseconds instead of seconds. - plugins/core registers `han hook dispatch ` as one of its own hooks.json entries for eight events. With resolution working and timeouts correct, the dispatcher would rediscover that entry and re-exec itself without bound. HAN_DISPATCH, already set on every child, is now honored as a recursion guard. - Hook group matchers were ignored, so a PostToolUse group matching Edit|Write ran for every tool. Matchers are now applied to plugin and settings hooks alike. HookEntry also carries the full Claude Code handler union, so http, mcp_tool and agent handlers are recognized and left to Claude Code rather than falling through an unrecognized-type branch. The two dispatch tests asserting no plugins installed ran from inside the han checkout, where the repo project settings enable plugins. They only passed because resolution was broken; they now run from an isolated cwd. --- .../lib/commands/completion/completions.ts | 18 +- packages/han/lib/commands/hook/dispatch.ts | 292 +++++++++--------- packages/han/lib/commands/hook/test.tsx | 181 +++++++---- .../han/lib/commands/mcp/exposed-tools.ts | 105 ++----- packages/han/lib/hook-runner.ts | 98 +----- packages/han/lib/hook-test.ts | 27 +- packages/han/lib/hooks/plugin-discovery.ts | 128 ++++++-- packages/han/lib/memory/provider-discovery.ts | 26 +- packages/han/test/cli-integration.test.ts | 14 +- packages/han/test/dispatch-simple.test.ts | 34 +- .../test/plugin-discovery-marketplace.test.ts | 145 +++++++++ 11 files changed, 575 insertions(+), 493 deletions(-) create mode 100644 packages/han/test/plugin-discovery-marketplace.test.ts diff --git a/packages/han/lib/commands/completion/completions.ts b/packages/han/lib/commands/completion/completions.ts index d4e2bfa42..526f95338 100644 --- a/packages/han/lib/commands/completion/completions.ts +++ b/packages/han/lib/commands/completion/completions.ts @@ -9,6 +9,7 @@ import { listAvailableHooks, loadPluginConfig, } from '../../hooks/hook-config.ts'; +import { findPluginInMarketplace } from '../../hooks/plugin-discovery.ts'; import { getMarketplacePlugins } from '../../marketplace-cache.ts'; import { getInstalledPlugins } from '../../shared.ts'; @@ -61,24 +62,15 @@ function findPluginRoot(pluginName: string): string | null { const configDir = process.env.CLAUDE_CONFIG_DIR || join(homeDir, '.claude'); const marketplaceDir = join(configDir, 'plugins', 'marketplaces', 'han'); - // Check common plugin directories - const prefixes = ['jutsu', 'do', 'hashi', 'core']; - for (const prefix of prefixes) { - const pluginPath = join(marketplaceDir, prefix, pluginName); - if (existsSync(join(pluginPath, 'han-plugin.yml'))) { - return pluginPath; - } + const installed = findPluginInMarketplace(marketplaceDir, pluginName); + if (installed) { + return installed; } // Also check if we're in the han repo itself (for development) const cwd = process.cwd(); if (existsSync(join(cwd, '.claude-plugin', 'marketplace.json'))) { - for (const prefix of prefixes) { - const pluginPath = join(cwd, prefix, pluginName); - if (existsSync(join(pluginPath, 'han-plugin.yml'))) { - return pluginPath; - } - } + return findPluginInMarketplace(cwd, pluginName); } return null; diff --git a/packages/han/lib/commands/hook/dispatch.ts b/packages/han/lib/commands/hook/dispatch.ts index b4e11c0b2..82382ddd0 100644 --- a/packages/han/lib/commands/hook/dispatch.ts +++ b/packages/han/lib/commands/hook/dispatch.ts @@ -23,6 +23,7 @@ import { isCoordinatorHealthy } from '../../grpc/client.ts'; import { sessionFileChanges } from '../../grpc/data-access.ts'; import { executeHooksViaGrpc } from '../../grpc/hook-executor.ts'; import { getClaudeProjectPath } from '../../memory/paths.ts'; +import { getPluginDir } from '../../hooks/plugin-discovery.ts'; import { getPluginNameFromRoot, isDebugMode } from '../../shared.ts'; import { recordHookExecution as recordOtelHookExecution } from '../../telemetry/index.ts'; @@ -180,44 +181,58 @@ function _getSessionIdFromStdin(): string | undefined { } /** - * Hook definition from hooks.json + * Hook handler from hooks.json. + * + * Claude Code defines five handler types. Only `command` runs locally here; + * `http`, `mcp_tool`, `prompt`, and `agent` are executed by Claude Code + * itself and are skipped so this dispatcher never double-fires them. */ interface HookEntry { - type: 'command' | 'prompt'; + type: 'command' | 'http' | 'mcp_tool' | 'prompt' | 'agent'; command?: string; prompt?: string; + /** Seconds, per the Claude Code hook schema. */ timeout?: number; } +/** + * Fallback timeout, in seconds, for a hooks.json entry that omits `timeout`. + * + * Claude Code's own default is 600s, but a hook run through this dispatcher is + * already blocking a session turn, so han caps the wait far lower. + */ +const DEFAULT_HOOK_TIMEOUT_SECONDS = 30; + interface HookGroup { + /** + * Regex matched against the tool name on tool events. Absent or empty means + * the group runs for every tool. + */ + matcher?: string; hooks: HookEntry[]; } -interface PluginHooks { - hooks: Record; -} - /** - * Find plugin in a marketplace root directory + * Decide whether a hook group applies to the event being dispatched. + * + * Claude Code treats `matcher` as a regex anchored to the whole tool name. A + * group with a matcher never runs on a non-tool event, which carries no tool + * name. */ -function findPluginInMarketplace( - marketplaceRoot: string, - pluginName: string -): string | null { - const potentialPaths = [ - join(marketplaceRoot, 'jutsu', pluginName), - join(marketplaceRoot, 'do', pluginName), - join(marketplaceRoot, 'hashi', pluginName), - join(marketplaceRoot, pluginName), - ]; - - for (const path of potentialPaths) { - if (existsSync(path)) { - return path; - } +function matchesHookGroup(group: HookGroup, toolName?: string): boolean { + const matcher = group.matcher; + if (!matcher || matcher === '*') return true; + if (!toolName) return false; + try { + return new RegExp(`^(?:${matcher})$`).test(toolName); + } catch { + // A malformed matcher must not silently swallow the hook. + return true; } +} - return null; +interface PluginHooks { + hooks: Record; } /** @@ -230,55 +245,6 @@ export function resolveToAbsolute(path: string): string { return join(process.cwd(), path); } -/** - * Get plugin directory based on plugin name, marketplace, and marketplace config - */ -function getPluginDir( - pluginName: string, - marketplace: string, - marketplaceConfig: MarketplaceConfig | undefined -): string | null { - // If marketplace config specifies a directory source, use that path - if (marketplaceConfig?.source?.source === 'directory') { - const directoryPath = marketplaceConfig.source.path; - if (directoryPath) { - const absolutePath = resolveToAbsolute(directoryPath); - const found = findPluginInMarketplace(absolutePath, pluginName); - if (found) { - return found; - } - } - } - - // Check if we're in the marketplace repo itself (for development) - const cwd = process.cwd(); - if (existsSync(join(cwd, '.claude-plugin', 'marketplace.json'))) { - const found = findPluginInMarketplace(cwd, pluginName); - if (found) { - return found; - } - } - - // Fall back to the default shared config path - const configDir = getClaudeConfigDir(); - if (!configDir) { - return null; - } - - const marketplaceRoot = join( - configDir, - 'plugins', - 'marketplaces', - marketplace - ); - - if (!existsSync(marketplaceRoot)) { - return null; - } - - return findPluginInMarketplace(marketplaceRoot, pluginName); -} - /** * Load a plugin's hooks.json */ @@ -309,13 +275,17 @@ function loadPluginHooks( } /** - * Execute a command hook and return its output - * Also reports execution to metrics and logs events + * Execute a command hook and return its output. + * Also reports execution to metrics and logs events. + * + * @param timeoutSeconds - Hook timeout in SECONDS, matching the `timeout` + * field in the Claude Code hooks schema. Converted to milliseconds for + * `execSync`, which takes milliseconds. */ function executeCommandHook( command: string, pluginRoot: string, - timeout: number, + timeoutSeconds: number, hookType: string, hookName: string, noCache = false, @@ -371,7 +341,7 @@ function executeCommandHook( const output = execSync(resolvedCommand, { encoding: 'utf-8', - timeout, + timeout: timeoutSeconds * 1000, stdio: ['pipe', 'pipe', 'pipe'], shell: '/bin/sh', cwd: process.cwd(), @@ -515,94 +485,94 @@ export function deriveHookName(command: string, pluginName: string): string { return pluginName; } +/** + * Run every command handler in a list of hook groups whose matcher applies. + */ +function runHookGroups( + groups: unknown, + hookType: string, + toolName: string | undefined, + outputs: string[], + noCache: boolean, + noCheckpoints: boolean +): void { + if (!Array.isArray(groups)) return; + for (const group of groups) { + if ( + typeof group !== 'object' || + group === null || + !('hooks' in group) || + !Array.isArray((group as HookGroup).hooks) + ) { + continue; + } + if (!matchesHookGroup(group as HookGroup, toolName)) { + continue; + } + for (const hook of (group as HookGroup).hooks) { + if (hook.type !== 'command' || !hook.command) continue; + const output = executeCommandHook( + hook.command, + process.cwd(), + hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, + hookType, + 'settings-hook', + noCache, + noCheckpoints + ); + if (output) { + outputs.push(output); + } + } + } +} + /** * Execute hooks from settings files (not from Han plugins) */ function dispatchSettingsHooks( hookType: string, + toolName: string | undefined, outputs: string[], noCache = false, noCheckpoints = false ): void { for (const { path } of getSettingsPaths()) { - // Check settings.json for hooks const settings = readSettingsFile(path); if (settings?.hooks) { - const hookGroups = (settings.hooks as Record)[hookType]; - if (Array.isArray(hookGroups)) { - for (const group of hookGroups) { - if ( - typeof group === 'object' && - group !== null && - 'hooks' in group && - Array.isArray(group.hooks) - ) { - for (const hook of group.hooks as HookEntry[]) { - if (hook.type === 'command' && hook.command) { - const output = executeCommandHook( - hook.command, - process.cwd(), - hook.timeout || 30000, - hookType, - 'settings-hook', - noCache, - noCheckpoints - ); - if (output) { - outputs.push(output); - } - } - } - } - } - } + runHookGroups( + (settings.hooks as Record)[hookType], + hookType, + toolName, + outputs, + noCache, + noCheckpoints + ); } - // Also check for hooks.json in the same directory + // A hooks.json can sit beside settings.json, with the events either at the + // root or under a "hooks" key. const hooksJsonPath = path.replace( /settings(\.local)?\.json$/, 'hooks.json' ); - if (hooksJsonPath !== path && existsSync(hooksJsonPath)) { - try { - const content = readFileSync(hooksJsonPath, 'utf-8'); - const hooksJson = JSON.parse(content) as Record; - - // hooks.json can have hooks at root level or under "hooks" key - const hooksObj = - (hooksJson.hooks as Record) ?? hooksJson; - const hookGroups = hooksObj[hookType]; - - if (Array.isArray(hookGroups)) { - for (const group of hookGroups) { - if ( - typeof group === 'object' && - group !== null && - 'hooks' in group && - Array.isArray(group.hooks) - ) { - for (const hook of group.hooks as HookEntry[]) { - if (hook.type === 'command' && hook.command) { - const output = executeCommandHook( - hook.command, - process.cwd(), - hook.timeout || 30000, - hookType, - 'settings-hook', - noCache, - noCheckpoints - ); - if (output) { - outputs.push(output); - } - } - } - } - } - } - } catch { - // Invalid JSON, skip - } + if (hooksJsonPath === path || !existsSync(hooksJsonPath)) continue; + try { + const hooksJson = JSON.parse( + readFileSync(hooksJsonPath, 'utf-8') + ) as Record; + const hooksObj = + (hooksJson.hooks as Record) ?? hooksJson; + runHookGroups( + hooksObj[hookType], + hookType, + toolName, + outputs, + noCache, + noCheckpoints + ); + } catch { + // Invalid JSON, skip } } } @@ -657,6 +627,20 @@ async function dispatchHooks( process.exit(0); } + // `plugins/core` registers `han hook dispatch ` as one of its own + // hooks.json entries so events Claude Code does not surface to plugins still + // reach han. Without this guard the dispatcher would rediscover that entry + // and re-exec itself forever. Children of executeCommandHook carry + // HAN_DISPATCH=1. + if (process.env.HAN_DISPATCH === '1') { + if (isDebugMode()) { + console.error( + `[dispatch] Skipping nested dispatch of ${hookType} (HAN_DISPATCH=1)` + ); + } + return; + } + // Initialize event logger for this session // Events are stored alongside Claude transcripts in the project directory const payload = getStdinPayload(); @@ -726,7 +710,13 @@ async function dispatchHooks( // Dispatch settings hooks if --all is specified if (includeSettings) { - dispatchSettingsHooks(hookType, outputs, noCache, noCheckpoints); + dispatchSettingsHooks( + hookType, + typeof payload?.tool_name === 'string' ? payload.tool_name : undefined, + outputs, + noCache, + noCheckpoints + ); } // Dispatch Han plugin hooks @@ -744,8 +734,12 @@ async function dispatchHooks( const hookGroups = pluginHooks.hooks[hookType]; for (const group of hookGroups) { + if (!matchesHookGroup(group, payload?.tool_name)) { + continue; + } for (const hook of group.hooks) { - // Only execute command hooks - prompt hooks are handled by Claude Code directly + // Only command hooks run here. Claude Code executes http, mcp_tool, + // prompt, and agent handlers itself. if (hook.type === 'command' && hook.command) { // Derive hook name from command or use plugin name const hookName = deriveHookName(hook.command, pluginName); @@ -753,7 +747,7 @@ async function dispatchHooks( const output = executeCommandHook( hook.command, pluginRoot, - hook.timeout || 30000, + hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, hookType, hookName, noCache, diff --git a/packages/han/lib/commands/hook/test.tsx b/packages/han/lib/commands/hook/test.tsx index 6e8094c99..eebe4fc6e 100644 --- a/packages/han/lib/commands/hook/test.tsx +++ b/packages/han/lib/commands/hook/test.tsx @@ -21,9 +21,11 @@ import { } from '../../config/claude-settings.ts'; import { getHookEvents, + HOOK_EVENT_TYPES, loadPluginConfig, type PluginHookDefinition, } from '../../hooks/index.ts'; +import { findPluginInMarketplace } from '../../hooks/plugin-discovery.ts'; /** * Hook types where stdout is meant to inject context into Claude's conversation. @@ -93,33 +95,6 @@ interface PhaseStats { startTime?: number; } -/** - * Find plugin in a marketplace root directory - */ -function findPluginInMarketplace( - marketplaceRoot: string, - pluginName: string -): string | null { - const potentialPaths = [ - join(marketplaceRoot, 'jutsu', pluginName), - join(marketplaceRoot, 'do', pluginName), - join(marketplaceRoot, 'hashi', pluginName), - join(marketplaceRoot, pluginName), - ]; - - if (pluginName === 'core') { - potentialPaths.push(join(marketplaceRoot, 'core')); - } - - for (const path of potentialPaths) { - if (existsSync(path)) { - return path; - } - } - - return null; -} - function resolveToAbsolute(path: string): string { if (path.startsWith('/')) { return path; @@ -530,6 +505,122 @@ function generateStdinPayload(hookType: string): string { basePayload.message = 'Claude needs your permission'; basePayload.notification_type = 'permission_prompt'; break; + + case 'PostToolBatch': + basePayload.tool_calls = [ + { + tool_name: 'Write', + tool_input: { + file_path: '/tmp/test-file.txt', + content: 'test content', + }, + tool_response: { filePath: '/tmp/test-file.txt', success: true }, + tool_use_id: `toolu_01TEST${Date.now()}`, + }, + ]; + break; + + case 'PermissionDenied': + basePayload.permission_mode = 'auto'; + basePayload.tool_name = 'Bash'; + basePayload.tool_input = { + command: 'rm -rf /tmp/build', + description: 'Clean build directory', + }; + basePayload.tool_use_id = `toolu_01TEST${Date.now()}`; + basePayload.reason = 'Blocked by classifier'; + break; + + case 'UserPromptExpansion': + basePayload.expansion_type = 'slash_command'; + basePayload.command_name = 'example-skill'; + basePayload.command_args = 'arg1 arg2'; + basePayload.command_source = 'plugin'; + basePayload.prompt = '/example-skill arg1 arg2'; + break; + + case 'MessageDisplay': + basePayload.turn_id = `turn-${Date.now()}`; + basePayload.message_id = `msg-${Date.now()}`; + basePayload.index = 0; + basePayload.final = true; + basePayload.delta = 'Assistant message text\n'; + break; + + case 'InstructionsLoaded': + basePayload.file_path = `${process.cwd()}/CLAUDE.md`; + basePayload.memory_type = 'Project'; + basePayload.load_reason = 'session_start'; + break; + + case 'TaskCreated': + case 'TaskCompleted': + basePayload.task_id = 'task-001'; + basePayload.task_subject = 'Implement user authentication'; + basePayload.task_description = 'Test task for hook execution'; + break; + + case 'StopFailure': + basePayload.error = 'rate_limit'; + basePayload.error_details = 'Test rate limit error'; + basePayload.last_assistant_message = 'Working on it'; + break; + + case 'TeammateIdle': + basePayload.teammate_name = 'researcher'; + basePayload.team_name = 'session-a1b2c3d4'; + break; + + case 'ConfigChange': + basePayload.source = 'settings'; + basePayload.file_path = `${process.cwd()}/.claude/settings.json`; + break; + + case 'CwdChanged': + basePayload.old_cwd = process.cwd(); + basePayload.new_cwd = `${process.cwd()}/src`; + break; + + case 'DirectoryAdded': + basePayload.directory = `${process.cwd()}/packages`; + basePayload.source = 'slash_command'; + break; + + case 'FileChanged': + basePayload.file_path = `${process.cwd()}/.envrc`; + basePayload.event = 'change'; + break; + + case 'WorktreeCreate': + basePayload.name = 'feature-auth'; + break; + + case 'WorktreeRemove': + basePayload.worktree_path = `${process.cwd()}/.claude/worktrees/feature-auth`; + break; + + case 'PostCompact': + basePayload.trigger = 'manual'; + basePayload.compact_summary = 'Summary of the compacted conversation'; + break; + + case 'Elicitation': + basePayload.mcp_server_name = 'test-mcp-server'; + basePayload.message = 'Please provide your credentials'; + basePayload.mode = 'form'; + basePayload.requested_schema = { + type: 'object', + properties: { username: { type: 'string', title: 'Username' } }, + }; + break; + + case 'ElicitationResult': + basePayload.mcp_server_name = 'test-mcp-server'; + basePayload.action = 'accept'; + basePayload.mode = 'form'; + basePayload.elicitation_id = 'elicit-123'; + basePayload.content = { username: 'alice' }; + break; } return JSON.stringify(basePayload, null, 2); @@ -811,22 +902,8 @@ const HookTestUI: React.FC = ({ const passed = Array.from(results.values()).filter((r) => r.success).length; const failed = Array.from(results.values()).filter((r) => !r.success).length; - // Sort phases in logical order - const eventOrder = [ - 'Setup', - 'SessionStart', - 'UserPromptSubmit', - 'PreToolUse', - 'PermissionRequest', - 'PostToolUse', - 'PostToolUseFailure', - 'SubagentStart', - 'SubagentStop', - 'Stop', - 'PreCompact', - 'SessionEnd', - 'Notification', - ]; + // Sort phases into the canonical Claude Code lifecycle order + const eventOrder: readonly string[] = HOOK_EVENT_TYPES; const sortedPhases = Array.from(phaseStats.values()).sort((a, b) => { const aIdx = eventOrder.indexOf(a.hookType); const bIdx = eventOrder.indexOf(b.hookType); @@ -1122,22 +1199,8 @@ async function runWithConsole( hooksByType.set(hook.hookType, existing); } - // Sort phases - const eventOrder = [ - 'Setup', - 'SessionStart', - 'UserPromptSubmit', - 'PreToolUse', - 'PermissionRequest', - 'PostToolUse', - 'PostToolUseFailure', - 'SubagentStart', - 'SubagentStop', - 'Stop', - 'PreCompact', - 'SessionEnd', - 'Notification', - ]; + // Sort phases into the canonical Claude Code lifecycle order + const eventOrder: readonly string[] = HOOK_EVENT_TYPES; const sortedPhases = Array.from(hooksByType.keys()).sort((a, b) => { const aIdx = eventOrder.indexOf(a); const bIdx = eventOrder.indexOf(b); diff --git a/packages/han/lib/commands/mcp/exposed-tools.ts b/packages/han/lib/commands/mcp/exposed-tools.ts index 9d5637989..aebb504d1 100644 --- a/packages/han/lib/commands/mcp/exposed-tools.ts +++ b/packages/han/lib/commands/mcp/exposed-tools.ts @@ -7,43 +7,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import YAML from 'yaml'; -import { - getMergedPluginsAndMarketplaces, - type MarketplaceConfig, -} from '../../config/claude-settings.ts'; - -/** - * Find plugin in a marketplace root directory - */ -export function findPluginInMarketplace( - marketplaceRoot: string, - pluginName: string -): string | null { - const potentialPaths = [ - join(marketplaceRoot, 'jutsu', pluginName), - join(marketplaceRoot, 'do', pluginName), - join(marketplaceRoot, 'hashi', pluginName), - join(marketplaceRoot, pluginName), - ]; - - for (const path of potentialPaths) { - if (existsSync(path)) { - return path; - } - } - - return null; -} - -/** - * Resolve a path to absolute, relative to cwd - */ -export function resolvePathToAbsolute(path: string): string { - if (path.startsWith('/')) { - return path; - } - return join(process.cwd(), path); -} +import { getMergedPluginsAndMarketplaces } from '../../config/claude-settings.ts'; +import { getPluginDir } from '../../hooks/plugin-discovery.ts'; +import { getShortPluginName } from '../../plugin-aliases.ts'; /** * MCP server definition from han-plugin.yml @@ -102,26 +68,34 @@ export interface McpServerConfig { } /** - * Capability descriptions for each plugin type + * Capability descriptions keyed by short plugin name. + * + * Plugins are named after their directory (`github`), but installs made before + * the rename still carry the prefixed alias (`hashi-github`) in settings, so + * lookups go through the short name. */ const CAPABILITY_DESCRIPTIONS: Record = { - 'hashi-github': 'Create branches, commits, PRs, manage issues, code search', - 'hashi-gitlab': 'Manage merge requests, issues, CI/CD pipelines, code search', - 'hashi-playwright-mcp': - 'Navigate pages, fill forms, take screenshots, test UIs', - 'hashi-linear': 'Create and manage issues, track projects, update status', - 'hashi-jira': 'Manage tickets, JQL search, update issue status', - 'hashi-clickup': 'Manage tasks, workspaces, project tracking', - 'hashi-blueprints': 'Search, read, and write technical blueprints', - 'hashi-figma': 'Access design components, specs, and design systems', - 'hashi-sentry': 'Track errors, monitor performance, manage incidents', + github: 'Create branches, commits, PRs, manage issues, code search', + gitlab: 'Manage merge requests, issues, CI/CD pipelines, code search', + 'playwright-mcp': 'Navigate pages, fill forms, take screenshots, test UIs', + linear: 'Create and manage issues, track projects, update status', + jira: 'Manage tickets, JQL search, update issue status', + clickup: 'Manage tasks, workspaces, project tracking', + blueprints: 'Search, read, and write technical blueprints', + figma: 'Access design components, specs, and design systems', + sentry: 'Track errors, monitor performance, manage incidents', + notion: 'Search pages and databases, create and update content', + reddit: 'Read subreddits, posts, and comments', + canva: 'Create and edit designs, export assets', + 'agent-sop': 'Look up and apply standard operating procedures', }; /** - * Extract display name from plugin name (e.g., "hashi-github" -> "GitHub") + * Extract display name from a plugin name, tolerating the legacy `hashi-` + * prefix that older installs still record (`hashi-github` -> `GitHub`). */ function extractDisplayName(pluginName: string): string { - const name = pluginName.replace(/^hashi-/, ''); + const name = getShortPluginName(pluginName); const specialCases: Record = { 'playwright-mcp': 'Playwright', github: 'GitHub', @@ -136,28 +110,6 @@ function extractDisplayName(pluginName: string): string { return name.charAt(0).toUpperCase() + name.slice(1); } -/** - * Get plugin directory based on plugin name, marketplace, and marketplace config - */ -function getPluginDir( - pluginName: string, - _marketplace: string, - marketplaceConfig: MarketplaceConfig | undefined -): string | null { - if (marketplaceConfig?.source?.source === 'directory') { - const directoryPath = marketplaceConfig.source.path; - if (directoryPath) { - const absolutePath = resolvePathToAbsolute(directoryPath); - const found = findPluginInMarketplace(absolutePath, pluginName); - if (found) { - return found; - } - } - } - - return null; -} - /** * Load plugin.json from a plugin directory */ @@ -199,13 +151,6 @@ export function discoverMcpServers(): McpServerConfig[] { const { plugins, marketplaces } = getMergedPluginsAndMarketplaces(); for (const [pluginName, marketplace] of plugins.entries()) { - const isHashi = pluginName.startsWith('hashi-'); - const isCore = pluginName === 'core'; - - if (!isHashi && !isCore) { - continue; - } - const marketplaceConfig = marketplaces.get(marketplace); const pluginDir = getPluginDir(pluginName, marketplace, marketplaceConfig); @@ -239,7 +184,7 @@ export function discoverMcpServers(): McpServerConfig[] { pluginName, serverName, description: - CAPABILITY_DESCRIPTIONS[pluginName] || + CAPABILITY_DESCRIPTIONS[getShortPluginName(pluginName)] || pluginJson.description || `${displayName} integration`, command: serverDef.command, diff --git a/packages/han/lib/hook-runner.ts b/packages/han/lib/hook-runner.ts index 69ba2ccb0..f3c4eb922 100644 --- a/packages/han/lib/hook-runner.ts +++ b/packages/han/lib/hook-runner.ts @@ -48,6 +48,7 @@ import { buildCommandWithFiles, HAN_FILES_TEMPLATE, } from './hooks/transcript-filter.ts'; +import { findPluginInMarketplace } from './hooks/plugin-discovery.ts'; import { getPluginNameFromRoot, isDebugMode } from './shared.ts'; // Re-export for tests @@ -562,103 +563,6 @@ export async function validate(options: ValidateOptions): Promise { // Plugin Discovery (for running outside hook context) // ============================================ -/** - * Find plugin in a marketplace root directory using multiple discovery methods. - * - * Discovery order: - * 1. Check marketplace.json for the plugin's source path - * 2. Scan for han-plugin.yml files (for external plugins) - * 3. Fall back to legacy directory patterns - */ -function findPluginInMarketplace( - marketplaceRoot: string, - pluginName: string -): string | null { - // 1. Try marketplace.json first (most reliable for han marketplace) - const marketplaceJsonPath = join( - marketplaceRoot, - '.claude-plugin', - 'marketplace.json' - ); - if (existsSync(marketplaceJsonPath)) { - try { - const marketplaceJson = JSON.parse( - readFileSync(marketplaceJsonPath, 'utf8') - ); - const plugin = marketplaceJson.plugins?.find( - (p: { name: string }) => p.name === pluginName - ); - if (plugin?.source) { - // Source is relative to marketplace root (e.g., "./plugins/validation/biome") - const pluginPath = join(marketplaceRoot, plugin.source); - if (existsSync(pluginPath)) { - return pluginPath; - } - } - } catch { - // Ignore parse errors, try other methods - } - } - - // 2. Scan for han-plugin.yml files in the marketplace (for external plugins) - // Look for directories containing han-plugin.yml where the directory name matches - const scanDirs = [marketplaceRoot, join(marketplaceRoot, 'plugins')]; - for (const scanDir of scanDirs) { - if (!existsSync(scanDir)) continue; - try { - const entries = readdirSync(scanDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const dirPath = join(scanDir, entry.name); - // Check direct match - if (entry.name === pluginName) { - const hanPluginPath = join(dirPath, 'han-plugin.yml'); - if (existsSync(hanPluginPath)) { - return dirPath; - } - } - // Check subdirectories (one level deep for category dirs) - try { - const subEntries = readdirSync(dirPath, { withFileTypes: true }); - for (const subEntry of subEntries) { - if (!subEntry.isDirectory()) continue; - if (subEntry.name === pluginName) { - const hanPluginPath = join( - dirPath, - subEntry.name, - 'han-plugin.yml' - ); - if (existsSync(hanPluginPath)) { - return join(dirPath, subEntry.name); - } - } - } - } catch { - // Ignore permission errors - } - } - } catch { - // Ignore permission errors - } - } - - // 3. Legacy directory patterns (for backwards compatibility) - const legacyPaths = [ - join(marketplaceRoot, 'jutsu', pluginName), - join(marketplaceRoot, 'do', pluginName), - join(marketplaceRoot, 'hashi', pluginName), - join(marketplaceRoot, pluginName), - ]; - - for (const path of legacyPaths) { - if (existsSync(path)) { - return path; - } - } - - return null; -} - /** * Resolve a path to absolute, relative to cwd */ diff --git a/packages/han/lib/hook-test.ts b/packages/han/lib/hook-test.ts index c6e59bc86..2c73fefc3 100644 --- a/packages/han/lib/hook-test.ts +++ b/packages/han/lib/hook-test.ts @@ -9,12 +9,13 @@ import { type MarketplaceConfig, } from './config/claude-settings.ts'; import { HookTestUI } from './hook-test-ui.tsx'; +import { findPluginInMarketplace } from './hooks/plugin-discovery.ts'; interface HookCommand { plugin: string; command: string; pluginDir: string; - type: 'command' | 'prompt'; + type: 'command' | 'http' | 'mcp_tool' | 'prompt' | 'agent'; timeout?: number; } @@ -236,30 +237,6 @@ async function executeHookCommand( }); } -/** - * Find plugin in a marketplace root directory - */ -function findPluginInMarketplace( - marketplaceRoot: string, - pluginName: string -): string | null { - // Try different plugin directory structures - const potentialPaths = [ - join(marketplaceRoot, 'jutsu', pluginName), - join(marketplaceRoot, 'do', pluginName), - join(marketplaceRoot, 'hashi', pluginName), - join(marketplaceRoot, pluginName), - ]; - - for (const path of potentialPaths) { - if (existsSync(path)) { - return path; - } - } - - return null; -} - /** * Get plugin directory based on plugin name, marketplace, and marketplace config */ diff --git a/packages/han/lib/hooks/plugin-discovery.ts b/packages/han/lib/hooks/plugin-discovery.ts index 5aea1d5fa..59f3716f5 100644 --- a/packages/han/lib/hooks/plugin-discovery.ts +++ b/packages/han/lib/hooks/plugin-discovery.ts @@ -1,13 +1,20 @@ /** * Shared plugin discovery utilities * - * Provides consistent plugin directory resolution across all hook commands - * (list, explain, run). Uses dynamic scanning for han-plugin.yml - * files to support flexible directory structures. + * Provides consistent plugin directory resolution across every command that + * needs to locate a plugin on disk. + * + * A marketplace's `.claude-plugin/marketplace.json` is the authority: every + * entry carries a `source` pointing at its directory, and legacy names stay + * published as extra entries pointing at the same source. That makes the + * manifest the only place that knows both the current + * `plugins//` layout and every historical alias. Scanning for + * `han-plugin.yml` is the fallback, since plugins that ship only agents or an + * MCP server have no such file. */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, parse } from 'node:path'; +import { dirname, isAbsolute, join, parse, resolve, sep } from 'node:path'; import { getClaudeConfigDir, getProjectDir, @@ -44,20 +51,92 @@ function findMarketplaceRoot(startDir: string): string | null { */ const pluginDirCache = new Map>(); +interface MarketplaceSourceObject { + source?: string; + path?: string; +} + +interface MarketplaceManifest { + metadata?: { pluginRoot?: string }; + plugins?: Array<{ name?: string; source?: string | MarketplaceSourceObject }>; +} + /** - * Build plugin directory cache by scanning for han-plugin.yml files. - * Recursively scans up to depth 3 to support directory structures like: - * - plugins/validation/biome/han-plugin.yml - * - plugins/services/github/han-plugin.yml + * Index every plugin entry in a marketplace manifest, canonical names and + * aliases alike, into `cache`. + * + * Remote sources (github, npm, archive, git) have no local directory and are + * skipped. A `source` that escapes the marketplace root is rejected, since a + * manifest is third-party content. + */ +function addManifestEntries( + marketplaceRoot: string, + cache: Map +): void { + const manifestPath = join( + marketplaceRoot, + '.claude-plugin', + 'marketplace.json' + ); + if (!existsSync(manifestPath)) return; + + let manifest: MarketplaceManifest; + try { + manifest = JSON.parse( + readFileSync(manifestPath, 'utf-8') + ) as MarketplaceManifest; + } catch { + return; + } + + const pluginRoot = manifest.metadata?.pluginRoot; + const rootPrefix = resolve(marketplaceRoot); + + for (const entry of manifest.plugins ?? []) { + if (!entry?.name) continue; + + const source = entry.source; + let relative: string | undefined; + if (typeof source === 'string') { + // `metadata.pluginRoot` prefixes bare sources, letting an entry say + // "formatter" instead of "./plugins/formatter". + relative = + pluginRoot && !source.startsWith('.') && !isAbsolute(source) + ? join(pluginRoot, source) + : source; + } else if (source?.source === 'directory' && source.path) { + relative = source.path; + } + if (!relative) continue; + + const resolved = isAbsolute(relative) + ? relative + : join(marketplaceRoot, relative); + const target = resolve(resolved); + if (target !== rootPrefix && !target.startsWith(rootPrefix + sep)) continue; + if (!existsSync(resolved)) continue; + + cache.set(entry.name.toLowerCase(), resolved); + } +} + +/** + * Build the plugin name to directory map for a marketplace root. + * + * Reads `.claude-plugin/marketplace.json` first so canonical names and every + * published alias resolve, then scans up to depth 3 for `han-plugin.yml` to + * pick up plugins the manifest does not list. * * @param marketplaceRoot - Root directory of the marketplace to scan - * @returns Map of plugin name to plugin directory path + * @returns Map of lowercased plugin name to plugin directory path */ export function buildPluginDirCache( marketplaceRoot: string ): Map { const cache = new Map(); + addManifestEntries(marketplaceRoot, cache); + const scanDir = (dir: string, depth: number) => { // Depth 3 supports: plugins/category/plugin/han-plugin.yml if (depth > 3) return; @@ -70,29 +149,26 @@ export function buildPluginDirCache( const subdir = join(dir, entry.name); - // Check if this directory has han-plugin.yml if (existsSync(join(subdir, 'han-plugin.yml'))) { - // Get plugin name from .claude-plugin/plugin.json if available + // Prefer the declared plugin name over the directory name. + let name = entry.name; const pluginJsonPath = join(subdir, '.claude-plugin', 'plugin.json'); if (existsSync(pluginJsonPath)) { try { const pluginJson = JSON.parse( readFileSync(pluginJsonPath, 'utf-8') ); - if (pluginJson.name) { - cache.set(pluginJson.name, subdir); - } + if (pluginJson.name) name = pluginJson.name; } catch { - // Invalid plugin.json, fall back to directory name - cache.set(entry.name, subdir); + // Invalid plugin.json, keep the directory name. } - } else { - // No plugin.json, use directory name - cache.set(entry.name, subdir); + } + // The manifest wins when both know the name. + if (!cache.has(name.toLowerCase())) { + cache.set(name.toLowerCase(), subdir); } } - // Recurse into subdirectories scanDir(subdir, depth + 1); } } catch { @@ -115,13 +191,15 @@ export function findPluginInMarketplace( marketplaceRoot: string, pluginName: string ): string | null { - // Use cached mapping if available - if (!pluginDirCache.has(marketplaceRoot)) { - pluginDirCache.set(marketplaceRoot, buildPluginDirCache(marketplaceRoot)); + if (!pluginName) return null; + + let cache = pluginDirCache.get(marketplaceRoot); + if (!cache) { + cache = buildPluginDirCache(marketplaceRoot); + pluginDirCache.set(marketplaceRoot, cache); } - const cache = pluginDirCache.get(marketplaceRoot); - return cache?.get(pluginName) ?? null; + return cache.get(pluginName.toLowerCase()) ?? null; } /** diff --git a/packages/han/lib/memory/provider-discovery.ts b/packages/han/lib/memory/provider-discovery.ts index cc51b0bb9..b3e6b24b5 100644 --- a/packages/han/lib/memory/provider-discovery.ts +++ b/packages/han/lib/memory/provider-discovery.ts @@ -26,6 +26,7 @@ import { getClaudeConfigDir, getMergedPluginsAndMarketplaces, } from '../config/claude-settings.ts'; +import { findPluginInMarketplace } from '../hooks/plugin-discovery.ts'; import { loadPluginConfig } from '../hooks/hook-config.ts'; import type { ExtractedObservation, @@ -120,31 +121,6 @@ function resolveMarketplacePath( return directoryPath; // Return as-is, will fail to find } -/** - * Find plugin directory in marketplace structure - */ -function findPluginInMarketplace( - marketplaceRoot: string, - pluginName: string -): string | null { - // Check category directories first - const categories = ['jutsu', 'do', 'hashi']; - for (const category of categories) { - const categoryPath = join(marketplaceRoot, category, pluginName); - if (existsSync(categoryPath)) { - return categoryPath; - } - } - - // Check root level - const rootPath = join(marketplaceRoot, pluginName); - if (existsSync(rootPath)) { - return rootPath; - } - - return null; -} - /** * Discover all memory providers from installed plugins * @param projectPath - Optional project path for context-aware plugin discovery diff --git a/packages/han/test/cli-integration.test.ts b/packages/han/test/cli-integration.test.ts index ea51fc960..94d3c4b42 100644 --- a/packages/han/test/cli-integration.test.ts +++ b/packages/han/test/cli-integration.test.ts @@ -75,13 +75,22 @@ describe('CLI integration tests', () => { describe('han hook dispatch', () => { test('exits cleanly when no plugins installed', () => { + // Run from an isolated cwd. Inside the han checkout the repo's own + // project settings enable plugins, which is the opposite of what this + // test asserts. const result = spawnSync( 'bun', - ['run', 'lib/main.ts', 'hook', 'dispatch', 'SessionStart'], + [ + 'run', + join(packageRoot, 'lib/main.ts'), + 'hook', + 'dispatch', + 'SessionStart', + ], { encoding: 'utf-8', timeout: 30000, - cwd: packageRoot, + cwd: join(testDir, 'project'), env: { ...process.env, CLAUDE_CONFIG_DIR: join(testDir, 'config'), @@ -92,6 +101,7 @@ describe('CLI integration tests', () => { // Should exit cleanly with no output when no plugins have hooks expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); }); test('respects HAN_DISABLE_HOOKS=true', () => { diff --git a/packages/han/test/dispatch-simple.test.ts b/packages/han/test/dispatch-simple.test.ts index 60ce660d7..32829fd23 100644 --- a/packages/han/test/dispatch-simple.test.ts +++ b/packages/han/test/dispatch-simple.test.ts @@ -15,12 +15,18 @@ const packageRoot = join(__dirname, '..'); describe.serial('dispatch simple coverage', () => { let testDir: string; let configDir: string; + let projectDir: string; + // Absolute so each spawn can run from an isolated cwd. Running inside the + // han checkout would pull in the repo's own project settings and plugins. + const entrypoint = join(packageRoot, 'lib/main.ts'); beforeEach(() => { // Create unique directory for each test to avoid race conditions testDir = `/tmp/test-dispatch-simple-${Date.now()}-${Math.random().toString(36).slice(2)}`; configDir = join(testDir, 'config'); + projectDir = join(testDir, 'project'); mkdirSync(configDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); }); afterEach(() => { @@ -30,11 +36,11 @@ describe.serial('dispatch simple coverage', () => { test('dispatch with HAN_DISABLE_HOOKS=true exits early', () => { const result = spawnSync( 'bun', - ['run', 'lib/main.ts', 'hook', 'dispatch', 'SessionStart'], + ['run', entrypoint, 'hook', 'dispatch', 'SessionStart'], { encoding: 'utf-8', timeout: 10000, - cwd: packageRoot, + cwd: projectDir, env: { ...process.env, HAN_DISABLE_HOOKS: 'true', @@ -54,11 +60,11 @@ describe.serial('dispatch simple coverage', () => { const result = spawnSync( 'bun', - ['run', 'lib/main.ts', 'hook', 'dispatch', 'SessionStart'], + ['run', entrypoint, 'hook', 'dispatch', 'SessionStart'], { encoding: 'utf-8', timeout: 10000, - cwd: packageRoot, + cwd: projectDir, env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, @@ -85,11 +91,11 @@ describe.serial('dispatch simple coverage', () => { const result = spawnSync( 'bun', - ['run', 'lib/main.ts', 'hook', 'dispatch', 'SessionStart', '--all'], + ['run', entrypoint, 'hook', 'dispatch', 'SessionStart', '--all'], { encoding: 'utf-8', timeout: 10000, - cwd: packageRoot, + cwd: projectDir, env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, @@ -119,19 +125,11 @@ describe.serial('dispatch simple coverage', () => { const result = spawnSync( 'bun', - [ - 'run', - 'lib/main.ts', - 'hook', - 'dispatch', - 'Stop', - '--all', - '--no-cache', - ], + ['run', entrypoint, 'hook', 'dispatch', 'Stop', '--all', '--no-cache'], { encoding: 'utf-8', timeout: 10000, - cwd: packageRoot, + cwd: projectDir, env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, @@ -164,7 +162,7 @@ describe.serial('dispatch simple coverage', () => { 'bun', [ 'run', - 'lib/main.ts', + entrypoint, 'hook', 'dispatch', 'Stop', @@ -174,7 +172,7 @@ describe.serial('dispatch simple coverage', () => { { encoding: 'utf-8', timeout: 10000, - cwd: packageRoot, + cwd: projectDir, env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, diff --git a/packages/han/test/plugin-discovery-marketplace.test.ts b/packages/han/test/plugin-discovery-marketplace.test.ts new file mode 100644 index 000000000..a25184d1d --- /dev/null +++ b/packages/han/test/plugin-discovery-marketplace.test.ts @@ -0,0 +1,145 @@ +/** + * Plugin directory resolution against a marketplace manifest. + * + * Regression cover for plugins moving under `plugins//`: + * resolution used to probe a fixed `jutsu/`, `do/`, `hashi/`, root list, which + * matched nothing once the tree was reorganized. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + clearPluginDirCache, + findPluginInMarketplace, +} from '../lib/hooks/plugin-discovery.ts'; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +describe('findPluginInMarketplace', () => { + let root: string; + + beforeEach(() => { + clearPluginDirCache(); + root = `/tmp/test-plugin-discovery-${Date.now()}-${Math.random().toString(36).slice(2)}`; + mkdirSync(join(root, '.claude-plugin'), { recursive: true }); + mkdirSync(join(root, 'plugins', 'languages', 'typescript'), { + recursive: true, + }); + mkdirSync(join(root, 'plugins', 'services', 'github'), { recursive: true }); + mkdirSync(join(root, 'plugins', 'disciplines', 'frontend-development'), { + recursive: true, + }); + writeFileSync( + join(root, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'test-marketplace', + owner: { name: 'Test' }, + plugins: [ + { name: 'typescript', source: './plugins/languages/typescript' }, + { + name: 'jutsu-typescript', + source: './plugins/languages/typescript', + }, + { name: 'github', source: './plugins/services/github' }, + { name: 'hashi-github', source: './plugins/services/github' }, + { + name: 'frontend-development', + source: './plugins/disciplines/frontend-development', + }, + { name: 'escaper', source: '../../../etc' }, + { name: 'ghost', source: './plugins/languages/does-not-exist' }, + ], + }) + ); + }); + + afterEach(() => { + clearPluginDirCache(); + rmSync(root, { recursive: true, force: true }); + }); + + test('resolves a plugin nested under its category directory', () => { + expect(findPluginInMarketplace(root, 'typescript')).toBe( + join(root, 'plugins', 'languages', 'typescript') + ); + expect(findPluginInMarketplace(root, 'frontend-development')).toBe( + join(root, 'plugins', 'disciplines', 'frontend-development') + ); + }); + + test('resolves legacy alias names to the same directory', () => { + expect(findPluginInMarketplace(root, 'jutsu-typescript')).toBe( + findPluginInMarketplace(root, 'typescript') + ); + expect(findPluginInMarketplace(root, 'hashi-github')).toBe( + findPluginInMarketplace(root, 'github') + ); + }); + + test('rejects a source that escapes the marketplace root', () => { + expect(findPluginInMarketplace(root, 'escaper')).toBeNull(); + }); + + test('returns null for an entry whose directory is missing', () => { + expect(findPluginInMarketplace(root, 'ghost')).toBeNull(); + }); + + test('returns null for an unknown plugin', () => { + expect(findPluginInMarketplace(root, 'nonexistent-plugin')).toBeNull(); + }); + + test('finds a plugin the manifest omits by scanning the tree', () => { + mkdirSync(join(root, 'plugins', 'tools', 'unlisted'), { recursive: true }); + writeFileSync( + join(root, 'plugins', 'tools', 'unlisted', 'han-plugin.yml'), + 'hooks: {}\n' + ); + clearPluginDirCache(); + + expect(findPluginInMarketplace(root, 'unlisted')).toBe( + join(root, 'plugins', 'tools', 'unlisted') + ); + }); + + test('resolves every plugin the han marketplace publishes', () => { + const manifest = require( + join(packageRoot, '..', '..', '.claude-plugin', 'marketplace.json') + ) as { plugins: Array<{ name: string }> }; + const repoRoot = join(packageRoot, '..', '..'); + + const unresolved = manifest.plugins + .map((p) => p.name) + .filter((name) => findPluginInMarketplace(repoRoot, name) === null); + + expect(unresolved).toEqual([]); + }); +}); + +describe('han hook dispatch recursion guard', () => { + test('a nested dispatch exits without re-running plugin hooks', () => { + // plugins/core registers `han hook dispatch ` as one of its own + // hooks.json entries. Without the guard the dispatcher rediscovers that + // entry and re-execs itself. + const result = spawnSync( + 'bun', + [ + 'run', + join(packageRoot, 'lib/main.ts'), + 'hook', + 'dispatch', + 'ConfigChange', + ], + { + encoding: 'utf-8', + timeout: 30000, + cwd: packageRoot, + env: { ...process.env, HAN_DISPATCH: '1' }, + } + ); + + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); +}); From 24ceee065dfca67f7777f77748466cdf2b3dff52 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 00:54:34 -0600 Subject: [PATCH 03/10] feat(cli): add DirectoryAdded and collapse the hook event lists Claude Code 2.1.219 added DirectoryAdded, which fires after /add-dir or the SDK register_repo_root control request registers a working directory mid-session. han was pinned to the 2.1.215 surface and did not know the event, so a plugin could not subscribe to it. The event list existed in three hand-maintained copies that had to be edited in lockstep: the HookEventType union, a validation Set, and the hooks.json key ordering in generate-hooks.ts. They now all derive from one HOOK_EVENT_TYPES array, so the next upstream event is a one-line change. hook-explain-ui and hook test now sort by that same canonical order instead of their own seven and thirteen event subsets. --- .../han/lib/commands/plugin/generate-hooks.ts | 34 +-------- packages/han/lib/hook-explain-ui.tsx | 15 +--- packages/han/lib/hook-test-ui.tsx | 39 +++++----- packages/han/lib/hooks/hook-config.ts | 73 +++++++------------ packages/han/lib/hooks/index.ts | 1 + 5 files changed, 55 insertions(+), 107 deletions(-) diff --git a/packages/han/lib/commands/plugin/generate-hooks.ts b/packages/han/lib/commands/plugin/generate-hooks.ts index e95603793..9f536fb70 100644 --- a/packages/han/lib/commands/plugin/generate-hooks.ts +++ b/packages/han/lib/commands/plugin/generate-hooks.ts @@ -21,6 +21,7 @@ import { basename, join } from 'node:path'; import type { Command } from 'commander'; import { parse as parseYaml } from 'yaml'; import { + HOOK_EVENT_TYPES, type HookEventType, parseEventShorthands, } from '../../hooks/hook-config.ts'; @@ -144,38 +145,7 @@ export function generateHooksJson( // Build the final hooks object with deterministic key order. // Events not in this list are appended in sorted order so that // newly added Claude Code events are never silently dropped. - const orderedEvents: HookEventType[] = [ - 'SessionStart', - 'Setup', - 'UserPromptSubmit', - 'UserPromptExpansion', - 'PreToolUse', - 'PermissionRequest', - 'PermissionDenied', - 'PostToolUse', - 'PostToolUseFailure', - 'PostToolBatch', - 'Notification', - 'MessageDisplay', - 'Stop', - 'SubagentStop', - 'SubagentStart', - 'TaskCreated', - 'TaskCompleted', - 'StopFailure', - 'TeammateIdle', - 'InstructionsLoaded', - 'ConfigChange', - 'CwdChanged', - 'FileChanged', - 'WorktreeCreate', - 'WorktreeRemove', - 'PreCompact', - 'PostCompact', - 'Elicitation', - 'ElicitationResult', - 'SessionEnd', - ]; + const orderedEvents: readonly HookEventType[] = HOOK_EVENT_TYPES; const orderedKeys = [ ...orderedEvents, diff --git a/packages/han/lib/hook-explain-ui.tsx b/packages/han/lib/hook-explain-ui.tsx index ca7d92709..691f306a8 100644 --- a/packages/han/lib/hook-explain-ui.tsx +++ b/packages/han/lib/hook-explain-ui.tsx @@ -1,7 +1,7 @@ import { Box, Text } from 'ink'; import type React from 'react'; import type { SettingsScope } from './config/claude-settings.ts'; -import type { HookDependency } from './hooks/hook-config.ts'; +import { HOOK_EVENT_TYPES, type HookDependency } from './hooks/hook-config.ts'; /** * Hook types where stdout is meant to inject context into Claude's conversation. @@ -230,18 +230,11 @@ const HookTypeSection: React.FC<{ }; /** - * Helper to sort hook types in logical order + * Sort hook types into the canonical Claude Code lifecycle order, with + * anything unrecognized after them alphabetically. */ function sortHookTypes(types: string[]): string[] { - const eventOrder = [ - 'SessionStart', - 'UserPromptSubmit', - 'PreToolUse', - 'PostToolUse', - 'Stop', - 'SubagentStart', - 'SubagentStop', - ]; + const eventOrder: readonly string[] = HOOK_EVENT_TYPES; return types.sort((a, b) => { const aIdx = eventOrder.indexOf(a); const bIdx = eventOrder.indexOf(b); diff --git a/packages/han/lib/hook-test-ui.tsx b/packages/han/lib/hook-test-ui.tsx index 21d03b48e..a32b17bd1 100644 --- a/packages/han/lib/hook-test-ui.tsx +++ b/packages/han/lib/hook-test-ui.tsx @@ -17,7 +17,7 @@ interface HookStructureItem { plugin: string; command: string; pluginDir: string; - type: 'command' | 'prompt'; + type: 'command' | 'http' | 'mcp_tool' | 'prompt' | 'agent'; timeout?: number; } @@ -550,8 +550,7 @@ export const HookTestUI: React.FC = ({ isRunning && liveLines ? liveLines : result?.output || []; const visibleLines = outputLines .slice(scrollOffset, scrollOffset + viewportHeight) - .map((line, i) => ({ lineNum: scrollOffset + i, line } as const) - ); + .map((line, i) => ({ lineNum: scrollOffset + i, line }) as const); const canScrollUp = scrollOffset > 0; const canScrollDown = scrollOffset + viewportHeight < outputLines.length; @@ -703,13 +702,15 @@ export const HookTestUI: React.FC = ({ {result.output.length > 0 && ( - {Array.from(result.output.slice(0, 10).entries()).map(([pos, line]) => ( - - {line} - - ))} + {Array.from(result.output.slice(0, 10).entries()).map( + ([pos, line]) => ( + + {line} + + ) + )} {result.output.length > 10 && ( ... and {result.output.length - 10} more lines @@ -749,14 +750,16 @@ export const HookTestUI: React.FC = ({ {result.output.length > 0 && ( - {Array.from(result.output.entries()).map(([pos, line]) => ( - - {line} - - ))} + {Array.from(result.output.entries()).map( + ([pos, line]) => ( + + {line} + + ) + )} )} diff --git a/packages/han/lib/hooks/hook-config.ts b/packages/han/lib/hooks/hook-config.ts index fd6327d2f..2f67d1928 100644 --- a/packages/han/lib/hooks/hook-config.ts +++ b/packages/han/lib/hooks/hook-config.ts @@ -12,46 +12,15 @@ import { getPluginNameFromRoot } from '../shared/index.ts'; import { findDirectoriesWithMarkers } from './hook-cache.ts'; /** - * Claude Code hook event types that can trigger han hooks. - * Complete as of Claude Code 2.1.215. - */ -export type HookEventType = - | 'SessionStart' - | 'Setup' - | 'UserPromptSubmit' - | 'UserPromptExpansion' - | 'PreToolUse' - | 'PermissionRequest' - | 'PermissionDenied' - | 'PostToolUse' - | 'PostToolUseFailure' - | 'PostToolBatch' - | 'Notification' - | 'MessageDisplay' - | 'SubagentStart' - | 'SubagentStop' - | 'TaskCreated' - | 'TaskCompleted' - | 'Stop' - | 'StopFailure' - | 'TeammateIdle' - | 'InstructionsLoaded' - | 'ConfigChange' - | 'CwdChanged' - | 'FileChanged' - | 'WorktreeCreate' - | 'WorktreeRemove' - | 'PreCompact' - | 'PostCompact' - | 'Elicitation' - | 'ElicitationResult' - | 'SessionEnd'; - -/** - * Valid base event types for shorthand parsing validation. - * Complete as of Claude Code 2.1.215. + * Every Claude Code hook event, in the order han writes them into a generated + * `hooks.json`. This is the single source of truth: the `HookEventType` union, + * shorthand validation, and hooks.json key ordering all derive from it, so a + * new upstream event is added in exactly one place. + * + * Complete as of Claude Code 2.1.228. + * See https://code.claude.com/docs/en/plugins-reference#hooks */ -const VALID_EVENT_TYPES = new Set([ +export const HOOK_EVENT_TYPES = [ 'SessionStart', 'Setup', 'UserPromptSubmit', @@ -64,16 +33,17 @@ const VALID_EVENT_TYPES = new Set([ 'PostToolBatch', 'Notification', 'MessageDisplay', - 'SubagentStart', + 'Stop', 'SubagentStop', + 'SubagentStart', 'TaskCreated', 'TaskCompleted', - 'Stop', 'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged', + 'DirectoryAdded', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', @@ -82,7 +52,17 @@ const VALID_EVENT_TYPES = new Set([ 'Elicitation', 'ElicitationResult', 'SessionEnd', -]); +] as const; + +/** + * Claude Code hook event types that can trigger han hooks. + */ +export type HookEventType = (typeof HOOK_EVENT_TYPES)[number]; + +/** + * Valid base event types for shorthand parsing validation. + */ +const VALID_EVENT_TYPES: ReadonlySet = new Set(HOOK_EVENT_TYPES); /** * Parsed event from shorthand syntax. @@ -525,17 +505,18 @@ export function loadPluginConfig( return null; } - const config = convertYamlPluginConfig(yamlConfig); - + // Validate the raw YAML, not the converted config. The validator's schema + // describes han-plugin.yml's snake_case surface, so validating after + // conversion would reject every camelCase field it produces. if (validate) { - const result = validatePluginConfig(config); + const result = validatePluginConfig(yamlConfig); if (!result.valid) { console.error(formatValidationErrors(yamlPath, result)); return null; } } - return config; + return convertYamlPluginConfig(yamlConfig); } catch (error) { console.error(`Error loading plugin config from ${yamlPath}:`, error); return null; diff --git a/packages/han/lib/hooks/index.ts b/packages/han/lib/hooks/index.ts index 8d09e77c8..438ec6d3e 100644 --- a/packages/han/lib/hooks/index.ts +++ b/packages/han/lib/hooks/index.ts @@ -46,6 +46,7 @@ export { getHookConfigs, getHookDefinition, getHookEvents, + HOOK_EVENT_TYPES, type HookCategory, type HookDependency, type HookEventType, From 5fac2e267f0912b49b066956fe3904089a8935df Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 00:54:46 -0600 Subject: [PATCH 04/10] fix(cli): bring plugin manifest validation to the 2.1.228 schema han plugin validate knew three plugin.json fields and treated a missing version as an error. Upstream makes name the only required field and ignores fields it does not recognize, so validate now knows the full 25 field set, including workflows, outputStyles, lspServers, experimental.themes, experimental.monitors, userConfig, channels, dependencies, defaultEnabled, displayName, metadata and strict, and reports anything else as a warning rather than failing the plugin. Declaring themes or monitors at the top level warns about the coming experimental.* requirement. The han-plugin.yml hook allow-list had drifted from what the code reads. It listed camelCase keys that never appear in YAML and omitted timeout, so any plugin setting a hook timeout failed to load. It now matches the fields the two readers actually consume, hook-config.ts plus sync from generate-hooks.ts, and the per-field type checks read the snake_case keys instead of camelCase ones that were never present. loadPluginConfig validated the converted camelCase config against a schema that describes the snake_case YAML surface, so validation was checking a shape that file never has. It now validates the raw YAML before conversion. --- .claude-plugin/marketplace.json | 1 + packages/han/lib/commands/plugin/validate.ts | 165 +++++++++++++--- packages/han/lib/config/config-validator.ts | 93 +++++---- .../han/test/config-validator-unit.test.ts | 72 ++++--- .../han/test/plugin-manifest-validate.test.ts | 183 ++++++++++++++++++ 5 files changed, 410 insertions(+), 104 deletions(-) create mode 100644 packages/han/test/plugin-manifest-validate.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dff48e449..12962f8e9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", "name": "han", "owner": { "name": "The Bushido Collective", diff --git a/packages/han/lib/commands/plugin/validate.ts b/packages/han/lib/commands/plugin/validate.ts index 3d2e65655..2862fbc08 100644 --- a/packages/han/lib/commands/plugin/validate.ts +++ b/packages/han/lib/commands/plugin/validate.ts @@ -14,13 +14,13 @@ import { parse as parseYaml } from 'yaml'; import { validatePluginConfig } from '../../config/config-validator.ts'; import { generateHooksForPlugin } from './generate-hooks.ts'; -interface ValidationIssue { +export interface ValidationIssue { type: 'error' | 'warning'; path: string; message: string; } -interface PluginValidationResult { +export interface PluginValidationResult { pluginPath: string; pluginName: string; issues: ValidationIssue[]; @@ -46,10 +46,52 @@ function findFiles(dir: string, filename: string): string[] { return results; } +/** + * Top-level fields recognized by the Claude Code plugin manifest schema. + * + * Source: https://code.claude.com/docs/en/plugins-reference + * Claude Code ignores top-level fields it does not recognize, so anything + * outside this set is reported as a warning rather than an error. + */ +const RECOGNIZED_MANIFEST_FIELDS: Record = { + $schema: true, + name: true, + displayName: true, + version: true, + description: true, + author: true, + homepage: true, + repository: true, + license: true, + keywords: true, + metadata: true, + defaultEnabled: true, + strict: true, + skills: true, + commands: true, + agents: true, + workflows: true, + hooks: true, + mcpServers: true, + outputStyles: true, + lspServers: true, + experimental: true, + userConfig: true, + channels: true, + dependencies: true, +}; + +/** + * Component keys that belong under the manifest's `experimental` key. + * Declaring them at the top level still works, but a future Claude Code + * release will require `experimental.*`. + */ +const EXPERIMENTAL_COMPONENT_KEYS: readonly string[] = ['themes', 'monitors']; + /** * Validate a single plugin directory */ -function validatePlugin(pluginPath: string): PluginValidationResult { +export function validatePlugin(pluginPath: string): PluginValidationResult { const issues: ValidationIssue[] = []; const pluginName = basename(pluginPath); @@ -75,36 +117,103 @@ function validatePlugin(pluginPath: string): PluginValidationResult { } else { // Validate plugin.json try { - const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8')); - - if (!pluginJson.name) { + const parsed: unknown = JSON.parse( + readFileSync(pluginJsonPath, 'utf-8') + ); + + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { issues.push({ type: 'error', path: pluginJsonPath, - message: "Missing required 'name' field", - }); - } else if (pluginJson.name !== pluginName) { - issues.push({ - type: 'warning', - path: pluginJsonPath, - message: `Plugin name '${pluginJson.name}' doesn't match directory name '${pluginName}'`, + message: 'plugin.json must contain a JSON object', }); - } + } else { + const manifest = parsed as Record; - if (!pluginJson.version) { - issues.push({ - type: 'error', - path: pluginJsonPath, - message: "Missing required 'version' field", - }); - } + if (!manifest.name) { + issues.push({ + type: 'error', + path: pluginJsonPath, + message: "Missing required 'name' field", + }); + } else if (manifest.name !== pluginName) { + issues.push({ + type: 'warning', + path: pluginJsonPath, + message: `Plugin name '${manifest.name}' doesn't match directory name '${pluginName}'`, + }); + } - if (!pluginJson.description) { - issues.push({ - type: 'warning', - path: pluginJsonPath, - message: "Missing 'description' field", - }); + if (!manifest.version) { + issues.push({ + type: 'warning', + path: pluginJsonPath, + message: + "Missing 'version' field - optional upstream, but setting it pins the plugin version so users only get updates when you bump it", + }); + } + + if (!manifest.description) { + issues.push({ + type: 'warning', + path: pluginJsonPath, + message: "Missing 'description' field", + }); + } + + for (const key of Object.keys(manifest)) { + if ( + Object.hasOwn(RECOGNIZED_MANIFEST_FIELDS, key) || + EXPERIMENTAL_COMPONENT_KEYS.includes(key) + ) { + continue; + } + issues.push({ + type: 'warning', + path: pluginJsonPath, + message: `Unrecognized field '${key}' - Claude Code ignores top-level fields it does not recognize`, + }); + } + + if (Object.hasOwn(manifest, 'experimental')) { + const experimental = manifest.experimental; + if ( + typeof experimental !== 'object' || + experimental === null || + Array.isArray(experimental) + ) { + issues.push({ + type: 'warning', + path: pluginJsonPath, + message: + "'experimental' must be an object - Claude Code ignores a non-object value", + }); + } else { + for (const key of Object.keys(experimental)) { + if (!EXPERIMENTAL_COMPONENT_KEYS.includes(key)) { + issues.push({ + type: 'warning', + path: pluginJsonPath, + message: `Unrecognized 'experimental.${key}' - recognized experimental components are ${EXPERIMENTAL_COMPONENT_KEYS.join(', ')}`, + }); + } + } + } + } + + for (const key of EXPERIMENTAL_COMPONENT_KEYS) { + if (Object.hasOwn(manifest, key)) { + issues.push({ + type: 'warning', + path: pluginJsonPath, + message: `'${key}' at the top level still loads, but a future Claude Code release will require 'experimental.${key}'`, + }); + } + } } } catch { issues.push({ @@ -124,7 +233,7 @@ function validatePlugin(pluginPath: string): PluginValidationResult { type: 'error', path: join(claudePluginDir, file), message: - 'Should not be in .claude-plugin/ - move to plugin root or hooks/', + "Unexpected file in .claude-plugin/ - a plugin's .claude-plugin/ holds only plugin.json; move hooks to hooks/ and everything else to the plugin root", }); } } diff --git a/packages/han/lib/config/config-validator.ts b/packages/han/lib/config/config-validator.ts index 130f80d46..901a4781d 100644 --- a/packages/han/lib/config/config-validator.ts +++ b/packages/han/lib/config/config-validator.ts @@ -63,26 +63,26 @@ export function validatePluginConfig(config: unknown): ValidationResult { }); } - // Optional: dirsWith (array of strings) - if ('dirsWith' in hook) { - if (!Array.isArray(hook.dirsWith)) { + // Optional: dirs_with (array of strings) + if ('dirs_with' in hook) { + if (!Array.isArray(hook.dirs_with)) { errors.push({ - path: `${hookPath}.dirsWith`, - message: "'dirsWith' must be an array", + path: `${hookPath}.dirs_with`, + message: "'dirs_with' must be an array", }); - } else if (!hook.dirsWith.every((item) => typeof item === 'string')) { + } else if (!hook.dirs_with.every((item) => typeof item === 'string')) { errors.push({ - path: `${hookPath}.dirsWith`, - message: "'dirsWith' must contain only strings", + path: `${hookPath}.dirs_with`, + message: "'dirs_with' must contain only strings", }); } } - // Optional: dirTest (string) - if ('dirTest' in hook && typeof hook.dirTest !== 'string') { + // Optional: dir_test (string) + if ('dir_test' in hook && typeof hook.dir_test !== 'string') { errors.push({ - path: `${hookPath}.dirTest`, - message: "'dirTest' must be a string", + path: `${hookPath}.dir_test`, + message: "'dir_test' must be a string", }); } @@ -94,45 +94,45 @@ export function validatePluginConfig(config: unknown): ValidationResult { }); } - // Optional: ifChanged (array of strings) - if ('ifChanged' in hook) { - if (!Array.isArray(hook.ifChanged)) { + // Optional: if_changed (array of strings) + if ('if_changed' in hook) { + if (!Array.isArray(hook.if_changed)) { errors.push({ - path: `${hookPath}.ifChanged`, - message: "'ifChanged' must be an array", + path: `${hookPath}.if_changed`, + message: "'if_changed' must be an array", }); - } else if (!hook.ifChanged.every((item) => typeof item === 'string')) { + } else if (!hook.if_changed.every((item) => typeof item === 'string')) { errors.push({ - path: `${hookPath}.ifChanged`, - message: "'ifChanged' must contain only strings", + path: `${hookPath}.if_changed`, + message: "'if_changed' must contain only strings", }); } } - // Optional: idleTimeout (positive integer, in seconds) - if ('idleTimeout' in hook) { + // Optional: idle_timeout (positive integer, in seconds) + if ('idle_timeout' in hook) { if ( - typeof hook.idleTimeout !== 'number' || - !Number.isInteger(hook.idleTimeout) || - hook.idleTimeout < 0 + typeof hook.idle_timeout !== 'number' || + !Number.isInteger(hook.idle_timeout) || + hook.idle_timeout < 0 ) { errors.push({ - path: `${hookPath}.idleTimeout`, - message: "'idleTimeout' must be a non-negative integer (seconds)", + path: `${hookPath}.idle_timeout`, + message: "'idle_timeout' must be a non-negative integer (seconds)", }); } } - // Optional: dependsOn (array of dependencies) - if ('dependsOn' in hook) { - if (!Array.isArray(hook.dependsOn)) { + // Optional: depends_on (array of dependencies) + if ('depends_on' in hook) { + if (!Array.isArray(hook.depends_on)) { errors.push({ - path: `${hookPath}.dependsOn`, - message: "'dependsOn' must be an array", + path: `${hookPath}.depends_on`, + message: "'depends_on' must be an array", }); } else { - for (const [idx, dep] of hook.dependsOn.entries()) { - const depPath = `${hookPath}.dependsOn[${idx}]`; + for (const [idx, dep] of hook.depends_on.entries()) { + const depPath = `${hookPath}.depends_on[${idx}]`; if (typeof dep !== 'object' || dep === null) { errors.push({ @@ -182,28 +182,25 @@ export function validatePluginConfig(config: unknown): ValidationResult { } } - // Check for unknown properties (support both camelCase and snake_case) + // Check for unknown properties. + // This list is the union of every han-plugin.yml hook field the code + // actually reads: YamlPluginHookDefinition in lib/hooks/hook-config.ts for + // the hook runtime, plus `sync`, which only + // lib/commands/plugin/generate-hooks.ts reads (`const isAsync = + // !hookDef.sync`). Keep all three in sync. const validProperties = [ - 'command', - 'dirsWith', + 'event', + 'tool_filter', 'dirs_with', - 'dirTest', 'dir_test', + 'command', 'description', - 'ifChanged', 'if_changed', - 'idleTimeout', + 'timeout', 'idle_timeout', - 'dependsOn', - 'depends_on', 'tip', - 'event', - 'toolFilter', - 'tool_filter', + 'depends_on', 'mcp', - 'silent', - 'test_dir', - 'file_test', 'sync', ]; for (const key of Object.keys(hook)) { diff --git a/packages/han/test/config-validator-unit.test.ts b/packages/han/test/config-validator-unit.test.ts index a4297b66a..ba84d43ac 100644 --- a/packages/han/test/config-validator-unit.test.ts +++ b/packages/han/test/config-validator-unit.test.ts @@ -22,16 +22,22 @@ describe('Config Validator', () => { expect(result.errors).toHaveLength(0); }); - test('validates config with all hook properties', () => { + test('validates every YamlPluginHookDefinition property', () => { const result = validatePluginConfig({ hooks: { test: { command: 'npm test', - dirsWith: ['package.json'], - dirTest: 'test -f jest.config.ts', + event: 'Stop', + tool_filter: ['Edit', 'Write'], + dirs_with: ['package.json'], + dir_test: 'test -f jest.config.ts', description: 'Run tests', - ifChanged: ['**/*.ts', '**/*.tsx'], - idleTimeout: 30, + if_changed: ['**/*.ts', '**/*.tsx'], + timeout: 60000, + idle_timeout: 30, + tip: 'Use the jutsu_jest MCP tool', + depends_on: [{ plugin: 'typescript', hook: 'typecheck' }], + mcp: true, }, }, }); @@ -133,36 +139,36 @@ describe('Config Validator', () => { ); }); - test('rejects dirsWith as non-array', () => { + test('rejects dirs_with as non-array', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', dirsWith: 'package.json' }, + lint: { command: 'lint', dirs_with: 'package.json' }, }, }); expect(result.valid).toBe(false); - expect(result.errors[0].message).toBe("'dirsWith' must be an array"); + expect(result.errors[0].message).toBe("'dirs_with' must be an array"); }); - test('rejects dirsWith with non-strings', () => { + test('rejects dirs_with with non-strings', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', dirsWith: ['package.json', 123] }, + lint: { command: 'lint', dirs_with: ['package.json', 123] }, }, }); expect(result.valid).toBe(false); expect(result.errors[0].message).toBe( - "'dirsWith' must contain only strings" + "'dirs_with' must contain only strings" ); }); - test('rejects dirTest as non-string', () => { + test('rejects dir_test as non-string', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', dirTest: 123 }, + lint: { command: 'lint', dir_test: 123 }, }, }); expect(result.valid).toBe(false); - expect(result.errors[0].message).toBe("'dirTest' must be a string"); + expect(result.errors[0].message).toBe("'dir_test' must be a string"); }); test('rejects description as non-string', () => { @@ -175,54 +181,64 @@ describe('Config Validator', () => { expect(result.errors[0].message).toBe("'description' must be a string"); }); - test('rejects ifChanged as non-array', () => { + test('rejects if_changed as non-array', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', ifChanged: '**/*.ts' }, + lint: { command: 'lint', if_changed: '**/*.ts' }, }, }); expect(result.valid).toBe(false); - expect(result.errors[0].message).toBe("'ifChanged' must be an array"); + expect(result.errors[0].message).toBe("'if_changed' must be an array"); }); - test('rejects ifChanged with non-strings', () => { + test('rejects if_changed with non-strings', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', ifChanged: ['**/*.ts', 123] }, + lint: { command: 'lint', if_changed: ['**/*.ts', 123] }, }, }); expect(result.valid).toBe(false); expect(result.errors[0].message).toBe( - "'ifChanged' must contain only strings" + "'if_changed' must contain only strings" ); }); - test('rejects idleTimeout as non-number', () => { + test('rejects idle_timeout as non-number', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', idleTimeout: '30000' }, + lint: { command: 'lint', idle_timeout: '30000' }, }, }); expect(result.valid).toBe(false); - expect(result.errors[0].message).toContain("'idleTimeout'"); + expect(result.errors[0].message).toContain("'idle_timeout'"); }); - test('rejects idleTimeout as float', () => { + test('rejects idle_timeout as float', () => { + const result = validatePluginConfig({ + hooks: { + lint: { command: 'lint', idle_timeout: 30.5 }, + }, + }); + expect(result.valid).toBe(false); + }); + + test('rejects negative idle_timeout', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', idleTimeout: 30.5 }, + lint: { command: 'lint', idle_timeout: -1000 }, }, }); expect(result.valid).toBe(false); }); - test('rejects negative idleTimeout', () => { + test('rejects camelCase keys absent from the YAML schema', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 'lint', idleTimeout: -1000 }, + lint: { command: 'lint', dirsWith: ['package.json'] }, }, }); expect(result.valid).toBe(false); + expect(result.errors[0].message).toBe("Unknown property 'dirsWith'"); }); test('rejects unknown hook properties', () => { @@ -242,7 +258,7 @@ describe('Config Validator', () => { test('collects multiple errors', () => { const result = validatePluginConfig({ hooks: { - lint: { command: 123, dirsWith: 'invalid' }, + lint: { command: 123, dirs_with: 'invalid' }, }, }); expect(result.valid).toBe(false); diff --git a/packages/han/test/plugin-manifest-validate.test.ts b/packages/han/test/plugin-manifest-validate.test.ts new file mode 100644 index 000000000..084904e20 --- /dev/null +++ b/packages/han/test/plugin-manifest-validate.test.ts @@ -0,0 +1,183 @@ +/** + * Tests for plugin.json manifest validation in `han plugin validate`. + * + * The manifest schema tracked here is the Claude Code plugin manifest: + * https://code.claude.com/docs/en/plugins-reference + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + type ValidationIssue, + validatePlugin, +} from '../lib/commands/plugin/validate.ts'; + +const PLUGIN_NAME = 'manifest-fixture'; + +let testDir: string; +let pluginDir: string; + +beforeEach(() => { + const random = Math.random().toString(36).substring(2, 9); + testDir = join(tmpdir(), `han-manifest-validate-${Date.now()}-${random}`); + pluginDir = join(testDir, PLUGIN_NAME); + mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true }); +}); + +afterEach(() => { + if (testDir && existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } +}); + +/** Write a plugin.json manifest and validate the fixture plugin. */ +function validateManifest(manifest: unknown): ValidationIssue[] { + writeFileSync( + join(pluginDir, '.claude-plugin', 'plugin.json'), + JSON.stringify(manifest, null, 2) + ); + return validatePlugin(pluginDir).issues; +} + +function messages(issues: ValidationIssue[], type: ValidationIssue['type']) { + return issues.filter((i) => i.type === type).map((i) => i.message); +} + +describe('plugin.json manifest validation', () => { + test('a complete recognized manifest produces no issues', () => { + const issues = validateManifest({ + $schema: 'https://json.schemastore.org/claude-code-plugin-manifest.json', + name: PLUGIN_NAME, + displayName: 'Manifest Fixture', + version: '1.0.0', + description: 'Fixture plugin', + author: { name: 'Dev' }, + homepage: 'https://example.com', + repository: 'https://github.com/example/plugin', + license: 'MIT', + keywords: ['fixture'], + metadata: { catalogId: 'cat-1' }, + defaultEnabled: false, + strict: true, + skills: './skills/', + commands: ['./commands/deploy.md'], + agents: ['./agents/reviewer.md'], + workflows: './workflows/', + hooks: './hooks/hooks.json', + mcpServers: './.mcp.json', + outputStyles: './output-styles/', + lspServers: './.lsp.json', + experimental: { themes: './themes/', monitors: './monitors.json' }, + userConfig: {}, + channels: [], + dependencies: ['helper-lib'], + }); + + expect(issues).toEqual([]); + }); + + test('an unrecognized top-level field is a warning, not an error', () => { + const issues = validateManifest({ + name: PLUGIN_NAME, + version: '1.0.0', + description: 'Fixture plugin', + contributes: { commands: [] }, + }); + + expect(messages(issues, 'error')).toEqual([]); + expect(messages(issues, 'warning')).toEqual([ + "Unrecognized field 'contributes' - Claude Code ignores top-level fields it does not recognize", + ]); + expect(validatePlugin(pluginDir).valid).toBe(true); + }); + + test('a field named after an Object.prototype member still warns', () => { + const issues = validateManifest({ + name: PLUGIN_NAME, + version: '1.0.0', + description: 'Fixture plugin', + constructor: 'nonsense', + toString: 'nonsense', + }); + + expect(messages(issues, 'error')).toEqual([]); + expect(messages(issues, 'warning')).toEqual([ + "Unrecognized field 'constructor' - Claude Code ignores top-level fields it does not recognize", + "Unrecognized field 'toString' - Claude Code ignores top-level fields it does not recognize", + ]); + }); + + test("a missing 'version' field is a warning, not an error", () => { + const issues = validateManifest({ + name: PLUGIN_NAME, + description: 'Fixture plugin', + }); + + expect(messages(issues, 'error')).toEqual([]); + expect( + messages(issues, 'warning').some((m) => m.includes("Missing 'version'")) + ).toBe(true); + expect(validatePlugin(pluginDir).valid).toBe(true); + }); + + test("a missing 'name' field is still an error", () => { + const issues = validateManifest({ version: '1.0.0' }); + + expect(messages(issues, 'error')).toContain( + "Missing required 'name' field" + ); + }); + + test("a non-object 'experimental' value is a warning", () => { + const issues = validateManifest({ + name: PLUGIN_NAME, + version: '1.0.0', + description: 'Fixture plugin', + experimental: './experimental/', + }); + + expect(messages(issues, 'error')).toEqual([]); + expect(messages(issues, 'warning')).toEqual([ + "'experimental' must be an object - Claude Code ignores a non-object value", + ]); + }); + + test("an unknown 'experimental' subkey is a warning", () => { + const issues = validateManifest({ + name: PLUGIN_NAME, + version: '1.0.0', + description: 'Fixture plugin', + experimental: { themes: './themes/', widgets: './widgets/' }, + }); + + expect(messages(issues, 'error')).toEqual([]); + expect(messages(issues, 'warning')).toEqual([ + "Unrecognized 'experimental.widgets' - recognized experimental components are themes, monitors", + ]); + }); + + test('top-level themes and monitors get a migration warning', () => { + const issues = validateManifest({ + name: PLUGIN_NAME, + version: '1.0.0', + description: 'Fixture plugin', + themes: './themes/', + monitors: './monitors.json', + }); + + expect(messages(issues, 'error')).toEqual([]); + expect(messages(issues, 'warning')).toEqual([ + "'themes' at the top level still loads, but a future Claude Code release will require 'experimental.themes'", + "'monitors' at the top level still loads, but a future Claude Code release will require 'experimental.monitors'", + ]); + }); + + test('a manifest that is not a JSON object is an error', () => { + const issues = validateManifest(['not', 'a', 'manifest']); + + expect(messages(issues, 'error')).toEqual([ + 'plugin.json must contain a JSON object', + ]); + }); +}); From 53af41edbd18aab80b2b312ce7fe2634a2619416 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 01:24:18 -0600 Subject: [PATCH 05/10] fix(plugins): bring every plugin under han's own integrity checks Twenty-one plugins shipped a hooks/hooks.json with no han-plugin.yml, so generate-hooks --all --check and han plugin validate never saw them and their generated hooks could drift forever undetected. Each now has a han-plugin.yml that regenerates its committed hooks.json byte for byte; --check goes from 70 to 88 files covered with zero hooks.json content changed. Also removes han-plugin.yml config that nothing reads, which the tightened validator would otherwise treat as fatal: - file_test in 7 plugins. Declared only as an unread interface field in generate-hooks.ts; the if_changed globs above it already scope those hooks. - test_dir in android. A typo for dir_test, so the guard that was supposed to limit the build hook to Android projects never applied. Renamed, which restores the intended behaviour. - mcp: null in graphql-inspector and syncpack. No reader, null value. - agents and orchestrator_guidance in playwright-mcp. No reader, and the content is already carried by agents/*/AGENT.md, which Claude Code discovers natively. - A dead top-level dependencies key in session-code-review. plugin-install.ts hardcodes bushido and core regardless. learn_patterns and sync are the opposite case: both are live, so they are added to the validator allow-list rather than stripped from the 13 plugins using them. han plugin validate is now clean across all 160 plugins, 0 errors 0 warnings. --- packages/han/lib/config/config-validator.ts | 18 +++---- plugins/frameworks/angular/han-plugin.yml | 12 +++++ plugins/frameworks/django/han-plugin.yml | 19 ++++++++ plugins/frameworks/expo/han-plugin.yml | 13 +++++ plugins/frameworks/nestjs/han-plugin.yml | 12 +++++ plugins/frameworks/nextjs/han-plugin.yml | 11 +++++ plugins/languages/c/han-plugin.yml | 19 ++++++++ plugins/languages/cpp/han-plugin.yml | 19 ++++++++ plugins/languages/csharp/han-plugin.yml | 33 +++++++++++++ plugins/languages/elixir/han-plugin.yml | 1 - plugins/languages/java/han-plugin.yml | 30 ++++++++++++ plugins/languages/lua/han-plugin.yml | 18 +++++++ plugins/languages/objective-c/han-plugin.yml | 22 +++++++++ plugins/languages/php/han-plugin.yml | 30 ++++++++++++ plugins/languages/python/han-plugin.yml | 35 ++++++++++++++ plugins/patterns/bdd/han-plugin.yml | 7 +++ plugins/patterns/git-worktrees/CHANGELOG.md | 18 +++++++ plugins/patterns/tdd/han-plugin.yml | 7 +++ plugins/services/canva/han-plugin.yml | 12 +++++ .../services/playwright-mcp/han-plugin.yml | 48 ------------------- plugins/specialized/android/han-plugin.yml | 2 +- plugins/tools/absinthe-graphql/han-plugin.yml | 16 +++++++ plugins/tools/apollo-graphql/han-plugin.yml | 13 +++++ plugins/tools/cucumber/han-plugin.yml | 21 ++++++++ .../tools/graphql-inspector/han-plugin.yml | 1 - plugins/tools/graphql/han-plugin.yml | 17 +++++++ plugins/tools/jest/han-plugin.yml | 1 - plugins/tools/kubernetes/han-plugin.yml | 11 +++++ plugins/tools/mocha/han-plugin.yml | 1 - plugins/tools/playwright/han-plugin.yml | 1 - plugins/tools/pytest/han-plugin.yml | 1 - plugins/tools/rspec/han-plugin.yml | 1 - plugins/tools/syncpack/han-plugin.yml | 2 - plugins/tools/vitest/han-plugin.yml | 1 - .../session-code-review/CHANGELOG.md | 22 +++++++++ .../session-code-review/han-plugin.yml | 11 ++++- .../skills/code-review/SKILL.md | 1 + 37 files changed, 438 insertions(+), 69 deletions(-) create mode 100644 plugins/frameworks/angular/han-plugin.yml create mode 100644 plugins/frameworks/django/han-plugin.yml create mode 100644 plugins/frameworks/expo/han-plugin.yml create mode 100644 plugins/frameworks/nestjs/han-plugin.yml create mode 100644 plugins/frameworks/nextjs/han-plugin.yml create mode 100644 plugins/languages/c/han-plugin.yml create mode 100644 plugins/languages/cpp/han-plugin.yml create mode 100644 plugins/languages/csharp/han-plugin.yml create mode 100644 plugins/languages/java/han-plugin.yml create mode 100644 plugins/languages/lua/han-plugin.yml create mode 100644 plugins/languages/objective-c/han-plugin.yml create mode 100644 plugins/languages/php/han-plugin.yml create mode 100644 plugins/languages/python/han-plugin.yml create mode 100644 plugins/patterns/bdd/han-plugin.yml create mode 100644 plugins/patterns/git-worktrees/CHANGELOG.md create mode 100644 plugins/patterns/tdd/han-plugin.yml create mode 100644 plugins/services/canva/han-plugin.yml create mode 100644 plugins/tools/absinthe-graphql/han-plugin.yml create mode 100644 plugins/tools/apollo-graphql/han-plugin.yml create mode 100644 plugins/tools/cucumber/han-plugin.yml create mode 100644 plugins/tools/graphql/han-plugin.yml create mode 100644 plugins/tools/kubernetes/han-plugin.yml create mode 100644 plugins/validation/session-code-review/CHANGELOG.md diff --git a/packages/han/lib/config/config-validator.ts b/packages/han/lib/config/config-validator.ts index 901a4781d..3183cb6a2 100644 --- a/packages/han/lib/config/config-validator.ts +++ b/packages/han/lib/config/config-validator.ts @@ -183,11 +183,12 @@ export function validatePluginConfig(config: unknown): ValidationResult { } // Check for unknown properties. - // This list is the union of every han-plugin.yml hook field the code - // actually reads: YamlPluginHookDefinition in lib/hooks/hook-config.ts for - // the hook runtime, plus `sync`, which only - // lib/commands/plugin/generate-hooks.ts reads (`const isAsync = - // !hookDef.sync`). Keep all three in sync. + // This list is every han-plugin.yml hook field han actually reads: + // YamlPluginHookDefinition in lib/hooks/hook-config.ts for the hook + // runtime, plus `sync`, which only lib/commands/plugin/generate-hooks.ts + // reads (`const isAsync = !hookDef.sync`). An unknown key here stops the + // whole plugin from loading, so a field must be listed the moment any + // reader consumes it. Keep all three in sync. const validProperties = [ 'event', 'tool_filter', @@ -213,9 +214,10 @@ export function validatePluginConfig(config: unknown): ValidationResult { } } - // Check for unknown top-level properties - strict validation - // Valid properties: hooks (required), mcp_servers, memory - const validTopLevel = ['hooks', 'mcp_servers', 'memory']; + // Check for unknown top-level properties - strict validation. + // `learn_patterns` is read by lib/marker-detection.ts and surfaced as + // detection.learnPatterns for prompt-based auto-detection. + const validTopLevel = ['hooks', 'mcp_servers', 'memory', 'learn_patterns']; for (const key of Object.keys(configObj)) { if (!validTopLevel.includes(key)) { errors.push({ path: key, message: `Unknown property '${key}'` }); diff --git a/plugins/frameworks/angular/han-plugin.yml b/plugins/frameworks/angular/han-plugin.yml new file mode 100644 index 000000000..a8133429e --- /dev/null +++ b/plugins/frameworks/angular/han-plugin.yml @@ -0,0 +1,12 @@ +hooks: + build: + event: Stop + command: "npx ng build" + dirs_with: + - "angular.json" + if_changed: + - "**/*.ts" + - "**/*.html" + - "**/*.{css,scss}" + - "angular.json" + - "tsconfig*.json" diff --git a/plugins/frameworks/django/han-plugin.yml b/plugins/frameworks/django/han-plugin.yml new file mode 100644 index 000000000..6cb116d0b --- /dev/null +++ b/plugins/frameworks/django/han-plugin.yml @@ -0,0 +1,19 @@ +hooks: + check: + event: Stop + command: "python manage.py check" + dirs_with: + - "manage.py" + if_changed: + - "**/*.py" + - "**/*.html" + - "manage.py" + + test: + event: Stop + command: "python manage.py test" + dirs_with: + - "manage.py" + if_changed: + - "**/*.py" + - "manage.py" diff --git a/plugins/frameworks/expo/han-plugin.yml b/plugins/frameworks/expo/han-plugin.yml new file mode 100644 index 000000000..f6de0b529 --- /dev/null +++ b/plugins/frameworks/expo/han-plugin.yml @@ -0,0 +1,13 @@ +hooks: + doctor: + event: Stop + command: "npx -y expo-doctor" + dirs_with: + - "package.json" + dir_test: "grep -qE '\"expo\":' package.json" + if_changed: + - "**/*.{ts,tsx,js,jsx}" + - "package.json" + - "app.json" + - "app.config.{js,ts}" + - "eas.json" diff --git a/plugins/frameworks/nestjs/han-plugin.yml b/plugins/frameworks/nestjs/han-plugin.yml new file mode 100644 index 000000000..d2fd04095 --- /dev/null +++ b/plugins/frameworks/nestjs/han-plugin.yml @@ -0,0 +1,12 @@ +hooks: + build: + event: Stop + command: "npx nest build" + dirs_with: + - "package.json" + dir_test: "grep -qE '\"@nestjs/core\":' package.json" + if_changed: + - "**/*.ts" + - "package.json" + - "nest-cli.json" + - "tsconfig*.json" diff --git a/plugins/frameworks/nextjs/han-plugin.yml b/plugins/frameworks/nextjs/han-plugin.yml new file mode 100644 index 000000000..ff542bc73 --- /dev/null +++ b/plugins/frameworks/nextjs/han-plugin.yml @@ -0,0 +1,11 @@ +hooks: + lint: + event: Stop + command: "npx next lint" + dirs_with: + - "package.json" + dir_test: "grep -qE '\"next\":' package.json" + if_changed: + - "**/*.{ts,tsx,js,jsx,mjs}" + - "package.json" + - "next.config.{js,mjs,ts}" diff --git a/plugins/languages/c/han-plugin.yml b/plugins/languages/c/han-plugin.yml new file mode 100644 index 000000000..55c5cb190 --- /dev/null +++ b/plugins/languages/c/han-plugin.yml @@ -0,0 +1,19 @@ +hooks: + format: + event: [Stop, PostToolUse:Edit|Write] + command: "clang-format --dry-run --Werror ${HAN_FILES}" + dirs_with: + - ".clang-format" + if_changed: + - "**/*.c" + - "**/*.h" + + build: + event: Stop + command: "make" + dirs_with: + - "Makefile" + if_changed: + - "**/*.c" + - "**/*.h" + - "Makefile" diff --git a/plugins/languages/cpp/han-plugin.yml b/plugins/languages/cpp/han-plugin.yml new file mode 100644 index 000000000..0d795b37d --- /dev/null +++ b/plugins/languages/cpp/han-plugin.yml @@ -0,0 +1,19 @@ +hooks: + format: + event: [Stop, PostToolUse:Edit|Write] + command: "clang-format --dry-run --Werror ${HAN_FILES}" + dirs_with: + - ".clang-format" + if_changed: + - "**/*.{cpp,cc,cxx}" + - "**/*.{hpp,hh,hxx}" + + build: + event: Stop + command: "make" + dirs_with: + - "Makefile" + if_changed: + - "**/*.{cpp,cc,cxx}" + - "**/*.{hpp,hh,hxx}" + - "Makefile" diff --git a/plugins/languages/csharp/han-plugin.yml b/plugins/languages/csharp/han-plugin.yml new file mode 100644 index 000000000..53f2f33d8 --- /dev/null +++ b/plugins/languages/csharp/han-plugin.yml @@ -0,0 +1,33 @@ +hooks: + format: + event: [Stop, PostToolUse:Edit|Write] + command: "dotnet format --verify-no-changes" + dirs_with: + - "*.sln" + - "*.csproj" + if_changed: + - "**/*.cs" + - "**/*.csproj" + - ".editorconfig" + + build: + event: Stop + command: "dotnet build --nologo" + dirs_with: + - "*.sln" + - "*.csproj" + if_changed: + - "**/*.cs" + - "**/*.csproj" + - "**/*.sln" + + test: + event: Stop + command: "dotnet test --nologo" + dirs_with: + - "*.sln" + - "*.csproj" + if_changed: + - "**/*.cs" + - "**/*.csproj" + - "**/*.sln" diff --git a/plugins/languages/elixir/han-plugin.yml b/plugins/languages/elixir/han-plugin.yml index 654a79f3f..e1fc010cf 100644 --- a/plugins/languages/elixir/han-plugin.yml +++ b/plugins/languages/elixir/han-plugin.yml @@ -48,7 +48,6 @@ hooks: if_changed: - "**/*_test.exs" - "**/test/**/*.exs" - file_test: grep -qE "(describe|test|doctest)" "${HAN_FILE}" typecheck: event: Stop diff --git a/plugins/languages/java/han-plugin.yml b/plugins/languages/java/han-plugin.yml new file mode 100644 index 000000000..41f9ea93a --- /dev/null +++ b/plugins/languages/java/han-plugin.yml @@ -0,0 +1,30 @@ +hooks: + build: + event: Stop + command: | + if [ -f pom.xml ]; then mvn -q -DskipTests compile + else ./gradlew --quiet classes; fi + dirs_with: + - "pom.xml" + - "build.gradle" + - "build.gradle.kts" + if_changed: + - "**/*.java" + - "pom.xml" + - "build.gradle" + - "build.gradle.kts" + + test: + event: Stop + command: | + if [ -f pom.xml ]; then mvn -q test + else ./gradlew --quiet test; fi + dirs_with: + - "pom.xml" + - "build.gradle" + - "build.gradle.kts" + if_changed: + - "**/*.java" + - "pom.xml" + - "build.gradle" + - "build.gradle.kts" diff --git a/plugins/languages/lua/han-plugin.yml b/plugins/languages/lua/han-plugin.yml new file mode 100644 index 000000000..2acef4f73 --- /dev/null +++ b/plugins/languages/lua/han-plugin.yml @@ -0,0 +1,18 @@ +hooks: + format: + event: [Stop, PostToolUse:Edit|Write] + command: "stylua --check ${HAN_FILES}" + dirs_with: + - ".stylua.toml" + - "stylua.toml" + if_changed: + - "**/*.lua" + + lint: + event: Stop + command: "luacheck ." + dirs_with: + - ".luacheckrc" + if_changed: + - "**/*.lua" + - ".luacheckrc" diff --git a/plugins/languages/objective-c/han-plugin.yml b/plugins/languages/objective-c/han-plugin.yml new file mode 100644 index 000000000..dfda4e9d8 --- /dev/null +++ b/plugins/languages/objective-c/han-plugin.yml @@ -0,0 +1,22 @@ +hooks: + format: + event: [Stop, PostToolUse:Edit|Write] + command: "clang-format --dry-run --Werror ${HAN_FILES}" + dirs_with: + - ".clang-format" + if_changed: + - "**/*.m" + - "**/*.mm" + - "**/*.h" + + build: + event: Stop + command: "xcodebuild -quiet build" + dirs_with: + - "*.xcodeproj" + - "*.xcworkspace" + if_changed: + - "**/*.m" + - "**/*.mm" + - "**/*.h" + - "**/project.pbxproj" diff --git a/plugins/languages/php/han-plugin.yml b/plugins/languages/php/han-plugin.yml new file mode 100644 index 000000000..eeb7bb23a --- /dev/null +++ b/plugins/languages/php/han-plugin.yml @@ -0,0 +1,30 @@ +hooks: + format: + event: [Stop, PostToolUse:Edit|Write] + command: "vendor/bin/php-cs-fixer fix --dry-run --diff ${HAN_FILES}" + dirs_with: + - ".php-cs-fixer.dist.php" + - ".php-cs-fixer.php" + if_changed: + - "**/*.php" + + syntax: + event: Stop + command: | + for f in ${HAN_FILES}; do php -l "$f" > /dev/null || exit 1; done + dirs_with: + - "composer.json" + if_changed: + - "**/*.php" + + test: + event: Stop + command: "vendor/bin/phpunit" + dirs_with: + - "composer.json" + dir_test: "grep -qE '\"phpunit/phpunit\"' composer.json" + if_changed: + - "**/*.php" + - "composer.json" + - "phpunit.xml" + - "phpunit.xml.dist" diff --git a/plugins/languages/python/han-plugin.yml b/plugins/languages/python/han-plugin.yml new file mode 100644 index 000000000..03613ec61 --- /dev/null +++ b/plugins/languages/python/han-plugin.yml @@ -0,0 +1,35 @@ +hooks: + format: + event: [Stop, PostToolUse:Edit|Write|NotebookEdit] + command: "ruff format --check ${HAN_FILES}" + dirs_with: + - "pyproject.toml" + dir_test: "grep -qE '^\\[tool\\.ruff|\"ruff\"' pyproject.toml" + if_changed: + - "**/*.py" + - "**/*.pyi" + - "**/*.ipynb" + + typecheck: + event: Stop + command: "npx -y pyright" + dirs_with: + - "pyproject.toml" + if_changed: + - "**/*.py" + - "**/*.pyi" + - "pyproject.toml" + - "pyrightconfig.json" + + test: + event: Stop + command: "pytest" + dirs_with: + - "pyproject.toml" + dir_test: "grep -qE '^pytest[^a-zA-Z]|\"pytest\"' pyproject.toml" + if_changed: + - "**/*.py" + - "**/*.pyi" + - "pyproject.toml" + - "pytest.ini" + - "conftest.py" diff --git a/plugins/patterns/bdd/han-plugin.yml b/plugins/patterns/bdd/han-plugin.yml new file mode 100644 index 000000000..bb339e325 --- /dev/null +++ b/plugins/patterns/bdd/han-plugin.yml @@ -0,0 +1,7 @@ +# bdd plugin configuration +# Injects behavior-driven development enforcement at session start +# +# Hooks are defined in hooks/hooks.json for direct Claude Code integration +# because they use `han hook reference`, which the generator does not emit + +hooks: {} diff --git a/plugins/patterns/git-worktrees/CHANGELOG.md b/plugins/patterns/git-worktrees/CHANGELOG.md new file mode 100644 index 000000000..8efd6d6a9 --- /dev/null +++ b/plugins/patterns/git-worktrees/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-03-03 + +### Added + +- support Task→Agent tool rename in Claude Code 2.1.63 ([2c9801f0](../../commit/2c9801f0)) + +## [1.0.0] - 2026-02-17 + +### Added + +- add git-worktrees plugin for subagent isolation ([25716011](../../commit/25716011)) diff --git a/plugins/patterns/tdd/han-plugin.yml b/plugins/patterns/tdd/han-plugin.yml new file mode 100644 index 000000000..d397e1a32 --- /dev/null +++ b/plugins/patterns/tdd/han-plugin.yml @@ -0,0 +1,7 @@ +# tdd plugin configuration +# Injects test-driven development enforcement at session start +# +# Hooks are defined in hooks/hooks.json for direct Claude Code integration +# because they use `han hook reference`, which the generator does not emit + +hooks: {} diff --git a/plugins/services/canva/han-plugin.yml b/plugins/services/canva/han-plugin.yml new file mode 100644 index 000000000..16327f459 --- /dev/null +++ b/plugins/services/canva/han-plugin.yml @@ -0,0 +1,12 @@ +# canva plugin configuration +# This plugin provides Canva integration via MCP server +# MCP server is defined in .mcp.json for direct exposure to Claude Code + +# No hooks - MCP server plugins don't have validation hooks +hooks: {} + +# Mirrors .mcp.json so han and Claude Code see the same server definition +mcp_servers: + canva: + type: http + url: https://mcp.canva.com/mcp diff --git a/plugins/services/playwright-mcp/han-plugin.yml b/plugins/services/playwright-mcp/han-plugin.yml index df199a310..8e9049e7c 100644 --- a/plugins/services/playwright-mcp/han-plugin.yml +++ b/plugins/services/playwright-mcp/han-plugin.yml @@ -4,51 +4,3 @@ # No hooks - MCP server plugins don't have validation hooks hooks: {} - -# Agent configuration -# These agents handle Playwright operations and return concise summaries -# to prevent verbose output from polluting the main orchestrator's context -agents: - test-generator: - description: | - Generate Playwright test cases from requirements, user flows, and specifications. - Delegates all browser interaction to this agent to keep verbose output contained. - when_to_use: - - Creating new test suites from scratch - - Converting manual test cases to automated tests - - Generating tests from user stories or specifications - - Building regression test coverage - returns: Concise summary of tests generated with file paths and test names - - ui-debugger: - description: | - Debug UI issues using Playwright inspection and browser automation tools. - Delegates all browser interaction to this agent to keep verbose output contained. - when_to_use: - - Investigating failing tests - - Debugging flaky tests - - Troubleshooting selector problems - - Analyzing element visibility issues - - Inspecting page state - returns: Concise summary of findings with root cause and recommended fix - -# IMPORTANT: Main orchestrator should NEVER call Playwright MCP tools directly -# Always delegate to test-generator or ui-debugger agents -orchestrator_guidance: | - DO NOT call Playwright MCP tools (browser_*, playwright_*) directly from the main - orchestrator context. Playwright tools return extremely verbose output including: - - Full page snapshots (thousands of lines) - - DOM trees - - Network request/response logs - - Console output - - This will fill your context window and degrade performance. - - INSTEAD: Delegate to specialized agents: - - test-generator: For creating/updating Playwright tests - - ui-debugger: For investigating UI issues or test failures - - These agents are designed to: - 1. Use Playwright tools freely within their context - 2. Process verbose output internally - 3. Return only concise, actionable summaries diff --git a/plugins/specialized/android/han-plugin.yml b/plugins/specialized/android/han-plugin.yml index a711eac97..a3a7a0aae 100644 --- a/plugins/specialized/android/han-plugin.yml +++ b/plugins/specialized/android/han-plugin.yml @@ -3,7 +3,7 @@ hooks: dirs_with: - "build.gradle" - "build.gradle.kts" - test_dir: "test -f gradlew && grep -q 'com.android' build.gradle* 2>/dev/null" + dir_test: "test -f gradlew && grep -q 'com.android' build.gradle* 2>/dev/null" command: "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/build.sh\"" if_changed: - "**/*.kt" diff --git a/plugins/tools/absinthe-graphql/han-plugin.yml b/plugins/tools/absinthe-graphql/han-plugin.yml new file mode 100644 index 000000000..7f84cc77b --- /dev/null +++ b/plugins/tools/absinthe-graphql/han-plugin.yml @@ -0,0 +1,16 @@ +# absinthe-graphql plugin configuration +# Absinthe validates schema definitions at compile time, so compiling with +# warnings-as-errors is the schema validation step for an Absinthe project. + +hooks: + validate: + event: Stop + command: "mix compile --warnings-as-errors" + dirs_with: + - "mix.exs" + dir_test: "grep -qE ':absinthe' mix.exs" + if_changed: + - "**/*.ex" + - "**/*.exs" + - "mix.exs" + - "mix.lock" diff --git a/plugins/tools/apollo-graphql/han-plugin.yml b/plugins/tools/apollo-graphql/han-plugin.yml new file mode 100644 index 000000000..24623a9b8 --- /dev/null +++ b/plugins/tools/apollo-graphql/han-plugin.yml @@ -0,0 +1,13 @@ +hooks: + codegen: + event: Stop + command: "npx -y --package @graphql-codegen/cli graphql-codegen" + dirs_with: + - "codegen.ts" + - "codegen.yml" + - "codegen.yaml" + - "codegen.json" + if_changed: + - "**/*.graphql" + - "**/*.gql" + - "codegen.{ts,yml,yaml,json}" diff --git a/plugins/tools/cucumber/han-plugin.yml b/plugins/tools/cucumber/han-plugin.yml new file mode 100644 index 000000000..a12dcee6f --- /dev/null +++ b/plugins/tools/cucumber/han-plugin.yml @@ -0,0 +1,21 @@ +hooks: + test: + event: Stop + command: "npx -y --package @cucumber/cucumber cucumber-js" + dirs_with: + - "package.json" + dir_test: "grep -qE '\"@cucumber/cucumber\":' package.json" + if_changed: + - "**/*.feature" + - "**/steps/**/*.{js,ts}" + - "**/step_definitions/**/*.{js,ts}" + - "cucumber.{js,cjs,mjs,json,yaml,yml}" + + test-changed: + event: PostToolUse:Edit|Write + command: "npx -y --package @cucumber/cucumber cucumber-js ${HAN_FILES}" + dirs_with: + - "package.json" + dir_test: "grep -qE '\"@cucumber/cucumber\":' package.json" + if_changed: + - "**/*.feature" diff --git a/plugins/tools/graphql-inspector/han-plugin.yml b/plugins/tools/graphql-inspector/han-plugin.yml index 5904fb4ed..8b9e7e990 100644 --- a/plugins/tools/graphql-inspector/han-plugin.yml +++ b/plugins/tools/graphql-inspector/han-plugin.yml @@ -19,5 +19,4 @@ hooks: - "schema.graphql" - "**/schema.graphql" -mcp: null memory: null diff --git a/plugins/tools/graphql/han-plugin.yml b/plugins/tools/graphql/han-plugin.yml new file mode 100644 index 000000000..e8d173931 --- /dev/null +++ b/plugins/tools/graphql/han-plugin.yml @@ -0,0 +1,17 @@ +# graphql plugin configuration +# Uses graphql-language-service-cli, the same toolchain this plugin's LSP +# server runs (scripts/lsp-entrypoint.sh). Gating on a GraphQL config file +# lets the CLI resolve schema and document locations itself instead of +# assuming a fixed ./schema.graphql layout. + +hooks: + validate: + event: Stop + command: "npx -y --package graphql-language-service-cli graphql-lsp validate" + dirs_with: + - ".graphqlrc" + - ".graphqlrc.{yml,yaml,json,js,ts}" + - "graphql.config.{yml,yaml,json,js,ts}" + if_changed: + - "**/*.graphql" + - "**/*.gql" diff --git a/plugins/tools/jest/han-plugin.yml b/plugins/tools/jest/han-plugin.yml index 3fb320913..c11d86bf8 100644 --- a/plugins/tools/jest/han-plugin.yml +++ b/plugins/tools/jest/han-plugin.yml @@ -26,4 +26,3 @@ hooks: if_changed: - "**/*.test.{js,jsx,ts,tsx}" - "**/*.spec.{js,jsx,ts,tsx}" - file_test: grep -qE "(describe|it|test)\s*\(" "${HAN_FILE}" diff --git a/plugins/tools/kubernetes/han-plugin.yml b/plugins/tools/kubernetes/han-plugin.yml new file mode 100644 index 000000000..0635b305a --- /dev/null +++ b/plugins/tools/kubernetes/han-plugin.yml @@ -0,0 +1,11 @@ +hooks: + validate: + event: Stop + command: "kubectl apply --dry-run=client -f ." + dirs_with: + - "*.yaml" + - "*.yml" + dir_test: "grep -qsE '^apiVersion:' *.yaml *.yml" + if_changed: + - "**/*.yaml" + - "**/*.yml" diff --git a/plugins/tools/mocha/han-plugin.yml b/plugins/tools/mocha/han-plugin.yml index ba23e9133..0d18c3851 100644 --- a/plugins/tools/mocha/han-plugin.yml +++ b/plugins/tools/mocha/han-plugin.yml @@ -20,4 +20,3 @@ hooks: - "**/*.test.{js,ts}" - "**/*.spec.{js,ts}" - "**/test/**/*.{js,ts}" - file_test: grep -qE "(describe|it)\s*\(" "${HAN_FILE}" diff --git a/plugins/tools/playwright/han-plugin.yml b/plugins/tools/playwright/han-plugin.yml index 42b698563..380c14706 100644 --- a/plugins/tools/playwright/han-plugin.yml +++ b/plugins/tools/playwright/han-plugin.yml @@ -22,4 +22,3 @@ hooks: - "**/*.spec.{js,ts}" - "**/*.test.{js,ts}" - "**/e2e/**/*.{js,ts}" - file_test: grep -qE "(test\(|test\.describe)" "${HAN_FILE}" diff --git a/plugins/tools/pytest/han-plugin.yml b/plugins/tools/pytest/han-plugin.yml index b0604c214..020390f2c 100644 --- a/plugins/tools/pytest/han-plugin.yml +++ b/plugins/tools/pytest/han-plugin.yml @@ -22,4 +22,3 @@ hooks: - "**/test_*.py" - "**/*_test.py" - "**/tests/*.py" - file_test: grep -qE "(def test_|class Test)" "${HAN_FILE}" diff --git a/plugins/tools/rspec/han-plugin.yml b/plugins/tools/rspec/han-plugin.yml index 98d2727ba..7854ea6ac 100644 --- a/plugins/tools/rspec/han-plugin.yml +++ b/plugins/tools/rspec/han-plugin.yml @@ -22,4 +22,3 @@ hooks: if_changed: - "**/*_spec.rb" - "**/spec/**/*.rb" - file_test: grep -qE "(describe|it|context)\s" "${HAN_FILE}" diff --git a/plugins/tools/syncpack/han-plugin.yml b/plugins/tools/syncpack/han-plugin.yml index 741f8fa0b..02e417f68 100644 --- a/plugins/tools/syncpack/han-plugin.yml +++ b/plugins/tools/syncpack/han-plugin.yml @@ -40,6 +40,4 @@ hooks: - "**/package.json" tip: "Formats package.json files consistently across the monorepo." -mcp: null - memory: null diff --git a/plugins/tools/vitest/han-plugin.yml b/plugins/tools/vitest/han-plugin.yml index 1a2fde7c4..766f073ea 100644 --- a/plugins/tools/vitest/han-plugin.yml +++ b/plugins/tools/vitest/han-plugin.yml @@ -26,4 +26,3 @@ hooks: if_changed: - "**/*.test.{js,jsx,ts,tsx}" - "**/*.spec.{js,jsx,ts,tsx}" - file_test: grep -qE "(describe|it|test)\s*\(" "${HAN_FILE}" diff --git a/plugins/validation/session-code-review/CHANGELOG.md b/plugins/validation/session-code-review/CHANGELOG.md new file mode 100644 index 000000000..acd5ee503 --- /dev/null +++ b/plugins/validation/session-code-review/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-03-16 + +### Changed + +- delegate hook review logic to /code-review skill ([5bc680d4](../../commit/5bc680d4)) + +## [1.0.0] - 2026-03-14 + +### Added + +- add session-code-review plugin with backpressure code review ([97308c00](../../commit/97308c00)) + +### Fixed + +- increase agent hook timeouts to 300s for thorough review ([fe07d553](../../commit/fe07d553)) diff --git a/plugins/validation/session-code-review/han-plugin.yml b/plugins/validation/session-code-review/han-plugin.yml index 2f6850f2c..a76acb954 100644 --- a/plugins/validation/session-code-review/han-plugin.yml +++ b/plugins/validation/session-code-review/han-plugin.yml @@ -1,2 +1,9 @@ -dependencies: - - bushido +# session-code-review plugin configuration +# Backpressure code review before session completion and before commits +# +# Hooks are hand-authored in hooks/hooks.json and intentionally stay there: +# they use the `agent` handler type with a prompt, which `han plugin +# generate-hooks` cannot emit (it only emits `type: command` running +# `han hook run`). Do not "fix" this by generating hook entries here. + +hooks: {} diff --git a/plugins/validation/session-code-review/skills/code-review/SKILL.md b/plugins/validation/session-code-review/skills/code-review/SKILL.md index 1141a585d..9b3cf2f3f 100644 --- a/plugins/validation/session-code-review/skills/code-review/SKILL.md +++ b/plugins/validation/session-code-review/skills/code-review/SKILL.md @@ -1,4 +1,5 @@ --- +name: code-review description: Review current branch changes against REVIEW.md guidelines --- From 10dd84ab91bda7223520c1f90bae8b2a01a2fb8d Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 01:51:06 -0600 Subject: [PATCH 06/10] ci: enforce han's own plugin integrity checks han ships two plugin-integrity checks that no workflow ran, so drift could land on main unnoticed: generate-hooks --all --check, which byte-diffs every generated hooks.json against what its han-plugin.yml produces, and han plugin validate. A separate workflow rather than a job in validate-han.yml, because GitHub Actions paths filters are workflow-level. Adding the job there would either never fire on a plugins/** change or drag biome, tsc, the full test suite and the coverage steps into every plugin edit. han plugin validate takes no directory argument and exits 0 silently when cwd has no .claude-plugin/, so a wrong cwd is a silent pass. The workflow discovers plugin directories, fails if it finds none, and fails if the validated count does not match the discovered count, so the check cannot pass vacuously. Triggers also cover generate-hooks.ts and lib/hooks/**, since a generator change can invalidate every checked-in hooks.json without touching a plugin file. --- .github/workflows/validate-plugins.yml | 110 +++++++++++++++++++++ packages/han/lib/commands/hook/dispatch.ts | 3 +- packages/han/lib/hook-runner.ts | 11 +-- 3 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/validate-plugins.yml diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml new file mode 100644 index 000000000..941cd4423 --- /dev/null +++ b/.github/workflows/validate-plugins.yml @@ -0,0 +1,110 @@ +name: Validate Plugins + +on: + pull_request: + paths: + - "plugins/**" + - "examples/**" + - ".claude-plugin/marketplace.json" + - "packages/han/lib/commands/plugin/generate-hooks.ts" + - "packages/han/lib/hooks/**" + - ".github/workflows/validate-plugins.yml" + push: + branches: + - main + paths: + - "plugins/**" + - "examples/**" + - ".claude-plugin/marketplace.json" + - "packages/han/lib/commands/plugin/generate-hooks.ts" + - "packages/han/lib/hooks/**" + - ".github/workflows/validate-plugins.yml" + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.2" + + - name: Install dependencies + run: bun install --frozen-lockfile + working-directory: ./packages/han + + - name: Generate build-info stub + working-directory: ./packages/han + run: | + cat > lib/build-info.generated.ts << 'EOF' + // Auto-generated stub for CI + export const HAN_VERSION = "0.0.0-ci"; + export const DETECT_PLUGINS_PROMPT = "You are a plugin detector."; + export const version = "0.0.0-ci"; + export const gitCommit = "ci"; + export const buildDate = new Date().toISOString(); + export const platform = "linux"; + export const arch = "x64"; + EOF + + - name: Check generated hooks.json is up to date + working-directory: ./packages/han + run: | + if ! bun run lib/main.ts plugin generate-hooks --all --check; then + echo "::error::One or more hooks/hooks.json files are stale. Run 'han plugin generate-hooks --all' and commit the result." + exit 1 + fi + + - name: Validate plugins + run: | + han_cli="$PWD/packages/han/lib/main.ts" + + plugin_list="$(mktemp)" + find plugins examples -type d -name .claude-plugin | sort > "$plugin_list" + + plugin_dirs=() + while IFS= read -r claude_plugin_dir; do + plugin_dirs+=("${claude_plugin_dir%/.claude-plugin}") + done < "$plugin_list" + rm -f "$plugin_list" + + echo "Discovered ${#plugin_dirs[@]} plugin(s)." + if [ "${#plugin_dirs[@]}" -eq 0 ]; then + echo "::error::No plugin directories discovered. Plugin validation would pass vacuously." + exit 1 + fi + + validated=0 + failed_dirs=() + + for plugin_dir in "${plugin_dirs[@]}"; do + echo "::group::$plugin_dir" + if (cd "$plugin_dir" && bun "$han_cli" plugin validate < /dev/null); then + echo "ok" + else + failed_dirs+=("$plugin_dir") + fi + validated=$((validated + 1)) + echo "::endgroup::" + done + + echo "Validated $validated plugin(s) of ${#plugin_dirs[@]} discovered, ${#failed_dirs[@]} failed." + + if [ "$validated" -ne "${#plugin_dirs[@]}" ]; then + echo "::error::Validated $validated plugin(s) but discovered ${#plugin_dirs[@]}. Discovery and validation disagree." + exit 1 + fi + + if [ "${#failed_dirs[@]}" -gt 0 ]; then + for plugin_dir in "${failed_dirs[@]}"; do + echo " - $plugin_dir" + done + echo "::error::${#failed_dirs[@]} plugin(s) failed 'han plugin validate'." + exit 1 + fi diff --git a/packages/han/lib/commands/hook/dispatch.ts b/packages/han/lib/commands/hook/dispatch.ts index 82382ddd0..01c7a53ec 100644 --- a/packages/han/lib/commands/hook/dispatch.ts +++ b/packages/han/lib/commands/hook/dispatch.ts @@ -11,7 +11,6 @@ import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import type { Command } from 'commander'; import { - getClaudeConfigDir, getMergedPluginsAndMarketplaces, getSettingsPaths, type MarketplaceConfig, @@ -22,8 +21,8 @@ import { getEventLogger, initEventLogger } from '../../events/logger.ts'; import { isCoordinatorHealthy } from '../../grpc/client.ts'; import { sessionFileChanges } from '../../grpc/data-access.ts'; import { executeHooksViaGrpc } from '../../grpc/hook-executor.ts'; -import { getClaudeProjectPath } from '../../memory/paths.ts'; import { getPluginDir } from '../../hooks/plugin-discovery.ts'; +import { getClaudeProjectPath } from '../../memory/paths.ts'; import { getPluginNameFromRoot, isDebugMode } from '../../shared.ts'; import { recordHookExecution as recordOtelHookExecution } from '../../telemetry/index.ts'; diff --git a/packages/han/lib/hook-runner.ts b/packages/han/lib/hook-runner.ts index f3c4eb922..77c2abeb7 100644 --- a/packages/han/lib/hook-runner.ts +++ b/packages/han/lib/hook-runner.ts @@ -1,13 +1,6 @@ import { execSync, spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - realpathSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import micromatch from 'micromatch'; @@ -44,11 +37,11 @@ import { type PluginHookDefinition, type ResolvedHookConfig, } from './hooks/hook-config.ts'; +import { findPluginInMarketplace } from './hooks/plugin-discovery.ts'; import { buildCommandWithFiles, HAN_FILES_TEMPLATE, } from './hooks/transcript-filter.ts'; -import { findPluginInMarketplace } from './hooks/plugin-discovery.ts'; import { getPluginNameFromRoot, isDebugMode } from './shared.ts'; // Re-export for tests From 8e6a0bd8cd70ec6d548d29665215f4c26309ed95 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 02:09:23 -0600 Subject: [PATCH 07/10] docs(blueprints): record the parity audit and close the settings gap The re-audit cross-checked han's recognized manifest field set against the machine-readable schemastore schema rather than only the reference prose, which surfaced one field the doc pass missed: settings, the plugin-level settings.json merged into user settings while the plugin is enabled. Added to the recognized set, taking it to 26 fields. The blueprint records the baseline, the re-run procedure including the two schemastore cross-checks, and the three invariants that are easy to break again: hooks.json timeout is seconds while execSync takes milliseconds, plugins/core registers han hook dispatch as its own hook so the dispatcher must refuse to recurse, and the han-plugin.yml allow-list must be the union of every reader because an unknown key there is fatal to the plugin. --- blueprints/harness-feature-parity.md | 135 ++++++++++++++++--- packages/han/lib/commands/plugin/validate.ts | 5 +- 2 files changed, 116 insertions(+), 24 deletions(-) diff --git a/blueprints/harness-feature-parity.md b/blueprints/harness-feature-parity.md index 9079da034..140037c97 100644 --- a/blueprints/harness-feature-parity.md +++ b/blueprints/harness-feature-parity.md @@ -1,48 +1,141 @@ # Harness Feature Parity -Tracks han's coverage of the upstream harness feature surface it integrates -with. The primary harness is Claude Code; the bridge plugins map the same -surface onto OpenCode, Gemini CLI, Kiro, Codex, and Antigravity. +Tracks han's coverage of the upstream harness surface it integrates with. The +primary harness is Claude Code; the bridge plugins map the same surface onto +OpenCode, Gemini CLI, Kiro, Codex, and Antigravity. **Upstream baseline: Claude Code 2.1.228** (npm `@anthropic-ai/claude-code` -`latest` dist-tag). Re-run the audit whenever the baseline moves. +`latest` dist-tag). ## How to re-run this audit 1. Read the npm `latest` dist-tag for `@anthropic-ai/claude-code`. That version - is the baseline, not the version referenced in source comments. + is the baseline. Do not take the version from a source comment; comments go + stale, which is exactly how the 2.1.215 pin survived thirteen releases. 2. Read the upstream reference pages, which are the schema source of truth: - `https://code.claude.com/docs/en/hooks.md` - `https://code.claude.com/docs/en/plugins-reference.md` - `https://code.claude.com/docs/en/plugin-marketplaces.md` - - `https://code.claude.com/docs/en/plugin-dependencies.md` - - `https://code.claude.com/docs/en/plugin-relevance.md` - `https://code.claude.com/docs/en/changelog.md` -3. Diff each table below against the docs, then close every gap before - updating the baseline line above. + These paginate. Read them in ranges (`:301-1322`) or you will silently get + the first 50 KB and conclude a section does not exist. +3. Diff each table below against the docs. Close every gap before moving the + baseline line above. ## Hook events -Source of truth in han: `packages/han/lib/hooks/hook-config.ts` -(`HookEventType`, `VALID_EVENT_TYPES`). +Single source of truth: `HOOK_EVENT_TYPES` in +`packages/han/lib/hooks/hook-config.ts`. The `HookEventType` union, shorthand +validation, and generated `hooks.json` key ordering all derive from that one +array. Adding an upstream event is a one-line change there. -Status is filled in by the audit; see the gap register below for open items. +All 31 upstream events are supported: `SessionStart`, `Setup`, +`UserPromptSubmit`, `UserPromptExpansion`, `PreToolUse`, `PermissionRequest`, +`PermissionDenied`, `PostToolUse`, `PostToolUseFailure`, `PostToolBatch`, +`Notification`, `MessageDisplay`, `Stop`, `SubagentStop`, `SubagentStart`, +`TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, +`InstructionsLoaded`, `ConfigChange`, `CwdChanged`, `DirectoryAdded`, +`FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, +`Elicitation`, `ElicitationResult`, `SessionEnd`. + +`han hook test` generates a realistic stdin payload for every one of them, with +the event-specific fields taken from the upstream input schemas. ## Hook handler types -Upstream supports five handler types: `command`, `http`, `mcp_tool`, `prompt`, -and `agent`. +Upstream defines five: `command`, `http`, `mcp_tool`, `prompt`, and `agent`. + +han's dispatcher executes `command` handlers only. The other four are executed +by Claude Code itself, so running them here would double-fire them. The +`HookEntry` type carries the full union so an unrecognized type is a +recognized-and-skipped case rather than a silent fallthrough. + +`han hook dispatch` exists to work around Claude Code issue 12151, where plugin +hook stdout does not reach the agent on context-injecting events. Two +constraints that are easy to break: + +- `timeout` in a `hooks.json` entry is **seconds**. `execSync` takes + **milliseconds**. Passing one to the other directly means every hook is killed + before it starts. +- `plugins/core` registers `han hook dispatch ` as one of its own + `hooks.json` entries for eight events. The dispatcher rediscovers that entry + when it scans plugins, so it must refuse to recurse. `HAN_DISPATCH=1`, set on + every child process, is the guard. + +## Plugin directory resolution + +Single source of truth: `packages/han/lib/hooks/plugin-discovery.ts`. + +`.claude-plugin/marketplace.json` is authoritative. It is the only place that +knows both the current `plugins//` layout and every published +legacy alias, and it does not require the plugin to have a `han-plugin.yml`, +which agent-only and MCP-only plugins do not. Scanning for `han-plugin.yml` is +the fallback for plugins the manifest omits. + +Never reintroduce a hardcoded directory probe. The previous +`jutsu/`, `do/`, `hashi/`, root list matched zero of the plugins on disk. ## Plugin manifest schema -Upstream `.claude-plugin/plugin.json` fields, and han's `han-plugin.yml` -superset. +Upstream makes `name` the only required field and ignores top-level fields it +does not recognize, so han reports an unrecognized field as a warning and never +fails a plugin for it. + +`han plugin validate` recognizes the 26 upstream `plugin.json` fields: +`$schema`, `name`, `displayName`, `version`, `description`, `author`, +`homepage`, `repository`, `license`, `keywords`, `metadata`, `defaultEnabled`, +`strict`, `skills`, `commands`, `agents`, `workflows`, `hooks`, `mcpServers`, +`outputStyles`, `lspServers`, `experimental`, `userConfig`, `channels`, +`settings`, `dependencies`, plus `themes` and `monitors` as recognized +top-level keys that warn about the coming `experimental.*` requirement. + +Two machine-readable cross-checks worth re-running, because they catch drift a +doc read misses: + +- `https://json.schemastore.org/claude-code-plugin-manifest.json` against the + recognized field set. +- `https://json.schemastore.org/claude-code-marketplace.json` against + `.claude-plugin/marketplace.json`. + +The schemastore snapshots lag the reference docs, so a field present in han and +absent from the schema is not automatically wrong. Confirm against the docs +before removing anything. + + +## han-plugin.yml schema + +han's own plugin config has two independent readers, and the validator's +allow-list must be the union of what both consume, not what either one declares: + +- `packages/han/lib/hooks/hook-config.ts` (`YamlPluginHookDefinition`) for the + hook runtime. +- `packages/han/lib/commands/plugin/generate-hooks.ts`, which reads `sync`. + +An unrecognized key here is fatal to the whole plugin, so a field must be listed +the moment any reader consumes it. Top-level keys: `hooks`, `mcp_servers`, +`memory`, and `learn_patterns` (read by `marker-detection.ts` and surfaced as +`detection.learnPatterns` for prompt-based auto-detection). + +`loadPluginConfig` validates the raw YAML before conversion. Validating the +converted camelCase config checks a shape the file never has. + +## Upstream surface han deliberately does not model + +These are Claude Code plugin components that han's tooling does not generate, +validate beyond field recognition, or wrap. They work because Claude Code +discovers them natively from the plugin directory: -## Marketplace schema +`agents/`, `skills/`, `workflows/`, `output-styles/`, `themes/`, `monitors/`, +`bin/` (executables added to the Bash tool's PATH), `settings.json`, +`.lsp.json`, `userConfig`, and `channels`. -Upstream `.claude-plugin/marketplace.json` fields. +If han grows opinions about any of them, that is a feature decision, not a +parity gap. -## Gap register +## Enforcement -Open gaps are tracked in this section and closed in the same change that -records them. +`han plugin generate-hooks --all --check` byte-diffs every generated +`hooks.json` against what its `han-plugin.yml` produces. `han plugin validate` +checks manifests and structure. Both run in CI. A plugin with a `hooks.json` but +no `han-plugin.yml` is invisible to both, so every plugin that ships hooks must +carry a `han-plugin.yml`. diff --git a/packages/han/lib/commands/plugin/validate.ts b/packages/han/lib/commands/plugin/validate.ts index 2862fbc08..bdaeab911 100644 --- a/packages/han/lib/commands/plugin/validate.ts +++ b/packages/han/lib/commands/plugin/validate.ts @@ -78,6 +78,7 @@ const RECOGNIZED_MANIFEST_FIELDS: Record = { experimental: true, userConfig: true, channels: true, + settings: true, dependencies: true, }; @@ -117,9 +118,7 @@ export function validatePlugin(pluginPath: string): PluginValidationResult { } else { // Validate plugin.json try { - const parsed: unknown = JSON.parse( - readFileSync(pluginJsonPath, 'utf-8') - ); + const parsed: unknown = JSON.parse(readFileSync(pluginJsonPath, 'utf-8')); if ( typeof parsed !== 'object' || From 053ea981a2c5902ac4b2211e7d9d9656e88b4056 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 03:04:04 -0600 Subject: [PATCH 08/10] fix(cli): make implemented commands reachable and dead controls live Three fully implemented command groups were never registered in main.ts, so han config, han auth and han sync did not exist at the CLI no matter what the docs said. Registered. Shell completion advertised metrics, checkpoint, explain, summary and gaps at the top level plus subcommands like plugin update and hook verify, none of which are registered. The table now mirrors main.ts, and two tests assert that every advertised command and subcommand resolves against the real commander program, so the table cannot drift again. The tests it replaces asserted the presence of the phantom commands, which is what let the drift persist. hooks.checkpoints was read only by isSessionFilteringEnabled, which had no production callers, and HAN_NO_CHECKPOINTS was written by hook dispatch and read by nothing. Both were documented controls that did nothing. substituteHanFilesForStop now honors them and falls back to checking all files, which is what turning session-scoped filtering off is supposed to mean. The deprecated validate-legacy command pointed users at han validate, which is not a command. It points at han hook run. --- packages/han/lib/commands/aliases.ts | 2 +- .../lib/commands/completion/completions.ts | 90 ++++++++++++++----- packages/han/lib/hook-runner.ts | 13 ++- packages/han/lib/main.ts | 6 ++ packages/han/test/completion.test.ts | 49 +++++----- 5 files changed, 115 insertions(+), 45 deletions(-) diff --git a/packages/han/lib/commands/aliases.ts b/packages/han/lib/commands/aliases.ts index c0d866827..755196d94 100644 --- a/packages/han/lib/commands/aliases.ts +++ b/packages/han/lib/commands/aliases.ts @@ -48,7 +48,7 @@ export function registerAliasCommands(program: Command): void { program .command('validate-legacy [ignored...]') .description( - "[DEPRECATED] Legacy validate format. Use 'han validate' instead.\n" + + "[DEPRECATED] Legacy validate format. Use 'han hook run' instead.\n" + 'Requires -- before command (e.g., han validate-legacy --dirs-with package.json -- npm test)' ) .option( diff --git a/packages/han/lib/commands/completion/completions.ts b/packages/han/lib/commands/completion/completions.ts index 526f95338..22dca8f92 100644 --- a/packages/han/lib/commands/completion/completions.ts +++ b/packages/han/lib/commands/completion/completions.ts @@ -158,36 +158,82 @@ export const CHECKPOINT_TYPE_COMPLETIONS: CompletionItem[] = [ { value: 'agent', description: 'Agent checkpoint' }, ]; -// Command structure for completion +// Command structure for completion. +// Must mirror the groups registered in lib/main.ts. Advertising a command that +// does not exist is worse than advertising none, since the shell presents it as +// real. export const COMMANDS: Record = { '': [ 'plugin', + 'create', 'hook', - 'memory', - 'metrics', - 'checkpoint', - 'index', 'mcp', - 'explain', - 'summary', - 'gaps', + 'blueprints', + 'memory', + 'keep', + 'parse', + 'reindex', + 'install', + 'uninstall', 'completion', + 'doctor', + 'setup', + 'worktree', + 'config', + 'auth', + 'sync', + 'browse', + 'coordinator', + ], + plugin: [ + 'generate-hooks', + 'install', + 'list', + 'migrate', + 'uninstall', + 'search', + 'update-marketplace', + 'validate', + ], + create: ['plugin'], + hook: [ + 'auto-detect', + 'auto-detect-prompt', + 'context', + 'dispatch', + 'explain', + 'list', + 'run', + 'test', + 'wrap-subagent-context', + 'reference', + ], + mcp: ['blueprints', 'memory'], + blueprints: ['sync-index'], + keep: ['save', 'load', 'list', 'delete', 'clear'], + parse: [ + 'json', + 'json-set', + 'json-validate', + 'yaml', + 'yaml-set', + 'yaml-to-json', + 'json-to-yaml', ], - plugin: ['install', 'list', 'uninstall', 'search', 'update'], - hook: ['run', 'dispatch', 'explain', 'test', 'verify', 'reference'], - metrics: [ - 'show', - 'session-start', - 'session-end', - 'session-current', - 'hook-exec', - 'session-context', - 'memory-context', - 'detect-patterns', + worktree: ['add', 'remove', 'list', 'prune', 'discover'], + config: ['set', 'get', 'list'], + auth: ['login', 'logout', 'status'], + sync: ['status', 'session', 'all', 'queue', 'enqueue', 'watch', 'upload'], + coordinator: [ + 'start', + 'stop', + 'restart', + 'status', + 'ensure', + 'logs', + 'register', + 'launchd', ], - checkpoint: ['capture', 'list', 'clean'], - index: ['run', 'search', 'status'], - mcp: ['blueprints'], }; /** diff --git a/packages/han/lib/hook-runner.ts b/packages/han/lib/hook-runner.ts index 77c2abeb7..68bd93625 100644 --- a/packages/han/lib/hook-runner.ts +++ b/packages/han/lib/hook-runner.ts @@ -11,6 +11,7 @@ import { import { getPluginHookSettings, isCacheEnabled, + isSessionFilteringEnabled, } from './config/han-settings.ts'; import { getEventLogger } from './events/logger.ts'; import { @@ -1603,6 +1604,10 @@ export async function runAsyncPostToolUse( * Gets session-modified files, filters them to the config's directory and * ifChanged patterns, and substitutes the template variable. * + * Falling back to `.` means "check everything", which is the correct behaviour + * whenever the session file set is unavailable or the user has turned session + * filtering off. + * * @returns The modified command, or null if no matching files exist (skip this config) */ export async function substituteHanFilesForStop( @@ -1615,8 +1620,12 @@ export async function substituteHanFilesForStop( return { command, skipped: false }; } - if (!sessionId) { - // No session ID - fall back to "." (check all files) + // `hooks.checkpoints: false` in han.yml, or --no-checkpoints on the + // dispatching command, turns session-scoped filtering off. + const filteringDisabled = + process.env.HAN_NO_CHECKPOINTS === '1' || !isSessionFilteringEnabled(); + + if (!sessionId || filteringDisabled) { return { command: buildCommandWithFiles(command, []), skipped: false }; } diff --git a/packages/han/lib/main.ts b/packages/han/lib/main.ts index 177cea563..439fbddc2 100644 --- a/packages/han/lib/main.ts +++ b/packages/han/lib/main.ts @@ -15,12 +15,14 @@ import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import { HAN_VERSION } from './build-info.generated.ts'; import { registerAliasCommands } from './commands/aliases.ts'; +import { registerAuthCommands } from './commands/auth/index.ts'; import { registerBlueprintsCommands } from './commands/blueprints/index.ts'; import { browse } from './commands/browse/index.ts'; import { handleGetCompletions, registerCompletionCommand, } from './commands/completion/index.ts'; +import { registerConfigCommands } from './commands/config/index.ts'; import { registerCoordinatorCommands } from './commands/coordinator/index.ts'; import { registerCreateCommands } from './commands/create/index.ts'; import { registerDoctorCommand } from './commands/doctor.ts'; @@ -32,6 +34,7 @@ import { registerMemoryCommand } from './commands/memory/index.ts'; import { registerParseCommands } from './commands/parse/index.ts'; import { registerPluginCommands } from './commands/plugin/index.ts'; import { registerSetupCommand } from './commands/setup/index.ts'; +import { registerSyncCommands } from './commands/sync/index.ts'; import { registerWorktreeCommands } from './commands/worktree/index.ts'; import { getMergedHanConfig } from './config/han-settings.ts'; import { initTelemetry, shutdownTelemetry } from './telemetry/index.ts'; @@ -252,6 +255,9 @@ export function makeProgram(options: MakeProgramOptions = {}): Command { registerDoctorCommand(program); registerSetupCommand(program); registerWorktreeCommands(program); + registerConfigCommands(program); + registerAuthCommands(program); + registerSyncCommands(program); // Register browse command program diff --git a/packages/han/test/completion.test.ts b/packages/han/test/completion.test.ts index 2d80d7142..4064cf9e4 100644 --- a/packages/han/test/completion.test.ts +++ b/packages/han/test/completion.test.ts @@ -2,6 +2,8 @@ * Tests for shell completion system */ import { describe, expect, test } from 'bun:test'; +import { COMMANDS } from '../lib/commands/completion/completions.ts'; +import { makeProgram } from '../lib/main.ts'; describe('completion', () => { describe('shell script generation', () => { @@ -245,17 +247,34 @@ describe('completion', () => { }); describe('COMMANDS structure', () => { - test('top-level commands are defined', async () => { - const { COMMANDS } = await import( - '../lib/commands/completion/completions.ts' - ); + test('every advertised command is actually registered', () => { + const program = makeProgram({ exitOverride: true }); + const registered = new Set(program.commands.map((c) => c.name())); + + // Shell completion presents these as real commands, so anything here + // that main.ts does not register is a lie to the user. + const phantom = COMMANDS[''].filter((name) => !registered.has(name)); + expect(phantom).toEqual([]); + }); - expect(COMMANDS['']).toContain('plugin'); - expect(COMMANDS['']).toContain('hook'); - expect(COMMANDS['']).toContain('memory'); - expect(COMMANDS['']).toContain('metrics'); - expect(COMMANDS['']).toContain('checkpoint'); - expect(COMMANDS['']).toContain('completion'); + test('every advertised subcommand is actually registered', () => { + const program = makeProgram({ exitOverride: true }); + const phantom: string[] = []; + + for (const [group, subcommands] of Object.entries(COMMANDS)) { + if (group === '') continue; + const parent = program.commands.find((c) => c.name() === group); + if (!parent) { + phantom.push(group); + continue; + } + const registered = new Set(parent.commands.map((c) => c.name())); + for (const sub of subcommands) { + if (!registered.has(sub)) phantom.push(`${group} ${sub}`); + } + } + + expect(phantom).toEqual([]); }); test('plugin subcommands are defined', async () => { @@ -278,16 +297,6 @@ describe('completion', () => { expect(COMMANDS.hook).toContain('dispatch'); expect(COMMANDS.hook).toContain('explain'); }); - - test('checkpoint subcommands are defined', async () => { - const { COMMANDS } = await import( - '../lib/commands/completion/completions.ts' - ); - - expect(COMMANDS.checkpoint).toContain('capture'); - expect(COMMANDS.checkpoint).toContain('list'); - expect(COMMANDS.checkpoint).toContain('clean'); - }); }); describe('CompletionItem interface', () => { From 884db7b720bd6b625c33d67b9f36e2d4506d10a0 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 03:09:43 -0600 Subject: [PATCH 09/10] docs: correct the documented CLI and harness surface An audit of every documented command, flag, and config key against the code found substantial drift. Corrections, each verified against packages/han/lib and live --help output: - Hook event tables existed in three hand-maintained copies at 30, 17 and 14 rows, all reading as exhaustive. There is now one canonical 31-event table in plugin-development/hooks.md, verified by diffing it against HOOK_EVENT_TYPES, and the other two are explicitly labelled subsets that link to it. - Documented commands that do not exist are gone: han checkpoint, plugin remove, plugin info, hook info, hook verify, memory query, cache clear, and top-level explain, summary, gaps, update, metrics. Each removal says plainly that the command does not exist so readers who saw the old docs are not left guessing. - hook run --directory does not exist; the flag is --only. - han mcp server does not exist; the command is han mcp. - plugin install rejects --scope user outright, while list and uninstall accept it and uninstall defaults to it. That asymmetry is now documented instead of the docs claiming user scope is the default everywhere. - distribution.md documented install flags that do not exist. The real set is --auto, --no-analyze, --scope, --from. - CONTRIBUTING prescribed hooks.fail_fast and --fail-fast, neither of which exists. It now teaches han-plugin.yml plus han plugin generate-hooks. - README claimed six plugin categories against the marketplace's nine. - The newly reachable config, auth and sync command groups are documented, and the session-filtering controls are documented as working rather than inert, matching the code change in this branch. Also removes em-dashes from the prose touched here. --- CONTRIBUTING.md | 93 ++-- README.md | 19 +- website/content/docs/_nav.json | 2 +- website/content/docs/cli/hooks.md | 212 ++++----- website/content/docs/cli/index.md | 23 +- website/content/docs/cli/other.md | 418 +++++++----------- website/content/docs/cli/plugins.md | 174 ++++---- website/content/docs/configuration/caching.md | 48 +- website/content/docs/configuration/index.md | 276 ++++++------ website/content/docs/features/checkpoints.md | 315 +++---------- website/content/docs/features/hooks.md | 81 ++-- website/content/docs/features/memory.md | 41 +- website/content/docs/getting-started.md | 6 +- website/content/docs/installation/plugins.md | 2 +- website/content/docs/installation/scopes.md | 99 ++--- website/content/docs/integrations.md | 200 +++------ website/content/docs/metrics/index.md | 2 +- website/content/docs/metrics/opentelemetry.md | 4 +- website/content/docs/plugin-categories.md | 27 +- .../docs/plugin-development/distribution.md | 196 +++----- .../content/docs/plugin-development/hooks.md | 7 +- .../content/docs/plugin-development/index.md | 39 +- .../content/docs/plugin-development/skills.md | 20 +- .../docs/plugin-development/testing.md | 73 +-- 24 files changed, 981 insertions(+), 1396 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a61e58b2..ec88aa633 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,39 +44,31 @@ Create validation hooks for languages, frameworks, or tools. plugins/languages/{name}/ # or plugins/validation/{name}/ ├── .claude-plugin/ │ └── plugin.json +├── han-plugin.yml ├── hooks/ -│ └── hooks.json +│ └── hooks.json # Generated, do not hand-edit ├── skills/ # Optional └── README.md ``` -**Example hooks.json:** - -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "han hook run --fail-fast --dirs-with package.json -- npm test" - } - ] - } - ], - "SubagentStop": [ - { - "hooks": [ - { - "type": "command", - "command": "han hook run --fail-fast --dirs-with package.json -- npm test" - } - ] - } - ] - } -} +**Example `han-plugin.yml`:** + +```yaml +hooks: + test: + command: npm test + dirs_with: + - package.json + description: Run the project's test suite + if_changed: + - "**/*.ts" + - "**/*.test.ts" +``` + +Hooks default to the `Stop` and `SubagentStop` events, so most validation plugins do not need an explicit `event:` key. Generate `hooks/hooks.json` from that file rather than writing it by hand: + +```bash +han plugin generate-hooks plugins/languages/{name} ``` ### 🛤️ New Discipline Plugins @@ -172,7 +164,7 @@ You can also use natural language prompts to create plugins. Here are example pr ``` Add a validation plugin for that validates . -It should use --dirs-with and run . +It should use dirs_with and run . Include skills for . ``` @@ -180,7 +172,7 @@ Include skills for . ``` Add a validation plugin for kubernetes that validates manifests. -It should use --dirs-with *.yaml,*.yml and run kubeconform. +It should use dirs_with *.yaml,*.yml and run kubeconform. Include skills for kubernetes-manifests, kubernetes-resources, and kubernetes-security. ``` @@ -281,7 +273,7 @@ When you request plugin generation, Claude will: After Claude generates files, review: -1. **Hook Commands**: Ensure they use `han hook run --fail-fast --dirs-with` +1. **Hook Commands**: Ensure hooks are declared in `han-plugin.yml` and `hooks/hooks.json` was regenerated with `han plugin generate-hooks` 2. **Marker Files**: Verify marker files match project types 3. **Skills**: Check that skills are comprehensive and accurate 4. **Examples**: Ensure code examples are correct and follow best practices @@ -400,17 +392,26 @@ npm test ## Validation Hook Guidelines -### Use han hook run +### Declare hooks in han-plugin.yml -All validation plugins should use the `han hook run` command: +Validation plugins declare their hooks in `han-plugin.yml`, then generate `hooks/hooks.json` from it: -```json -{ - "type": "command", - "command": "han hook run --fail-fast --dirs-with -- " -} +```yaml +hooks: + test: + command: + dirs_with: + - ``` +```bash +han plugin generate-hooks +``` + +The generated `hooks.json` wires each hook to `han hook run `. The older inline form, `han hook run --dirs-with -- `, still works but is legacy: it cannot express `if_changed`, dependencies, event selection, or `${HAN_FILES}` targeting. + +There is no `--fail-fast` flag. Earlier documentation prescribed one; it does not exist in the CLI. + ### Choose Appropriate Marker Files - `package.json` - JavaScript/TypeScript projects @@ -427,14 +428,22 @@ Don't add redundant error messages after the hook command - it handles errors au **Good:** -```json -"command": "han hook run --fail-fast --dirs-with package.json -- npm test" +```yaml +hooks: + test: + command: npm test + dirs_with: + - package.json ``` **Bad:** -```json -"command": "han hook run --fail-fast --dirs-with package.json -- npm test || (echo 'Tests failed'; exit 2)" +```yaml +hooks: + test: + command: npm test || (echo 'Tests failed'; exit 2) + dirs_with: + - package.json ``` ## Review Process diff --git a/README.md b/README.md index 5940a7c9b..7849d5905 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![GitHub Release](https://img.shields.io/github/v/release/thebushidocollective/han)](https://github.com/thebushidocollective/han/releases) [![codecov](https://codecov.io/gh/thebushidocollective/han/graph/badge.svg)](https://codecov.io/gh/thebushidocollective/han) -Ship-ready code from your AI coding agent. 139+ plugins for quality gates, tooling, memory, and specialized agents — so your AI writes code that's ready to merge. +Ship-ready code from your AI coding agent. 340 plugins for quality gates, tooling, memory, and specialized agents, so your AI writes code that's ready to merge. ## Getting Started @@ -35,16 +35,19 @@ brew install thebushidocollective/tap/han ## Plugin Categories -139 plugins across six categories: +340 plugins across nine categories: | Category | Description | Examples | |----------|-------------|----------| -| **Core** | Essential infrastructure. Auto-installs han binary, provides metrics and MCP servers | Always required | -| **Languages** | Validation plugins for your language/runtime | TypeScript, Rust, Python, Go, Ruby | -| **Validation** | Linters, formatters, and quality tools | Biome, ESLint, ShellCheck, Pytest | -| **Disciplines** | Specialized AI agents for engineering domains | Security, accessibility, API design, architecture | -| **Services** | MCP servers for external platforms | GitHub, GitLab, Linear | -| **Tools** | MCP servers for development utilities | Playwright, Blueprints, Context7 | +| **Core** | Essential infrastructure. Auto-installs the han binary, provides metrics and MCP servers | core, bushido | +| **Language** | Deep knowledge of a programming language | TypeScript, Rust, Python, Go, Ruby | +| **Framework** | Web, mobile, and backend framework expertise | React, Next.js, Django, Rails, Phoenix | +| **Validation** | Linters, formatters, and static analysis | Biome, ESLint, RuboCop, ShellCheck | +| **Tool** | Build systems, test runners, and dev utilities | Playwright, Jest, Pytest, Terraform | +| **Integration** | MCP servers for external services | GitHub, GitLab, Linear, Sentry | +| **Discipline** | Specialized AI agents for engineering domains | Security, accessibility, API design, architecture | +| **Pattern** | Methodologies and workflow practices | TDD, BDD, AI-DLC, git worktrees | +| **Specialized** | Domain-specific plugins that don't fit elsewhere | Android, iOS, embedded | Browse all plugins at [han.guru/plugins](https://han.guru/plugins/) diff --git a/website/content/docs/_nav.json b/website/content/docs/_nav.json index 8cf713a1f..a6e0f95f5 100644 --- a/website/content/docs/_nav.json +++ b/website/content/docs/_nav.json @@ -75,7 +75,7 @@ "slug": "features/hooks" }, { - "title": "Checkpoints", + "title": "Session-Scoped Validation", "slug": "features/checkpoints" }, { diff --git a/website/content/docs/cli/hooks.md b/website/content/docs/cli/hooks.md index 13525b6fe..cac93e9ee 100644 --- a/website/content/docs/cli/hooks.md +++ b/website/content/docs/cli/hooks.md @@ -30,11 +30,13 @@ han hook run --dirs-with -- | Option | Description | |--------|-------------| | `--no-cache` | Disable caching (caching is ON by default in v2.0.0+) | -| `--no-checkpoints` | Disable checkpoint filtering (checkpoints are ON by default) | +| `--only ` | Only run in the specified directory, for targeted re-runs after a failure | | `--verbose` | Show full command output in real-time | -| `--directory ` | Limit execution to specific directory | -| `--checkpoint-type ` | Filter against checkpoint type (`session` or `agent`) | -| `--checkpoint-id ` | Filter against specific checkpoint ID | +| `--skip-deps` | Skip dependency checks, for recheck and retry scenarios | +| `--session-id ` | Claude session ID used for event logging and cache tracking | +| `--async` | Enable per-file `${HAN_FILES}` substitution (PostToolUse) or session-file substitution (Stop) | + +`--checkpoint-type ` and `--checkpoint-id ` are still parsed and validated, but the hook runner ignores them. They are inert remnants of the removed checkpoint feature. **Legacy options:** @@ -47,14 +49,14 @@ han hook run --dirs-with -- ### Caching Behavior -Caching is enabled by default (since v2.0.0): +Caching is enabled by default (since v2.0.0). A hook run: -1. Han creates a checkpoint with file modification times -2. On subsequent runs, compares current file times to checkpoint -3. Skips execution if no files have changed -4. Clears checkpoint on failure (ensures retry on next run) +1. Builds a manifest of the files matching the hook's `if_changed` patterns +2. Skips the hook when nothing the current session touched appears in that manifest +3. Otherwise compares each file's hash against the hash recorded at its last validation, and runs when any differ or a validated file was deleted +4. Assumes changes and runs on any error, so a cache fault never silently skips validation -Checkpoints are session-scoped by default, meaning they're cleared when the Claude Code session ends. Use `--no-checkpoints` to disable checkpoint filtering entirely. +Pass `--no-cache` (or set `HAN_NO_CACHE=1`) to force a run. ### Examples @@ -68,8 +70,8 @@ han hook run bun test --no-cache # Run TypeScript type checking verbosely han hook run typescript typecheck --verbose -# Run Biome lint in specific directory -han hook run biome lint --directory packages/core +# Run Biome lint in one directory only +han hook run biome lint --only packages/core # Legacy: Run npm test in directories with package.json han hook run --dirs-with package.json -- npm test @@ -91,9 +93,9 @@ hooks: When you run `han hook run bun test`, Han: 1. Finds directories containing `bun.lock` or `bun.lockb` -2. Checks if files matching `**/*.ts` or `**/*.test.ts` have changed (caching is enabled by default) +2. Checks whether files matching `**/*.ts` or `**/*.test.ts` have changed (caching is enabled by default) 3. Runs `bun test --only-failures` in each directory -4. Records the result and updates checkpoints +4. Records the result and updates the file-validation cache ## `han hook list` @@ -108,6 +110,9 @@ han hook list # Filter by plugin han hook list --plugin bun +# Filter by event type +han hook list --event Stop + # Show detailed information han hook list --verbose ``` @@ -116,8 +121,9 @@ han hook list --verbose | Option | Description | |--------|-------------| -| `--plugin ` | Filter hooks by plugin name | -| `--verbose` | Show detailed hook configuration | +| `-e, --event ` | Filter by event type (for example `Stop`, `PreToolUse`) | +| `-p, --plugin ` | Filter hooks by plugin name (substring match) | +| `-v, --verbose` | Show additional details including source paths | | `--json` | Output as JSON for scripting | ### Output @@ -151,93 +157,89 @@ han hook list --json | jq '.[] | select(.plugin == "bun")' ## `han hook explain` -Show detailed explanation of a hook's configuration and behavior. +Show comprehensive information about configured hooks. By default it covers every hook Claude Code would fire, both Han plugin hooks and hooks defined in settings files. + +The optional positional argument is an **event type**, not a plugin or hook name. ### Usage ```bash -# Explain a specific hook -han hook explain -``` +# All hooks, Han plugins plus Claude Code settings +han hook explain -### Example - -```bash -han hook explain bun test -``` +# Only Stop hooks, from all sources +han hook explain Stop -Output: +# Only hooks contributed by Han plugins +han hook explain --han-only +# Combine both +han hook explain Stop --han-only ``` -Hook: bun/test -Description: Run Bun tests - -Command: bun test --only-failures - -Directories: Runs in directories containing: - - bun.lock - - bun.lockb - -File Patterns: Triggers when these files change: - - **/*.ts - - **/*.test.ts +### Options -Cache: Enabled by default (use --no-cache to disable) +| Option | Description | +|--------|-------------| +| `--han-only` | Show only Han plugin hooks, excluding settings hooks | -Usage: - han hook run bun test - han hook run bun test --no-cache - han hook run bun test --verbose --directory packages/core -``` +## `han hook test` -## `han hook verify` +Run hooks with simulated Claude Code input and show their actual output. This executes the hooks, which is the point: it reproduces a hook failure exactly as Claude Code would trigger it. -Verify hook configuration for all installed plugins. +As with `explain`, the optional positional argument is an event type. ### Usage ```bash -# Verify all plugin hooks -han hook verify +# Test every hook +han hook test + +# Test only SessionStart hooks +han hook test SessionStart -# Verify specific plugin -han hook verify --plugin bun +# Show the stdin payload sent to hooks +han hook test Stop --payload + +# Only hooks whose command contains "han" +han hook test --command han ``` ### Options | Option | Description | |--------|-------------| -| `--plugin ` | Verify hooks for specific plugin only | -| `--fix` | Attempt to fix common issues | +| `--payload` | Show the stdin JSON payload sent to hooks | +| `--command ` | Filter to hooks whose command contains this string | -Checks for: +## `han hook dispatch` -- Valid hook configuration syntax -- Command executability -- File pattern validity -- Directory detection logic - -## `han hook test` - -Test a hook configuration without actually running it. +Run every Han plugin hook registered for one event. This is what Claude Code invokes from a generated `hooks.json`. ### Usage ```bash -# Test which directories a hook would run in -han hook test - -# Test with specific directory -han hook test --directory packages/core +han hook dispatch Stop +han hook dispatch PostToolUse --all ``` -Shows: +### Options + +| Option | Description | +|--------|-------------| +| `-a, --all` | Include hooks defined in Claude Code settings, not just Han plugin hooks | +| `--no-cache` | Disable caching for the dispatched hooks | -- Detected directories -- Files that would trigger execution (if `ifChanged` is set) -- Checkpoint status (if `--cache` would skip) +`--no-checkpoints` exports `HAN_NO_CHECKPOINTS=1` to child hooks, which turns off session-scoped `${HAN_FILES}` filtering so hooks check the whole tree. See [session-scoped validation](/docs/features/checkpoints#turning-session-filtering-off). + +## Other `han hook` subcommands + +| Command | Purpose | +|---------|---------| +| `han hook context` | Print consolidated session context for `SessionStart` injection | +| `han hook auto-detect` | Auto-install plugins from file changes, for `PostToolUse` | +| `han hook auto-detect-prompt` | Auto-install plugins from URLs and patterns in a prompt, for `UserPromptSubmit` | +| `han hook wrap-subagent-context` | `PreToolUse` helper that injects context into Agent and Skill tool prompts | ## Environment Variables @@ -246,8 +248,16 @@ Hook execution respects these environment variables: | Variable | Description | |----------|-------------| | `HAN_DISABLE_HOOKS` | Set to `1` or `true` to disable all hooks | +| `HAN_FORCE_HOOKS` | Set to `1` or `true` to run hooks even where they would otherwise be suppressed | | `HAN_HOOK_RUN_VERBOSE` | Set to `1` or `true` to enable verbose output globally | -| `HAN_MCP_TIMEOUT` | Hook timeout in milliseconds (default: 600000 = 10 minutes) | +| `HAN_NO_CACHE` | Set to `1` or `true` to disable caching for this run | +| `HAN_SESSION_ID` | Session ID used for event logging, cache tracking, and lock scoping | +| `HAN_DEBUG` | Set to `1` or `true` for debug diagnostics on stderr | +| `HAN_HOOK_ABSOLUTE_TIMEOUT` | Hard ceiling on a single hook, in seconds (default: 300) | +| `HAN_HOOK_PARALLELISM` | Parallel hook slots (default: half the CPU count, minimum 1) | +| `HAN_HOOK_ACQUIRE_TIMEOUT` | How long to wait for a free slot, in milliseconds (default: 3600000) | +| `HAN_HOOK_LOCK_TIMEOUT` | When a held lock is treated as stale, in milliseconds (default: 900000) | +| `HAN_HOOK_NO_LOCK` | Set to `1` to disable hook resource locking entirely | ### Example @@ -288,27 +298,28 @@ Hooks run automatically at lifecycle events when configured in plugin `hooks.jso ### Hook Lifecycle -Han supports these Claude Code hook points: - -| Hook | When It Fires | Checkpoint Type | Purpose | -|------|---------------|-----------------|---------| -| `SessionStart` | Claude Code session begins | Creates session checkpoint | Initialize session state | -| `SubagentStart` | Subagent is spawned | Creates agent checkpoint | Capture pre-subagent state | -| `UserPromptSubmit` | User submits a prompt | N/A | Pre-process user input | -| `PreToolUse` | Before each tool call | N/A | Validate tool usage | -| `PermissionRequest` | Permission dialog appears | N/A | Audit/auto-approve permissions | -| `PostToolUse` | After each tool call | N/A | Post-process tool results | -| `PostToolUseFailure` | Tool execution fails | N/A | Error tracking, recovery | -| `Stop` | Agent about to respond | Validates using session checkpoint | Validate all session changes | -| `SubagentStop` | Subagent completes | Validates using agent checkpoint | Validate subagent changes | -| `Notification` | Notification event | N/A | Custom notification handling | -| `PreCompact` | Before context compaction | N/A | Save state before compaction | -| `SessionEnd` | Session ends | N/A | Cleanup session state | -| `ConfigChange` | Configuration modified (2.1.49+) | N/A | Audit trails, config monitoring | -| `TeammateIdle` | Teammate goes idle (2.1.33+) | N/A | Team coordination | -| `TaskCompleted` | Task completed (2.1.33+) | N/A | Task tracking, workflows | -| `WorktreeCreate` | Worktree created (2.1.50+) | N/A | Agent isolation tracking, custom VCS | -| `WorktreeRemove` | Worktree removed (2.1.50+) | N/A | Cleanup automation | +Han accepts all 31 Claude Code hook events. The events below are the ones you will most often wire up from the CLI; see the [full hook event reference](/docs/plugin-development/hooks#hook-lifecycle) for the complete list, current as of Claude Code 2.1.228. + +| Hook | When It Fires | Purpose | +|------|---------------|---------| +| `SessionStart` | Claude Code session begins | Initialize session state | +| `SubagentStart` | Subagent is spawned | Inject context into the subagent | +| `UserPromptSubmit` | User submits a prompt | Pre-process user input | +| `PreToolUse` | Before each tool call | Validate tool usage | +| `PermissionRequest` | Permission dialog appears | Audit or auto-approve permissions | +| `PostToolUse` | After each tool call | Post-process tool results | +| `PostToolUseFailure` | Tool execution fails | Error tracking, recovery | +| `Stop` | Agent about to respond | Validate all session changes | +| `SubagentStop` | Subagent completes | Validate subagent changes | +| `Notification` | Notification event | Custom notification handling | +| `PreCompact` | Before context compaction | Save state before compaction | +| `SessionEnd` | Session ends | Cleanup session state | +| `ConfigChange` | Configuration modified (2.1.49+) | Audit trails, config monitoring | +| `TeammateIdle` | Teammate goes idle (2.1.33+) | Team coordination | +| `TaskCompleted` | Task completed (2.1.33+) | Task tracking, workflows | +| `DirectoryAdded` | Directory added mid-session (2.1.219+) | React to a newly registered working root | +| `WorktreeCreate` | Worktree created (2.1.50+) | Agent isolation tracking, custom VCS | +| `WorktreeRemove` | Worktree removed (2.1.50+) | Cleanup automation | ### New Hook Events @@ -420,7 +431,7 @@ Only `type: "command"` hooks are supported. No matcher support. #### WorktreeRemove (2.1.50+) -Fired when a worktree is being removed (session exit or subagent completion). The hook receives the `worktree_path` that was created. WorktreeRemove hooks **cannot block** removal — failures are logged in debug mode only. +Fired when a worktree is being removed (session exit or subagent completion). The hook receives the `worktree_path` that was created. WorktreeRemove hooks **cannot block** removal; failures are logged in debug mode only. ```json { @@ -448,19 +459,14 @@ The `Stop` and `SubagentStop` hook inputs now include a `last_assistant_message` This is useful for content-aware validation, sentiment analysis, or logging the agent's final output. -### Checkpoint Filtering - -Hooks automatically filter what files they check based on when they run: +### How Hooks Narrow What They Check -- **Stop hooks** validate against the session checkpoint (created at `SessionStart`) - - Only checks files modified during the entire session - - Use for session-wide validations (tests, builds, linting) +Hooks do not re-check the whole tree on every run: -- **SubagentStop hooks** validate against the agent checkpoint (created at `SubagentStart`) - - Only checks files modified by that specific subagent - - Use for focused validations (type checking, unit tests) +- **Caching** skips a hook when nothing the current session touched matches its `if_changed` patterns, then compares file hashes against the last recorded validation +- **Session filtering** hands a hook only the session's own modified files, for any command that uses `${HAN_FILES}` -This ensures hooks only validate relevant changes and skip unchanged files automatically. +Earlier versions did this with explicit session and agent checkpoints captured at `SessionStart` and `SubagentStart`. That mechanism has been removed. The `--checkpoint-type`, `--checkpoint-id`, and `--no-checkpoints` flags survive as no-ops. ## Learn More diff --git a/website/content/docs/cli/index.md b/website/content/docs/cli/index.md index 1c8d322ae..de9885eba 100644 --- a/website/content/docs/cli/index.md +++ b/website/content/docs/cli/index.md @@ -42,12 +42,12 @@ han hook list ### [Other Commands](/docs/cli/other) -Version, help, and update commands. +Diagnostics, storage, parsing, worktrees, the coordinator daemon, and the browse dashboard. ```bash -han --version -han --help -han update +han doctor +han browse +han keep list ``` ## Global Options @@ -66,15 +66,20 @@ Han respects these environment variables: | Variable | Description | |----------|-------------| | `HAN_DISABLE_HOOKS` | Set to `1` or `true` to disable all hooks | +| `HAN_FORCE_HOOKS` | Set to `1` or `true` to run hooks even where they would otherwise be suppressed | | `HAN_HOOK_RUN_VERBOSE` | Set to `1` or `true` to enable verbose hook output | | `HAN_NO_CACHE` | Set to `1` to disable smart caching (force hooks to run) | -| `HAN_NO_FAIL_FAST` | Set to `1` to continue running hooks even after failures | -| `HAN_NO_CHECKPOINTS` | Set to `1` to disable checkpoint-based filtering | -| `HAN_CHECKPOINT_TYPE` | Checkpoint type for filtering (`session` or `agent`) | -| `HAN_CHECKPOINT_ID` | Checkpoint ID for filtering hooks | +| `HAN_SESSION_ID` | Session ID used for event logging, cache tracking, and lock scoping | +| `HAN_DEBUG` | Set to `1` or `true` for debug diagnostics on stderr | +| `HAN_BINARY` | Override the han binary command or path | +| `HAN_DATA_DIR` | Override the Han data directory (default: `~/.han`) | +| `HAN_COORDINATOR_PORT` | Coordinator daemon port (default: 41957) | +| `HAN_BROWSE_PORT` | Browse UI port (default: 41956) | | `CLAUDE_CODE_ENABLE_TELEMETRY` | Set to `1` to enable OpenTelemetry export | | `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry collector endpoint | +`HAN_NO_CHECKPOINTS=1` turns off session-scoped `${HAN_FILES}` filtering. `HAN_CHECKPOINT_TYPE` and `HAN_CHECKPOINT_ID` are still set by `han hook dispatch` but are no longer read by anything. See [session-scoped validation](/docs/features/checkpoints). + **Note:** Environment variables override `han.yml` settings at runtime. See [OpenTelemetry](/docs/metrics/opentelemetry) for more telemetry variables. @@ -91,4 +96,4 @@ See [OpenTelemetry](/docs/metrics/opentelemetry) for more telemetry variables. - [Plugin Commands](/docs/cli/plugins) - Detailed plugin management reference - [Hook Commands](/docs/cli/hooks) - Detailed hook management reference -- [Other Commands](/docs/cli/other) - Version, help, and update commands +- [Other Commands](/docs/cli/other) - Everything else the CLI exposes diff --git a/website/content/docs/cli/other.md b/website/content/docs/cli/other.md index b9431dfc9..b416be114 100644 --- a/website/content/docs/cli/other.md +++ b/website/content/docs/cli/other.md @@ -1,13 +1,13 @@ --- title: "Other Commands" -description: "Additional CLI commands for version, help, and updates." +description: "Version, help, diagnostics, storage, parsing, and the remaining top-level han commands." --- -Additional Han CLI commands for version information, help, and updates. +Everything the `han` CLI exposes outside the plugin and hook command groups. ## `han --version` -Show the current Han version. +Show the current Han version, along with the binary location and config status. ### Usage @@ -16,12 +16,6 @@ han --version han -V ``` -### Output - -``` -1.61.6 -``` - ### Example ```bash @@ -30,7 +24,7 @@ $ han --version 1.61.6 # Use in scripts -if [ "$(han --version | cut -d. -f1)" -ge 2 ]; then +if [ "$(han --version | head -1 | cut -d. -f1)" -ge 2 ]; then echo "Han v2+ detected" fi ``` @@ -51,359 +45,243 @@ han plugin --help han hook run --help ``` -### Output - -``` -Usage: han [options] [command] - -Utilities for The Bushido Collective's Han Code Marketplace - -Options: - -V, --version output the version number - -h, --help display help for command - -Commands: - plugin Manage Han plugins - hook Hook utilities - mcp MCP server utilities - memory Memory management - metrics Metrics and analytics - checkpoint Checkpoint management - explain Show comprehensive overview of Han configuration - summary AI-powered summary of how Han is improving this repository - gaps AI-powered analysis of repository gaps and Han plugin recommendations - help [command] display help for command -``` - -### Examples - -```bash -# General help -han --help - -# Plugin command help -han plugin --help - -# Hook run command help -han hook run --help -``` - -## `han update` - -Update Han to the latest version. - -### Usage - -```bash -# Update to latest version -han update - -# Update to specific version -han update --version 1.61.6 - -# Check for updates without installing -han update --check -``` - -### Options - -| Option | Description | -|--------|-------------| -| `--version ` | Update to specific version | -| `--check` | Check for updates without installing | -| `--force` | Force reinstall even if up to date | - -### Update Methods - -The update behavior depends on how Han was installed: - -**Homebrew installation:** +### Command groups + +| Command | Purpose | +|---------|---------| +| `plugin` | Manage Han plugins | +| `create` | Scaffold new Han resources | +| `hook` | Hook utilities | +| `mcp` | Start a Han MCP server | +| `blueprints` | Manage technical blueprint documentation | +| `memory` | Memory system status | +| `keep` | Scoped key-value storage across sessions | +| `parse` | JSON and YAML parsing utilities | +| `reindex` | Clear the database and reindex from JSONL logs | +| `worktree` | Git worktree management | +| `coordinator` | Manage the coordinator daemon | +| `config` | Manage Han CLI configuration | +| `auth` | Manage authentication with the Han team server | +| `sync` | Manage data synchronization to the team platform | +| `browse` | Start the Han system browser dashboard | +| `doctor` | Run diagnostics | +| `setup` | Generate config files for non-Claude-Code agents | +| `completion` | Generate a shell completion script | +| `install` | Alias for `plugin install --auto` | +| `uninstall` | Remove the Han marketplace and plugins | + +## Updating Han + +There is no `han update` subcommand. Update through whichever channel installed the binary: + +**Homebrew:** ```bash -# Update via Homebrew brew update brew upgrade thebushidocollective/tap/han ``` -**curl installation:** +**curl installer:** ```bash -# Update via installer script curl -fsSL https://han.guru/install.sh | bash ``` -**npm installation:** +**npm:** ```bash -# Update via npm npm update -g @thebushidocollective/han ``` -### Examples - -```bash -# Update to latest -han update +To refresh the plugin marketplace cache rather than the binary, use `han plugin update-marketplace`. -# Check current version first -han --version -han update --check +## `han doctor` -# Update to specific version -han update --version 1.61.0 -``` - -## `han explain` - -Show comprehensive overview of Han configuration. +Run diagnostics against your Han installation: binary location, config file discovery, coordinator health, and index database status. ### Usage ```bash -han explain -``` - -### Output - -Displays: - -- Installed plugins by scope (user, project, local) -- Active hooks and their triggers -- MCP servers and their status -- Configuration file locations -- Checkpoint and metrics status - -### Example - -```bash -$ han explain - -Han Configuration Overview -========================== - -Installed Plugins (user): - - github (v1.2.3) - GitHub integration - - bun (v1.0.0) - Bun runtime support +han doctor -Installed Plugins (project): - - typescript (v1.1.0) - TypeScript validation - -Active Hooks: - bun/test (Stop hook) - typescript/typecheck (Stop hook) - -MCP Servers: - - han (built-in) - - github (github plugin) - -Configuration: - User: ~/.claude/han.yml - Project: .claude/han.yml - Local: .claude/han.local.yml (not found) - -Metrics: ~/.claude/han/metrics/ -Checkpoints: ~/.claude/han/checkpoints/ +# Machine-readable +han doctor --json ``` -## `han summary` - -AI-powered summary of how Han is improving the repository. - -### Usage - -```bash -han summary -``` +### Options -Analyzes: +| Option | Description | +|--------|-------------| +| `--json` | Output results as JSON | -- Hook execution history -- Task completion metrics -- Calibration trends -- Plugin usage patterns +## `han browse` -Generates a natural language summary of Han's impact on your development workflow. +Start the Han system browser dashboard, a local UI for searching session history and inspecting hook activity. -### Example +### Usage ```bash -$ han summary +han browse -Analyzing Han's impact on your repository... +# Pick a port +han browse --port 41956 -Over the past month, Han has: -- Run 147 validation hooks with 94% success rate -- Tracked 23 tasks with average confidence of 0.82 -- Detected and prevented 8 potential issues before commit -- Improved calibration accuracy from 0.68 to 0.79 +# Run the local dev server over HTTP instead of opening the remote dashboard +han browse --local +``` -Top performing hooks: - 1. bun/test (100% success) - 2. typescript/typecheck (96% success) - 3. biome/lint (92% success) +### Options -Recommendations: -- Consider adding playwright-mcp for browser testing -- Hook failure rate for biome/lint suggests review of lint rules -``` +| Option | Description | +|--------|-------------| +| `-p, --port ` | Port to run the server on (default: 41956) | +| `-l, --local` | Run the local dev server for offline use | -## `han gaps` +## `han setup` -AI-powered analysis of repository gaps and plugin recommendations. +Generate config files so agents other than Claude Code can invoke Han's hook dispatch. ### Usage ```bash -han gaps -``` - -Analyzes your repository and suggests Han plugins that could add value based on: +# All supported agents +han setup -- Detected technologies and frameworks -- Missing validation hooks -- Development patterns -- Team workflows +# One agent +han setup --agent codex -### Example +# Overwrite existing files +han setup --agent opencode --force +``` -```bash -$ han gaps +### Options -Analyzing repository for improvement opportunities... +| Option | Description | +|--------|-------------| +| `--agent ` | One of `codex`, `kiro`, `gemini`, `opencode`, `agents-md` | +| `--all` | Generate configs for all agents (default) | +| `--force` | Overwrite existing config files | -Detected Technologies: - - TypeScript (✓ typescript installed) - - Bun (✓ bun installed) - - React (missing plugin) - - PostgreSQL (missing integration) +## `han keep` -Recommended Plugins: +Scoped key-value storage for persisting state across sessions. - react - React development patterns and hooks - Why: 23 React components detected without validation +### Usage - postgresql - PostgreSQL database integration - Why: Database queries found without schema validation +```bash +# Save (reads stdin when no content is given) +han keep save build-notes "release blocked on flaky test" +cat notes.md | han keep save notes - code-reviewer - Multi-agent code review system - Why: No automated code review process detected +# Read it back +han keep load build-notes -Install with: - han plugin install react postgresql code-reviewer +# Inspect and clean up +han keep list +han keep delete build-notes +han keep clear ``` -## `han checkpoint` - -Manage session and agent checkpoints for validation filtering. - -Checkpoints capture the state of files at specific points (session start, subagent start) to enable efficient validation filtering. Hooks can use the `if_changed` option to only run when relevant files have changed since the checkpoint. +Every subcommand takes `--global` or `--repo` to choose the storage scope. -### `han checkpoint capture` +## `han parse` -Capture a checkpoint of current file state. Can read from stdin (hook payload) or use explicit options. - -#### Usage +JSON and YAML utilities so plugin hooks do not need `jq` or `yq` on the box. ```bash -# From hook (reads stdin JSON with hook_event_name and session_id/agent_id) -echo '{"hook_event_name": "SessionStart", "session_id": "abc123"}' | han checkpoint capture - -# With explicit options -han checkpoint capture --type session --id abc123 -han checkpoint capture --type agent --id agent-xyz -``` +# Extract a path from JSON on stdin +echo '{"a":{"b":[1,2]}}' | han parse json a.b[0] -#### Auto-detection +# Set a value +echo '{}' | han parse json-set a.b 42 -When reading from stdin, checkpoint type is automatically determined: +# Validate a shape +echo '{"name":"x"}' | han parse json-validate --schema '{"name":"string"}' -- `SessionStart` → captures session checkpoint using `session_id` -- `SubagentStart` → captures agent checkpoint using `agent_id` +# YAML, including markdown frontmatter +cat SKILL.md | han parse yaml name +cat SKILL.md | han parse yaml-set version 2.0.0 -#### Options +# Convert between the two +han parse yaml-to-json < config.yml +han parse json-to-yaml < config.json +``` -| Option | Description | -|--------|-------------| -| `--type ` | Checkpoint type: `session` or `agent` | -| `--id ` | Checkpoint ID (session_id or agent_id) | +## `han reindex` -#### Example +Clear the index database and rebuild it from the JSONL session logs. ```bash -# Typically called from SessionStart hook -han checkpoint capture < /tmp/hook-payload.json +# Rebuild (default subcommand) +han reindex +han reindex --verbose -# Manual capture -han checkpoint capture --type session --id my-session-123 +# Query the index, for testing +han reindex search "auth refactor" -# Output -Checkpoint captured: session/my-session-123 +# Check index state +han reindex status ``` -### `han checkpoint list` +## `han memory` -List active checkpoints for the current project. - -#### Usage +Prints the current state of the memory system. Memory is indexed automatically from Claude Code session transcripts, so there is nothing to run by hand; use `han browse` to search session history. ```bash -han checkpoint list +han memory ``` -#### Output +## `han worktree` -```bash -$ han checkpoint list +Git worktree management for parallel development. -Active Checkpoints -================== +```bash +han worktree add ../feature-x feature/x --create-branch +han worktree list --json +han worktree discover +han worktree remove ../feature-x --force +han worktree prune --dry-run +``` -Session Checkpoints: - - abc123 (captured 2 hours ago) - - def456 (captured 1 day ago) +## `han coordinator` -Agent Checkpoints: - - agent-xyz (captured 5 minutes ago) - - agent-abc (captured 3 hours ago) +Manage the coordinator daemon that indexes sessions in the background. -Total: 4 checkpoints +```bash +han coordinator start +han coordinator status +han coordinator logs --follow +han coordinator restart +han coordinator stop ``` -### `han checkpoint clean` +On macOS, `han coordinator launchd install` registers the daemon to start on login, with matching `uninstall` and `status` subcommands. -Remove stale checkpoints older than specified age. +## `han completion` -#### Usage +Generate a shell completion script. ```bash -# Remove checkpoints older than 24 hours (default) -han checkpoint clean - -# Custom age in hours -han checkpoint clean --max-age 48 +han completion bash +han completion zsh +han completion fish ``` -#### Options +## `han install` and `han uninstall` -| Option | Description | -|--------|-------------| -| `--max-age ` | Remove checkpoints older than N hours (default: 24) | - -#### Example +Top-level aliases kept for backwards compatibility. ```bash -$ han checkpoint clean --max-age 48 +# Same as: han plugin install --auto +han install +han install --scope local -Cleaning checkpoints older than 48 hours... - -Removed: - - session/old-session-1 (72 hours old) - - session/old-session-2 (96 hours old) - - agent/old-agent-1 (120 hours old) - -Cleaned 3 checkpoints +# Remove the Han marketplace and all its plugins +han uninstall ``` +`han install --scope` accepts `project` (default) or `local`. + ## Learn More - [Installation Guide](/docs/installation) - Getting started with Han diff --git a/website/content/docs/cli/plugins.md b/website/content/docs/cli/plugins.md index 71a8015d1..6943a42e1 100644 --- a/website/content/docs/cli/plugins.md +++ b/website/content/docs/cli/plugins.md @@ -12,63 +12,73 @@ Install one or more Han plugins. ### Usage ```bash +# Interactive picker +han plugin install + # Install a single plugin han plugin install # Install multiple plugins han plugin install -# Auto-install recommended plugins based on repository +# Auto-detect plugins for this repository han plugin install --auto -# Install to project scope instead of user scope -han plugin install --scope project - # Install to local (gitignored) scope han plugin install --scope local + +# Install from an external marketplace repo +han plugin install --from thebushidocollective/ai-dlc ``` ### Options | Option | Description | |--------|-------------| -| `--auto` | Automatically detect and install recommended plugins | -| `--scope ` | Installation scope: `user` (default), `project`, or `local` | -| `--force` | Force reinstall if already installed | +| `--auto` | Auto-detect plugins using file markers and AI analysis | +| `--no-analyze` | With `--auto`, skip AI analysis and use file-marker detection only | +| `--scope ` | Installation scope: `project` (default) or `local` | +| `--from ` | Install from an external GitHub marketplace repo | ### Installation Scopes -By default, plugins install to user settings (`~/.claude/settings.json`) which applies across all projects: +Han plugins always install at project or local scope, never user scope. This keeps plugins tracked per project instead of polluting global settings. | Scope | Location | Use Case | |-------|----------|----------| -| `user` | `~/.claude/settings.json` | MCP servers, general-purpose plugins | -| `project` | `.claude/settings.json` | Project-specific validation hooks | -| `local` | `.claude/settings.local.json` | Personal preferences not shared with team | +| `project` | `.claude/settings.json` | Shared with the team, committed to the repo | +| `local` | `.claude/settings.local.json` | Personal preferences, gitignored | + +`han plugin install --scope user` is rejected with an error. If you omit `--scope`, Han reuses whichever project-level scope already has Han configured, and otherwise defaults to `project`. + +Note the asymmetry: `han plugin list` and `han plugin uninstall` do still accept `user`, because Han must be able to see and clean up plugins written to `~/.claude/settings.json` by older versions or by hand. ### Examples ```bash -# Install GitHub integration globally +# Install GitHub integration for this project han plugin install github -# Install TypeScript validation for this project -han plugin install typescript --scope project +# Install TypeScript validation without sharing it with the team +han plugin install typescript --scope local -# Auto-detect and install recommended plugins +# Auto-detect and install for your stack han plugin install --auto -# Install multiple plugins at once +# Auto-detect using file markers only, no AI analysis +han plugin install --auto --no-analyze + +# Install several at once han plugin install bun biome playwright-mcp ``` ### Auto-Detection -When using `--auto`, Han analyzes your repository and suggests relevant plugins: +With `--auto`, Han analyzes your repository and installs relevant plugins: - Detects package managers (npm, bun, pnpm) -- Identifies languages (TypeScript, Python, Go, etc.) -- Recognizes frameworks (Next.js, React, etc.) +- Identifies languages (TypeScript, Python, Go, and so on) +- Recognizes frameworks (Next.js, React, and so on) - Suggests appropriate plugins for your stack ## `han plugin list` @@ -78,54 +88,20 @@ List installed Han plugins. ### Usage ```bash -# List all installed plugins +# Every scope (default) han plugin list -# List plugins in a specific scope +# One scope han plugin list --scope user han plugin list --scope project han plugin list --scope local - -# Show detailed information -han plugin list --verbose ``` ### Options | Option | Description | |--------|-------------| -| `--scope ` | Filter by scope: `user`, `project`, or `local` | -| `--verbose` | Show detailed plugin information | -| `--json` | Output as JSON for scripting | - -### Output - -``` -Installed Han Plugins (user scope): - - github (v1.2.3) - GitHub integration for Claude Code - MCP Server: github - Commands: /create-pr, /review-pr - Skills: github-workflow-patterns - - bun (v1.0.0) - Bun runtime and testing support - Hooks: test, build -``` - -### Examples - -```bash -# List all plugins -han plugin list - -# List only project-specific plugins -han plugin list --scope project - -# Get JSON output for scripting -han plugin list --json | jq '.[] | select(.type == "technique")' -``` +| `--scope ` | Scope to list: `user`, `project`, `local`, or `all` (default: `all`) | ## `han plugin uninstall` @@ -140,7 +116,7 @@ han plugin uninstall # Uninstall multiple plugins han plugin uninstall -# Uninstall from specific scope +# Uninstall from a specific scope han plugin uninstall --scope project ``` @@ -148,21 +124,11 @@ han plugin uninstall --scope project | Option | Description | |--------|-------------| -| `--scope ` | Uninstall from specific scope: `user`, `project`, or `local` | -| `--all-scopes` | Uninstall from all scopes | - -### Examples +| `--scope ` | Scope to uninstall from: `user` (default), `project`, or `local` | -```bash -# Uninstall GitHub integration -han plugin uninstall github - -# Uninstall TypeScript plugin from project scope -han plugin uninstall typescript --scope project +The default here is `user`, which does not match `install`'s default of `project`. Pass `--scope` explicitly when uninstalling a plugin you installed at project scope. -# Uninstall from all scopes -han plugin uninstall bun --all-scopes -``` +There is no `han plugin remove`; the command is `uninstall`. ## `han plugin search` @@ -171,58 +137,72 @@ Search for available plugins in the Han marketplace. ### Usage ```bash -# Search all plugins +# List everything han plugin search -# Search for specific term -han plugin search +# Search for a term +han plugin search typescript +``` + +The search matches plugin names, descriptions, and keywords. There are no filter flags; pipe the output through `grep` to narrow further. + +## `han plugin validate` + +Validate the plugin in the current directory: manifest structure, `han-plugin.yml` schema, and the Claude Code plugin manifest fields. Exits silently when the directory is not a plugin, so it is safe to wire into a hook. + +### Usage -# Filter by category -han plugin search --category technique -han plugin search --category bridge +```bash +han plugin validate + +# Errors only, suppress warnings +han plugin validate --quiet ``` ### Options | Option | Description | |--------|-------------| -| `--category ` | Filter by category: `technique`, `bridge`, `discipline`, `core` | -| `--json` | Output as JSON for scripting | +| `-q, --quiet` | Only show errors, not warnings | -### Examples +Han knows the 26 top-level fields of the Claude Code plugin manifest, including `settings` and `dependencies`. Claude Code itself ignores fields it does not recognize, so Han matches that: an unrecognized key is reported as a warning, not an error, and the manifest still validates. The two experimental component keys, `themes` and `monitors`, are accepted at the top level and belong under `experimental` going forward. + +## `han plugin generate-hooks` + +Generate `hooks/hooks.json` from a plugin's `han-plugin.yml`. + +### Usage ```bash -# Search for TypeScript-related plugins -han plugin search typescript +# Current directory +han plugin generate-hooks -# List all bridge (MCP) plugins -han plugin search --category bridge +# A specific plugin directory +han plugin generate-hooks plugins/tools/biome + +# Every plugin in the marketplace +han plugin generate-hooks --all +``` -# Get JSON output -han plugin search --json | jq '.[] | select(.category == "technique")' +## `han plugin migrate` + +Rewrite old prefixed plugin names to their short names in your settings files. The legacy `jutsu-`, `do-`, and `hashi-` prefixes remain valid marketplace aliases, so migrating is a tidiness step rather than a fix for a broken install. + +```bash +han plugin migrate ``` ## `han plugin update-marketplace` -Update the local plugin marketplace cache. +Update the local plugin marketplace cache from GitHub. ### Usage ```bash -# Update marketplace cache han plugin update-marketplace - -# Force update even if recently updated -han plugin update-marketplace --force ``` -### Options - -| Option | Description | -|--------|-------------| -| `--force` | Force update even if cache is fresh | - -The marketplace cache is stored in `~/.claude/han/marketplace-cache.json` and updates automatically every 24 hours. +The cache lives at `~/.claude/cache/han-marketplace.json` (or `$CLAUDE_CONFIG_DIR/cache/han-marketplace.json`) and is refreshed automatically once it is more than 24 hours old. This command forces a refresh now; it takes no options. ## Learn More diff --git a/website/content/docs/configuration/caching.md b/website/content/docs/configuration/caching.md index ddac83d4a..f7451d110 100644 --- a/website/content/docs/configuration/caching.md +++ b/website/content/docs/configuration/caching.md @@ -7,21 +7,17 @@ Han's smart caching system eliminates redundant hook executions, saving time and ## Breaking Change in v2.0.0 -Han v2.0.0 introduced a **default-ON** philosophy for performance features: +Han v2.0.0 made caching **default-ON**: | Feature | v1.x Default | v2.0.0+ Default | Migration | |---------|--------------|-----------------|-----------| | `cache` | OFF | **ON** | Use `--no-cache` or `HAN_NO_CACHE=1` | -| `checkpoints` | OFF | **ON** | Use `--no-checkpoints` or `hooks.checkpoints: false` | -| `fail_fast` | OFF | **ON** | Use `--no-fail-fast` or `hooks.fail_fast: false` | -**Why this change?** Most users want optimized, fast hook execution by default. The new defaults provide: +**Why this change?** Most users want fast hook execution by default, and a hook that re-lints an untouched tree on every turn is pure latency. -- Faster development cycles through smart caching -- Fresh validation at session boundaries via checkpoints -- Quicker feedback via fail-fast behavior +**Upgrading from v1.x:** If you relied on opt-in caching, your hooks now cache by default. To restore v1.x behavior, set `hooks.cache: false` in your `han.yml`. -**Upgrading from v1.x:** If you relied on opt-in caching, your hooks will now cache by default. To restore v1.x behavior, set `hooks.cache: false` in your `han.yml`. +`hooks.fail_fast` and `--fail-fast`, which older releases shipped alongside caching, no longer exist anywhere in the code. `hooks.checkpoints` and `--no-checkpoints` do still work: they control session-scoped `${HAN_FILES}` filtering rather than caching. See [session-scoped validation](/docs/features/checkpoints#turning-session-filtering-off). ## How Caching Works @@ -41,20 +37,19 @@ This means hooks only run when something meaningful has changed. ## Cache Granularity -**Caching is session-scoped by default** through checkpoints (enabled by default as of v2.0.0): +Caching is scoped to the current session's file changes: ```yaml hooks: - cache: true # Enabled by default - checkpoints: true # Enabled by default - cache resets at session boundaries + cache: true # Enabled by default ``` -This ensures: +Before running a hook, Han builds the manifest of files matching its `if_changed` patterns and asks two questions in order: -- Fresh validation at the start of each work session -- No stale results from previous days -- Consistent behavior across team members -- Automatic cache invalidation at natural boundaries +1. Did this session touch any file in the manifest? If not, skip the hook. +2. For the files that are there, does any hash differ from the one recorded at its last validation, or was a validated file deleted? If so, run the hook. + +On any error reading cache state, Han assumes changes and runs the hook, so a cache fault never silently skips validation. ## File-Based Cache Invalidation @@ -174,7 +169,7 @@ biome: Cache data is stored in: - `.claude/cache/hooks/` - Project-specific hook results -- Session checkpoints - Temporal boundaries for cache validity +- Per-session file validations - the hashes each hook last validated, used to decide what changed Cache files are safe to delete - Han will rebuild them on the next run. @@ -240,7 +235,7 @@ Common scenarios that invalidate cache: | Edit non-matching file | No | Outside `if_changed` patterns | | Change hook command | Yes | Cache key includes command | | Update plugin | Yes | Version in cache key changed | -| New session (checkpoints on) | Yes | Session boundary reached | +| New session | Yes | Session file validations start empty | | Delete cached file | No | Cache persists until invalidation | ## Best Practices @@ -258,12 +253,7 @@ Common scenarios that invalidate cache: - "**/*" ``` -2. **Keep checkpoints enabled** (default) for session-scoped freshness - disable only if you understand the implications: - - ```yaml - hooks: - checkpoints: true # Default - recommended for most workflows - ``` +2. **Let session scoping do its job** - a hook only fires on files the current session touched, so resist widening `if_changed` to force runs 3. **Monitor cache hits** - if you're seeing frequent misses, patterns may be too broad @@ -272,9 +262,13 @@ Common scenarios that invalidate cache: 5. **Document cache requirements** - if a hook needs cache disabled, explain why in comments: ```yaml - custom-validator: - validate: - cache: false # Disabled because this hook checks external state + plugins: + custom-validator: + hooks: + validate: + # Documented here because this hook checks external state; + # disable caching globally with hooks.cache: false or per-run with --no-cache + command: ./scripts/check-external.sh ``` ## Troubleshooting diff --git a/website/content/docs/configuration/index.md b/website/content/docs/configuration/index.md index 1db2c71ae..1877fb764 100644 --- a/website/content/docs/configuration/index.md +++ b/website/content/docs/configuration/index.md @@ -29,18 +29,12 @@ A typical `han.yml` file has these main sections: ```yaml # Global hook settings hooks: - enabled: true # Master switch for all hooks (default: true) - checkpoints: true # Session/agent checkpoint filtering (default: true) - cache: true # Smart caching - skip if no changes (default: true) - transcript_filter: true # Multi-session conflict prevention (default: true, v2.3.0) + enabled: true # Master switch for all hooks (default: true) + cache: true # Smart caching - skip if no changes (default: true) -# Orchestrator settings (controls MCP tool exposure) -orchestrator: - enabled: true # Use unified workflow interface (default: true) - workflow: - enabled: true # Enable han_workflow tool - max_steps: 20 # Maximum workflow steps - timeout: 300 # Workflow timeout in seconds +# Auto-detection behavior +learn: + mode: auto # auto | ask | none (default: auto) # Memory system memory: @@ -50,75 +44,79 @@ memory: metrics: enabled: true # Enable task metrics (default: true) -# Plugin-specific settings -biome: - lint: - enabled: true - command: npx biome check --write . - -typescript: - typecheck: - enabled: true - command: npx tsc --noEmit +# Per-plugin hook overrides +plugins: + biome: + hooks: + lint: + enabled: true + command: npx biome check --write . + + typescript: + hooks: + typecheck: + enabled: true + command: npx tsc --noEmit ``` -**Note:** As of v2.0.0, all settings default to **enabled** (`true`). This means features are active by default unless explicitly disabled. - ## Global Hook Settings The `hooks` section controls behavior for all hooks across all plugins: ```yaml hooks: - enabled: true # Master switch - disable all hooks - checkpoints: true # Filter hooks by session/agent context - cache: true # Skip hooks when files haven't changed - transcript_filter: true # Filter by session's modified files (v2.3.0) + enabled: true # Master switch - disable all hooks + cache: true # Skip hooks when files haven't changed ``` ### Global Hook Options | Option | Type | Default | Description | |--------|------|---------|-------------| -| `enabled` | boolean | `true` | Master switch to enable/disable all hooks | -| `checkpoints` | boolean | `true` | Enable session-scoped filtering (only run relevant hooks) | +| `enabled` | boolean | `true` | Master switch to enable or disable all hooks | | `cache` | boolean | `true` | Enable smart caching to skip unchanged files | -| `transcript_filter` | boolean | `true` | Filter hooks to files THIS session modified (prevents multi-session conflicts) | +| `checkpoints` | boolean | `true` | Scope `${HAN_FILES}` to the files this session modified. Set `false` to check the whole tree | + +These three are the only keys the hook runner reads. There is no +`hooks.fail_fast` and no `hooks.transcript_filter`; both appeared in earlier +documentation and neither exists in the code. See +[session-scoped validation](/docs/features/checkpoints). ### Configuration Priority Settings are applied in this order (later overrides earlier): -1. **Built-in defaults** - All settings default to `true` in v2.0.0 -2. **`han.yml` configuration** - Values from config hierarchy -3. **CLI options** - Flags like `--no-cache` -4. **Environment variables** - `HAN_HOOKS_CACHE=false`, etc. +1. **Built-in defaults** - hooks and caching enabled +2. **`han.yml` configuration** - merged across the file hierarchy above +3. **CLI options** - flags like `--no-cache` +4. **Environment variables** - `HAN_NO_CACHE=1`, `HAN_DISABLE_HOOKS=1` Example: ```bash -# Disable caching via CLI (overrides han.yml) -han hook run biome lint --cache=false +# Disable caching for one run +han hook run biome lint --no-cache -# Disable via environment variable -HAN_HOOKS_CACHE=false han hook run biome lint +# Same, via environment variable +HAN_NO_CACHE=1 han hook run biome lint ``` ## Per-Hook Configuration -Each plugin can define multiple hooks. You can configure them individually: +Override an installed plugin's hook under `plugins..hooks.`: ```yaml -biome: - lint: - enabled: true # Enable/disable this hook - command: npx biome check # Override the default command - cache: true # Enable smart caching for this hook - dirs_with: # Only run in dirs with these files - - biome.json - if_changed: # Only run if these patterns changed - - "**/*.ts" - - "**/*.tsx" +plugins: + biome: + hooks: + lint: + enabled: true # Enable or disable this hook + command: npx biome check --write . # Override the default command + if_changed: # Add change patterns + - "**/*.ts" + - "**/*.tsx" + idle_timeout: 5 # Seconds to wait for file stability + before_all: ./scripts/codegen.sh # Run once before iterating directories ``` ### Per-Hook Options @@ -127,21 +125,29 @@ biome: |--------|------|-------------| | `enabled` | boolean | Enable or disable this specific hook | | `command` | string | Command to execute | -| `cache` | boolean | Use smart caching (inherits global default: true) | -| `dirs_with` | array | File patterns that must exist | -| `if_changed` | array | Only run if matching files changed | -| `idle_timeout` | number | Milliseconds to wait for file stability | +| `if_changed` | array | Extra change patterns, merged with the plugin's own | +| `idle_timeout` | number or `false` | Seconds to wait for file stability, or `false` to disable | +| `before_all` | string | Script run once before all directory iterations | + +`han.yml` is parsed without schema validation, so an unrecognized key here is silently ignored rather than reported. `cache` and `dirs_with` are not valid per-hook overrides; see below. -## Plugin Configuration Keys +## Plugin Authoring Keys -Han uses YAML configuration with snake_case keys: +These keys belong in a plugin's own `han-plugin.yml`, not in your `han.yml` overrides: | Key | Description | |-----|-------------| +| `event` | Hook event or events this hook responds to | +| `tool_filter` | Restrict a tool event to specific tools | | `dirs_with` | Directory detection patterns | | `dir_test` | Directory test command | +| `command` | Command to execute | | `if_changed` | File change patterns | +| `timeout` | Maximum execution time in seconds | | `idle_timeout` | File stability timeout | +| `depends_on` | Hooks that must run first | +| `description`, `tip` | Human-facing text | +| `mcp` | Expose the hook as an MCP tool | ## Example Configurations @@ -151,50 +157,50 @@ Han uses YAML configuration with snake_case keys: hooks: enabled: true -biome: - lint: - enabled: true +plugins: + biome: + hooks: + lint: + enabled: true ``` ### Full Configuration ```yaml -# Global hook settings (all default to true in v2.0.0+) hooks: - enabled: true # Master switch - checkpoints: true # Session filtering - cache: true # Smart caching - transcript_filter: true # Multi-session conflict prevention (v2.3.0) - -biome: - lint: - enabled: true - command: npx biome check --write . - cache: true - dirs_with: - - biome.json - if_changed: - - "**/*.ts" - - "**/*.tsx" - - "**/*.js" - -typescript: - typecheck: - enabled: true - command: npx tsc --noEmit - cache: true - dirs_with: - - tsconfig.json - if_changed: - - "**/*.ts" - - "**/*.tsx" - -markdown: - lint: - enabled: true - command: npx markdownlint-cli --fix . - if_changed: - - "**/*.md" + enabled: true + cache: true + +learn: + mode: ask + +plugins: + biome: + hooks: + lint: + enabled: true + command: npx biome check --write ${HAN_FILES} + if_changed: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + + typescript: + hooks: + typecheck: + enabled: true + command: npx tsc --noEmit + if_changed: + - "**/*.ts" + - "**/*.tsx" + + markdown: + hooks: + lint: + enabled: true + command: npx markdownlint-cli --fix . + if_changed: + - "**/*.md" ``` ### Directory-Specific Configuration @@ -203,9 +209,11 @@ You can create `han.yml` files in subdirectories to override settings: ```yaml # packages/frontend/han.yml -biome: - lint: - command: npx biome check --write --config ../../biome.frontend.json . +plugins: + biome: + hooks: + lint: + command: npx biome check --write --config ../../biome.frontend.json . ``` ## Disabling Hooks @@ -213,9 +221,11 @@ biome: To temporarily disable a specific hook without removing the plugin: ```yaml -biome: - lint: - enabled: false +plugins: + biome: + hooks: + lint: + enabled: false ``` Or disable all hooks globally: @@ -225,60 +235,42 @@ hooks: enabled: false ``` -You can also disable specific features: +Or disable caching so hooks always run: ```yaml hooks: - cache: false # Always run hooks, never use cache - checkpoints: false # Run all hooks regardless of context - transcript_filter: false # Ignore session transcript, use checkpoint-only filtering + cache: false ``` -Or via CLI for one-off runs: +For a one-off run: ```bash -# Disable caching for this run -han hook run biome lint --cache=false +han hook run biome lint --no-cache ``` -## Orchestrator Configuration - -The orchestrator controls how MCP service tools are exposed to Claude Code. - -### Orchestrator Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | boolean | `true` | Enable unified workflow interface | -| `workflow.enabled` | boolean | `true` | Expose the `han_workflow` tool | -| `workflow.max_steps` | number | `20` | Maximum steps in a workflow | -| `workflow.timeout` | number | `300` | Workflow timeout in seconds | - -### How It Works - -When orchestrator is **enabled** (default): +## Auto-Detection -- MCP servers return no tools (stub mode) -- Han exposes a unified `han_workflow` tool -- Reduces context from 50+ tools to ~5 -- Workflows can invoke any backend capability +The `learn` section controls whether Han installs plugins automatically when it detects marker files: -When orchestrator is **disabled**: +```yaml +learn: + mode: auto # Install detected plugins automatically (default) + # mode: ask # Report what would be installed, install nothing + # mode: none # Disable auto-detection entirely +``` -- MCP servers proxy to actual backends -- All individual MCP tools are exposed -- Full access to service-specific tools -- Higher context usage +## MCP Backend Pool -### Switching Modes +Plugins that expose an MCP server are proxied through a connection pool. Tune it with: ```yaml -# han.yml - disable orchestrator for direct MCP access orchestrator: - enabled: false + backends: + idle_timeout: 300 # Seconds before an idle backend is closed + max_connections: 10 ``` -This is useful when you need direct access to specific MCP tools rather than the unified workflow interface. +`orchestrator.backends` is the only `orchestrator` subsection Han reads. ## Best Practices @@ -286,16 +278,20 @@ This is useful when you need direct access to specific MCP tools rather than the 2. **Use local settings** (`.claude/han.local.yml`) for personal preferences 3. **Keep it simple** - start with minimal configuration and add as needed 4. **Document overrides** - add comments explaining why you've changed defaults -5. **Test changes** - run hooks manually after configuration changes +5. **Test changes** - run `han hook run ` after configuration changes ## Configuration Validation -Han validates configuration on startup. If you have syntax errors or invalid options, you'll see: +`han-plugin.yml` files are validated: unknown top-level keys and malformed hook definitions are reported as errors. Validate a plugin you are authoring with: + +```bash +han plugin validate +``` + +`han.yml` is not schema-validated. A typo in a key name is ignored silently, so check your change actually took effect: ```bash -han plugin install --auto -# Error: Invalid configuration in .claude/han.yml -# - biome.lint.enabled: must be boolean +han hook explain ``` ## Next Steps diff --git a/website/content/docs/features/checkpoints.md b/website/content/docs/features/checkpoints.md index 2764681f5..d8adb2cbf 100644 --- a/website/content/docs/features/checkpoints.md +++ b/website/content/docs/features/checkpoints.md @@ -1,13 +1,15 @@ --- -title: "Checkpoint System" -description: "How Han's checkpoint system brings focused, incremental validation by only checking code you actually modified." +title: "Session-Scoped Validation" +description: "How Han keeps validation focused on the code you actually modified, instead of drowning you in a legacy project's pre-existing issues." --- -Han's checkpoint system applies the Boy Scout Rule to code validation: leave the code better than you found it, but don't demand perfection on day one. +Han applies the Boy Scout Rule to code validation: leave the code better than you found it, but don't demand perfection on day one. + +> **Naming note:** Han used to implement this with an explicit checkpoint system, and some configuration keys and CLI flags still carry the `checkpoint` name. That subsystem has been removed and this page documents what actually runs today. The `checkpoints` config key and the `--no-checkpoints` flag now control session-scoped filtering; see [turning session filtering off](#turning-session-filtering-off). Two flags survive that genuinely do nothing, listed under [inert leftovers](#inert-leftovers). ## The Problem -Without checkpoints, validation hooks run against your entire codebase. Install a linting plugin on a legacy project with 500 pre-existing issues, and you're immediately overwhelmed with problems you didn't create. +Run validation hooks against your entire codebase and a legacy project with 500 pre-existing issues buries you in problems you didn't create. This leads to: @@ -15,295 +17,104 @@ This leads to: - Validation abandoned entirely - Net result: worse quality, not better -## The Solution: Session-Scoped Validation +## The Solution -Checkpoints capture file states when your session starts. When hooks run, they only validate files you actually modified during that session. +Two independent mechanisms keep a hook's attention on your work. ```text -Session Start (t0): -├─ Checkpoint created -├─ File hashes captured for all project files - -Your Work (t0 → t1): +Your Work: ├─ Modified: components/Button.tsx ├─ Untouched: utils/format.ts (has lint errors) -Session Stop (t1): -├─ Hook runs -├─ Filters to: components/Button.tsx only +Stop hook runs: +├─ Filters to: components/Button.tsx └─ Pre-existing issues in utils/format.ts: not your problem today ``` -## How Checkpoints Work - -### Session Checkpoints - -Created automatically at `SessionStart`: - -- Captures SHA-256 hashes of all tracked files -- Stored in `~/.claude/projects/{slug}/han/checkpoints/session_{id}.json` -- Used by `Stop` hooks to filter validation scope -- Cleaned up after 24 hours - -### Agent Checkpoints - -For complex workflows with subagents: - -- Created at `SubagentStart` for each spawned agent -- Stored as `agent_{agent_id}.json` -- Each agent's work is validated independently -- Prevents cross-contamination between parallel work - -```text -Main Session (session_abc): -├─ Checkpoint: session_abc.json -├─ Spawns Subagent 1 (agent_xyz): -│ ├─ Checkpoint: agent_xyz.json -│ └─ Works on feature-a/ -└─ Spawns Subagent 2 (agent_def): - ├─ Checkpoint: agent_def.json - └─ Works on feature-b/ -``` - -When Subagent 1 finishes, `SubagentStop` hooks only validate its changes. Subagent 2's work is isolated. - -## Transcript Filtering (v2.3.0) - -Checkpoints solve isolation for subagents within a session. But what about **multiple sessions** in the same working tree? - -### The Multi-Session Problem +### 1. Caching -```text -Session A: Modifies src/auth.ts, introduces lint error -Session B: Modifies src/utils.ts, runs Stop hook - -Without transcript filtering: -└─ Session B's hook sees auth.ts changed (vs B's checkpoint) -└─ Session B tries to fix auth.ts -└─ Session A also tries to fix auth.ts -└─ Edit conflict! -``` - -### The Solution: Transcript-Based Scoping +Every hook declares `if_changed` glob patterns. Before running, Han builds a manifest of the files matching those patterns and decides whether there is anything to do: -Each session maintains a transcript of file operations. Stop hooks use this to filter: +- If the current session has not touched any file in the manifest, the hook is skipped +- Otherwise each file's hash is compared against the hash recorded at its last validation, and the hook runs when any hash differs or a previously validated file was deleted +- On any error reading cache state, Han assumes changes and runs the hook, so a cache fault never silently skips validation -```text -Session A: src/auth.ts in transcript → validate auth.ts only -Session B: src/utils.ts in transcript → validate utils.ts only -``` +Caching is on by default. Turn it off with `--no-cache` on `han hook run`, `HAN_NO_CACHE=1`, or `hooks.cache: false` in `han.yml`. -No conflicts. Each session handles only its own changes. +### 2. Session file filtering -### How It Works - -1. Claude Code records all Write/Edit operations in session transcripts -2. At `Stop`, Han extracts modified files from the transcript -3. Files are intersected with the hook's `if_changed` patterns -4. Hook runs only on files THIS session actually modified - -### File-Targeted Commands - -For commands that support file arguments, use `${HAN_FILES}` to pass only session-modified files: - -```yaml -plugins: - biome: - hooks: - lint: - command: npx biome check --write ${HAN_FILES} -``` - -This ensures Session B doesn't see (or fail on) Session A's lint errors. - -When `cache=false` or transcript filtering is disabled, `${HAN_FILES}` is replaced with `.` to run on all files. Use `han hook run --cache=false` to force full validation. - -### Configuration - -Transcript filtering is enabled by default when checkpoints are enabled: +A hook command can opt in to file-level targeting by including the `${HAN_FILES}` template: ```yaml +# han-plugin.yml hooks: - checkpoints: true # Enables checkpoints (default: true) - transcript_filter: true # Enables transcript filtering (default: true) + lint: + command: npx biome check --write ${HAN_FILES} + if_changed: + - "**/*.ts" + - "**/*.tsx" ``` -The `transcript_filter` option requires `checkpoints: true`. Disabling checkpoints automatically disables transcript filtering. +At `Stop`, Han resolves that template against the files the current session modified: -### Fallback Behavior +- `${HAN_FILES}` becomes the session-modified files under the hook's directory that also match `if_changed` +- With no session ID, no session-modified files, or a failed lookup, it becomes `.` so the hook falls back to the whole directory +- If the session did modify files but none match this directory and `if_changed`, the hook is skipped for that directory rather than run against `.` +- A command without `${HAN_FILES}` runs unchanged -If a transcript can't be found or parsed, the hook runs normally (full checkpoint-based filtering). This ensures hooks never silently skip validation. +This is automatic and has no off switch. A command opts in by using the template and opts out by not using it. -## Configuration +## Multi-Session Safety -Checkpoints are enabled by default. All settings default to `true` as of v2.0.0. - -### Disable Checkpoints - -For intentional full-codebase validation: - -```yaml -# han.yml -hooks: - checkpoints: false # Validate all changed files, not just your session's -``` - -Or via environment variable: - -```bash -HAN_NO_CHECKPOINTS=1 han hook run biome lint -``` - -Or via CLI flag: - -```bash -han hook run biome lint --no-checkpoints -``` - -### When to Disable - -- **Tech debt sprints**: When deliberately addressing accumulated issues -- **CI pipelines**: Merge gates should validate the full codebase -- **Initial cleanup**: First-time adoption with planned fix-all session - -Most day-to-day development should keep checkpoints enabled. - -## CLI Commands - -Manage checkpoints directly with the CLI: - -### Capture a Checkpoint - -```bash -# Automatic (reads stdin from hook payload) -han checkpoint capture - -# Manual with explicit options -han checkpoint capture --type session --id my-session-123 -han checkpoint capture --type agent --id agent-xyz -``` - -### List Active Checkpoints - -```bash -han checkpoint list -``` - -Output: +Session file filtering is what keeps two Claude Code sessions in the same repo from fighting: ```text -Active Checkpoints -================== - -Session Checkpoints: - - abc123 (captured 2 hours ago) - - def456 (captured 1 day ago) - -Agent Checkpoints: - - agent-xyz (captured 5 minutes ago) - -Total: 3 checkpoints +Session A: modifies src/auth.ts → its Stop hook validates src/auth.ts +Session B: modifies src/utils.ts → its Stop hook validates src/utils.ts ``` -### Clean Stale Checkpoints - -```bash -# Remove checkpoints older than 24 hours (default) -han checkpoint clean +Without it, Session B's hook would see Session A's unfinished edit to `auth.ts`, try to fix it, and collide with Session A doing the same thing. -# Custom age threshold -han checkpoint clean --max-age 48 -``` +## Subagent Work -## Integration with Hooks - -Hooks automatically use checkpoints when available: - -```json -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { "type": "command", "command": "han checkpoint capture" } - ] - } - ], - "Stop": [ - { - "hooks": [ - { "type": "command", "command": "han hook run biome lint" } - ] - } - ] - } -} -``` +Subagents run the same hooks as the main conversation. A `SubagentStop` hook validates through the same caching and `${HAN_FILES}` path as a `Stop` hook, so a subagent's validation naturally narrows to the files that subagent touched. -The `Stop` hook automatically filters to files changed since the session checkpoint. +## Turning Session Filtering Off -## The Boy Scout Philosophy +Session-scoped filtering narrows a hook to the files the session touched. Turn +it off when you want a hook to check the whole tree every time: -The checkpoint system embodies incremental improvement: +| Name | Where it appears | Effect | +|------|------------------|--------| +| `hooks.checkpoints: false` | `han.yml` | `${HAN_FILES}` expands to `.`, so hooks check everything | +| `--no-checkpoints` | `han hook dispatch` | Same, for the hooks that dispatch invokes | +| `HAN_NO_CHECKPOINTS=1` | Environment | Same, read by the hook runner | -1. Touch a file → that file gets validated -2. Fix issues in code you're already changing -3. Over time, frequently-touched code gets cleaner -4. Rarely-touched code stays as-is until relevant +## Inert Leftovers -This is sustainable. Demanding perfection on day one isn't. +`--checkpoint-type` and `--checkpoint-id` on `han hook run` are still parsed and +validated, then ignored. They are remnants of the removed checkpoint system. -## What Checkpoints Don't Do +There is no `han checkpoint` command. Earlier documentation described +`han checkpoint capture`, `list`, and `clean`; those subcommands were removed +along with the feature. -**Hide problems**: Pre-existing issues still exist. They surface when you touch those files. +## Adopting Han on a Legacy Codebase -**Replace CI**: Your CI should validate the full codebase. Checkpoints optimize the developer feedback loop. +Because a hook only fires on files you touch, you can install validation plugins on a messy codebase without a cleanup sprint first: -**Persist across sessions**: Each session starts fresh. Old checkpoints are cleaned up automatically. +1. Install the plugins for your stack +2. Work normally. Hooks validate only what you edit +3. Pre-existing issues surface gradually, as you touch the files that contain them -## Technical Details - -### Checkpoint Storage - -```text -~/.claude/projects/{project-slug}/han/checkpoints/ -├── session_abc123.json -├── session_def456.json -└── agent_xyz789.json -``` - -### Checkpoint Format - -```json -{ - "type": "session", - "id": "session_abc123", - "timestamp": "2025-12-13T08:00:00Z", - "files": { - "src/index.ts": "sha256:abc...", - "src/utils.ts": "sha256:def..." - } -} -``` - -### Graceful Degradation - -- Missing checkpoint? Normal hook behavior (validates all changed files) -- Corrupted checkpoint? Falls back gracefully -- Never silently skips validation - -### Debugging - -Checkpoint capture fails silently by default to avoid blocking hooks. To see error messages: +To deliberately attack accumulated debt, force a full run: ```bash -HAN_VERBOSE=1 han checkpoint capture --type session --id test-123 +han hook run biome lint --no-cache ``` -The `HAN_VERBOSE=1` environment variable enables detailed error output for troubleshooting. - -## Next Steps +## Learn More -- Learn about [smart caching](/docs/configuration/caching) for faster hook execution -- Explore [hook configuration](/docs/configuration) for fine-tuning -- Read about [SubagentStart/Stop hooks](/docs/cli/hooks) for agent lifecycle +- [Hook System](/docs/features/hooks) - How hooks fire and what they do +- [Hook Commands](/docs/cli/hooks) - Running hooks manually +- [Configuration](/docs/configuration) - Tuning hook behaviour diff --git a/website/content/docs/features/hooks.md b/website/content/docs/features/hooks.md index 33b07b6fc..b91da1285 100644 --- a/website/content/docs/features/hooks.md +++ b/website/content/docs/features/hooks.md @@ -13,12 +13,12 @@ Hooks make validation **automatic**. Write code, finish your conversation, and h ## The Hook Lifecycle -Han hooks into Claude Code's execution at specific points: +Claude Code fires 31 hook events over a session's life, and Han's `han-plugin.yml` accepts every one of them. The table below is the curated shortlist you will actually reach for when writing a validation plugin. For the complete 31-event reference, current as of Claude Code 2.1.228, see [plugin hook events](/docs/plugin-development/hooks#hook-lifecycle). | Hook | When It Fires | Purpose | |------|---------------|---------| -| `SessionStart` | Session begins | Initialize state, capture checkpoints | -| `SubagentStart` | Subagent spawns | Capture agent checkpoint | +| `SessionStart` | Session begins | Initialize state | +| `SubagentStart` | Subagent spawns | Inject context into subagents | | `UserPromptSubmit` | Before processing input | Pre-process, inject context | | `PreToolUse` | Before tool execution | Validate tool calls | | `PermissionRequest` | Permission dialog appears | Audit/auto-approve permissions | @@ -35,7 +35,7 @@ Han hooks into Claude Code's execution at specific points: | `WorktreeCreate` | Git worktree created | Agent isolation tracking | | `WorktreeRemove` | Git worktree removed | Cleanup tracking | -Most validation happens at `Stop` and `SubagentStop` - the natural checkpoints after work is done. +Most validation happens at `Stop` and `SubagentStop` - the natural points after work is done. The events above are a curated subset, not the full set. ### New Hook Events (Claude Code 2.1.33+) @@ -53,7 +53,7 @@ Several hook events have been added for tool failure tracking, permission auditi - **`TaskCompleted`** (2.1.33+): Fires when a task is marked as completed via `TaskUpdate`. Useful for task tracking dashboards, triggering follow-up workflows, and team notifications. -- **`WorktreeCreate`** (2.1.50+): Fires when a worktree is being created. Receives `name` slug in the payload. When configured, replaces default git worktree behavior — the hook must print the created worktree path to stdout. Enables custom VCS support and tracking of parallel workstreams. +- **`WorktreeCreate`** (2.1.50+): Fires when a worktree is being created. Receives `name` slug in the payload. When configured, replaces default git worktree behavior: the hook must print the created worktree path to stdout. Enables custom VCS support and tracking of parallel workstreams. - **`WorktreeRemove`** (2.1.50+): Fires when a worktree is being removed. Receives `worktree_path` in the payload. Cannot block removal. Useful for cleanup automation and resource tracking. @@ -85,14 +85,12 @@ SessionStart └─ Stop (validates session changes) ``` -### Checkpoint Integration +### How Validation Is Scoped -- **SessionStart**: Creates session checkpoint -- **SubagentStart**: Creates agent checkpoint -- **Stop**: Validates against session checkpoint -- **SubagentStop**: Validates against agent checkpoint +- **Caching**: a hook is skipped when nothing the current session touched matches its `if_changed` patterns, and re-runs once a matching file's hash differs from the one recorded at its last validation +- **Session filtering**: a hook whose command contains `${HAN_FILES}` is handed only the files the current session modified -This ensures hooks only check files that changed since the relevant checkpoint. +Between them, hooks avoid re-checking untouched code and avoid tripping over another session's edits. Han previously did this with explicit session and agent checkpoints; that mechanism has been removed. ## What Hooks Do @@ -206,13 +204,15 @@ Skip hooks when files haven't changed: - Only re-validates modified files - Dramatically speeds up repeated runs -### Checkpoint Filtering +### Session Filtering Only validate your work: -- Session hooks filter to session changes -- Agent hooks filter to agent changes -- Pre-existing issues are out of scope +- A hook whose command contains `${HAN_FILES}` runs only against the files the current session modified +- Pre-existing issues elsewhere in the repo stay out of scope +- This is automatic. A command opts in by using `${HAN_FILES}` and opts out by not using it + +To turn session filtering off, set `hooks.checkpoints: false` in `han.yml`, pass `--no-checkpoints` to `han hook dispatch`, or export `HAN_NO_CHECKPOINTS=1`. Any of the three makes `${HAN_FILES}` expand to `.`, so hooks check the whole tree. ### Transcript Filtering @@ -249,16 +249,16 @@ plugins: - "**/*.tsx" ``` -When transcript filtering is active: +When a command uses `${HAN_FILES}`: -- `${HAN_FILES}` is replaced with the session's modified files that match `if_changed` patterns -- If no files match, `${HAN_FILES}` is replaced with `.` (fallback to full directory) +- It is replaced with the session-modified files under the hook's directory that also match `if_changed` +- If there is no session ID, the session modified nothing, or the lookup throws, it is replaced with `.` (fall back to the whole directory) +- If the session did modify files but none of them match this directory and `if_changed`, the hook is skipped for that directory rather than run against `.` - Commands without `${HAN_FILES}` run unchanged (backward compatible) -- When `cache=false` or transcript filter is disabled, `${HAN_FILES}` is replaced with `.` to run on all files This prevents the scenario where Session A's lint error causes Session B's hook to also fail. -All smart behaviors are enabled by default. Disable with `--no-cache`, `--no-checkpoints`, or configure `transcript_filter: false`. +Caching is the one smart behaviour with a working off switch: pass `--no-cache` to `han hook run`, set `HAN_NO_CACHE=1`, or set `hooks.cache: false` in `han.yml`. ## Configuration @@ -270,9 +270,10 @@ In `han.yml`: hooks: enabled: true # Master switch cache: true # Smart caching (default: true) - checkpoints: true # Session-scoped filtering (default: true) ``` +`hooks.enabled`, `hooks.cache`, and `hooks.checkpoints` are the keys the hook runner reads. + ### Per-Plugin Settings ```yaml @@ -282,26 +283,27 @@ plugins: lint: enabled: true command: npx biome check --write . - cache: true if_changed: - "**/*.ts" - "**/*.tsx" ``` +A per-plugin hook override is read as `enabled`, `command`, `if_changed`, `idle_timeout`, and `before_all`. `han.yml` is parsed without schema validation, so any other key is silently ignored rather than reported. + ### Conditional Execution -Only run in directories with specific files: +`dirs_with` and `dir_test` are plugin-author keys: they belong in the plugin's own `han-plugin.yml`, not in your `han.yml` overrides. Only run in directories with specific files: ```yaml -plugins: - typescript: - hooks: - typecheck: - dirs_with: - - tsconfig.json +# han-plugin.yml +hooks: + typecheck: + command: npx tsc --noEmit + dirs_with: + - tsconfig.json ``` -Only run when specific patterns changed: +From `han.yml`, narrow an existing hook by adding change patterns: ```yaml plugins: @@ -317,10 +319,10 @@ plugins: Settings cascade with later overriding earlier: -1. **Built-in defaults**: All features enabled -2. **`han.yml`**: Your configuration -3. **CLI flags**: `--no-cache`, `--no-fail-fast` -4. **Environment variables**: `HAN_NO_CACHE=1` +1. **Built-in defaults**: hooks and caching enabled +2. **`han.yml`**: your configuration, merged user then project then local then root +3. **CLI flags**: `--no-cache` on `han hook run` +4. **Environment variables**: `HAN_NO_CACHE=1`, `HAN_DISABLE_HOOKS=1` ## Running Hooks Manually @@ -385,7 +387,14 @@ han hook run biome lint --verbose View hook configuration: ```bash -han hook info biome lint +# Every configured hook, Han plugins plus Claude Code settings +han hook explain + +# Narrow to a single event +han hook explain Stop + +# Just the hooks discovered from installed plugins +han hook list ``` ### Force Re-run @@ -417,6 +426,6 @@ Let hooks run automatically. Don't disable them because they found issues - fix ## Next Steps -- Learn about [checkpoints](/docs/features/checkpoints) for session-scoped validation +- Read the full [hook event reference](/docs/plugin-development/hooks#hook-lifecycle) for all 31 events - Explore [configuration](/docs/configuration) for fine-tuning - See [CLI commands](/docs/cli/hooks) for manual execution diff --git a/website/content/docs/features/memory.md b/website/content/docs/features/memory.md index d67b71e3e..b5e93c3d8 100644 --- a/website/content/docs/features/memory.md +++ b/website/content/docs/features/memory.md @@ -310,24 +310,19 @@ All layers (except rules) are indexed for fast search using SQLite FTS5. ### CLI Commands ```bash -# Index all content for current project -han index run +# Clear the database and rebuild it from the JSONL session logs +han reindex +han reindex --verbose -# Index specific layer -han index run --layer transcripts -han index run --layer team - -# Index specific session -han index run --session "" - -# Search indexed content -han index search "authentication" -han index search "error handling" --layer team --limit 20 +# Search the index (for testing) +han reindex search "authentication" # Check index status -han index status +han reindex status ``` +The command is `han reindex`, not `han index`. It always rebuilds the whole index; there are no `--layer` or `--session` flags. + ### Automatic Indexing Indexing happens on-demand when you use `han browse` or run validation hooks. Manual indexing is optional but useful for: @@ -357,14 +352,16 @@ You don't think about layers - you just ask questions. ## Storage Layout ```text -~/.claude/ - han/ - han.db # SQLite database (Layer 2: indexed sessions) - memory/ - projects/ - github.com_org_repo/ - meta.yaml # Team memory metadata +~/.han/ # Han data directory (override with HAN_DATA_DIR) + han.db # SQLite database (Layer 2: indexed sessions) + memory/ + personal/ + sessions/ # Session observations and han events (JSONL) + summaries/ # Session summaries + projects/ + github.com_org_repo/ # Team memory metadata +~/.claude/ projects/ {project-slug}/ # Layer 3: Claude transcripts (JSONL) @@ -372,6 +369,8 @@ You don't think about layers - you just ask questions. rules/ # Layer 1: Permanent rules ``` +Han migrated this directory from `~/.claude/han` to `~/.han`; the move happens automatically on first run when only the old path exists. + --- ## Configuration @@ -400,6 +399,6 @@ memory: ## Next Steps -- Learn about [checkpoints](/docs/features/checkpoints) for session-scoped validation +- Learn about [session-scoped validation](/docs/features/checkpoints) for how hooks narrow their scope - Explore the [MCP integrations](/docs/integrations) Han provides - Read about [configuration](/docs/configuration) options diff --git a/website/content/docs/getting-started.md b/website/content/docs/getting-started.md index a9ce84ac9..778921d6d 100644 --- a/website/content/docs/getting-started.md +++ b/website/content/docs/getting-started.md @@ -3,7 +3,7 @@ title: "What is Han?" description: "Han is a plugin system for Claude Code that adds automatic quality gates through validation hooks, metrics tracking, and MCP integrations." --- -Han brings automatic quality gates to Claude Code. Every conversation ends with validation—linting, formatting, type-checking, and tests run automatically, catching issues before they ship. +Han brings automatic quality gates to Claude Code. Every conversation ends with validation: linting, formatting, type-checking, and tests run automatically, catching issues before they ship. ## How It Works @@ -29,7 +29,7 @@ You continue working with Claude Code exactly as before. Han operates transparen Stop hooks run automatically when conversations end. Linters, formatters, type checkers, and tests are all verified. -If validation fails, Claude sees the errors and can fix them immediately—no manual intervention required. +If validation fails, Claude sees the errors and can fix them immediately, with no manual intervention required. ### 4. Learn @@ -68,7 +68,7 @@ Validation that would take seconds runs in milliseconds when files haven't chang All data stays on your machine: -- Metrics stored locally in `~/.claude/metrics.db` +- Metrics stored locally in `~/.han/han.db` - No telemetry or phone-home - No cloud dependencies - Works completely offline diff --git a/website/content/docs/installation/plugins.md b/website/content/docs/installation/plugins.md index 98b1fd32a..4d25a9fcc 100644 --- a/website/content/docs/installation/plugins.md +++ b/website/content/docs/installation/plugins.md @@ -105,7 +105,7 @@ han plugin install biome --force To uninstall a plugin: ```bash -han plugin remove biome +han plugin uninstall biome --scope project ``` This removes the plugin from your settings and cleans up any associated configuration. diff --git a/website/content/docs/installation/scopes.md b/website/content/docs/installation/scopes.md index dafeb54bd..5109a0c45 100644 --- a/website/content/docs/installation/scopes.md +++ b/website/content/docs/installation/scopes.md @@ -1,43 +1,19 @@ --- title: "Installation Scopes" -description: "Choose where plugins are installed: user, local, or project scope." +description: "Choose where plugins are installed: project or local scope, and why Han refuses user scope." --- -Han supports three installation scopes that control where plugins are configured and who can access them. Understanding scopes helps you organize plugins effectively across your projects. +Han installs plugins at one of two scopes, which controls where the plugin is configured and who on your team gets it. -## User Scope (Default) +## Project Scope (Default) -Plugins installed at user scope apply across all your projects: +Plugins installed at project scope are shared with your team: ```bash -# User scope is the default -han plugin install playwright-mcp +# Project scope is the default +han plugin install typescript # Or be explicit -han plugin install playwright-mcp --scope user -``` - -**Configuration location:** `~/.claude/settings.json` - -**Best for:** - -- MCP server integrations (github, gitlab, playwright-mcp) -- General-purpose tools used across multiple projects -- Personal productivity plugins (Discipline plugins) -- Plugins you want available everywhere - -**Advantages:** - -- Install once, use everywhere -- No need to reinstall for each project -- Reduces duplication in project settings -- Ideal for personal tooling - -## Project Scope - -Plugins installed at project scope are shared with your team: - -```bash han plugin install typescript --scope project ``` @@ -48,14 +24,14 @@ han plugin install typescript --scope project - Project-specific validation hooks (biome, typescript, markdown) - Language-specific plugins needed by all contributors - Enforcing team standards and quality checks -- Ensuring consistent development environment +- MCP server integrations the whole team should share **Advantages:** - Team members get the same plugins automatically - Version-controlled plugin configuration - Enforces project standards across the team -- Great for CI/CD integration +- Works in CI, where there is no interactive user profile ## Local Scope @@ -78,40 +54,60 @@ han plugin install playwright --scope local - Private to your environment - Won't affect other team members -- Perfect for trying out new plugins -- Override project settings without conflicts +- Good for trying out a new plugin +- Overrides project settings without conflicts -## Scope Recommendations +## Why Not User Scope + +`han plugin install --scope user` is rejected: + +```text +Error: --scope "user" is not supported. Han plugins must be installed at project or local scope. +Use --scope "project" (default) or --scope "local" instead. +``` + +Han plugins carry validation hooks that run against a specific repository. Installing them globally in `~/.claude/settings.json` would fire a project's linters inside every unrelated project you open, and would make the set of active quality gates invisible to your team. + +User scope is not gone everywhere, though. `han plugin list --scope user` and `han plugin uninstall --scope user` both work, because Han still has to see and remove plugins written to `~/.claude/settings.json` by an older version or by hand. In fact `uninstall` defaults to `user`, so pass `--scope project` explicitly when removing a plugin you installed normally. + +## Scope Selection -Here's a quick guide for choosing scopes: +When you omit `--scope`, Han does not just assume `project`: + +1. If Han is already configured in `.claude/settings.local.json` or `.claude/settings.json`, it reuses that existing scope and tells you which one +2. Otherwise it installs to `project` and prints `Installing to project scope (.claude/settings.json)` + +This keeps a repo from ending up with half its plugins in one file and half in the other. + +## Scope Recommendations | Plugin Type | Recommended Scope | Example | |-------------|-------------------|---------| -| MCP Servers (Integration) | User | github, playwright-mcp | | Validation plugins | Project | biome, typescript | -| Team Standards | Project | markdown, shellcheck | -| Discipline agents | User or Local | frontend, backend | +| Team standards | Project | markdown, shellcheck | +| MCP servers the team shares | Project | github, playwright-mcp | +| Discipline agents | Project or Local | frontend, backend | | Experiments | Local | Any plugin you're testing | ## How Scopes Interact -When plugins are installed at multiple scopes, Han merges the configurations with this precedence (later overrides earlier): +Claude Code merges settings files with this precedence, later overriding earlier: 1. User scope (`~/.claude/settings.json`) 2. Project scope (`.claude/settings.json`) 3. Local scope (`.claude/settings.local.json`) -This means local settings always take precedence, allowing you to override team or personal defaults. +Local settings win, so you can override a team default for yourself without touching the shared file. ## Changing Scopes -To move a plugin between scopes, reinstall with the desired scope: +To move a plugin between scopes, uninstall it from the old scope and install it to the new one: ```bash -# Remove from current scope -han plugin remove biome +# Remove from its current scope +han plugin uninstall biome --scope local -# Install to new scope +# Install to the new scope han plugin install biome --scope project ``` @@ -121,16 +117,17 @@ View which scope each plugin is installed in: ```bash han plugin list -``` -The output shows the installation scope for each plugin, helping you understand your current configuration. +# Or narrow to one scope +han plugin list --scope project +``` ## Best Practices -1. **Start with user scope** for general tools, then move to project scope when your team needs them -2. **Use project scope** for validation hooks that enforce code quality standards -3. **Reserve local scope** for personal preferences and experiments -4. **Document project plugins** in your README so team members know what's expected +1. **Default to project scope** so quality gates are visible and shared +2. **Reserve local scope** for personal preferences and experiments +3. **Document project plugins** in your README so team members know what's expected +4. **Pass `--scope` explicitly when uninstalling**, since `uninstall` defaults to `user` 5. **Review scopes periodically** to ensure plugins are in the right place ## Next Steps diff --git a/website/content/docs/integrations.md b/website/content/docs/integrations.md index f10a0faa8..e9f726539 100644 --- a/website/content/docs/integrations.md +++ b/website/content/docs/integrations.md @@ -7,7 +7,7 @@ Han uses the Model Context Protocol (MCP) to connect Claude Code to external ser ## Han's Built-in MCP Server -Han provides its own MCP server that runs via `han mcp server` and exposes powerful built-in capabilities. When you install the core plugin, Han registers as an MCP server in Claude Code's configuration. +Han provides its own MCP server, started with `han mcp`, which exposes built-in capabilities. When you install the core plugin, Han registers as an MCP server in Claude Code's configuration. ### How It Works @@ -18,168 +18,99 @@ The Han MCP server is automatically configured: "mcpServers": { "han": { "command": "han", - "args": ["mcp", "server"] + "args": ["mcp"] } } } ``` -The server implements MCP protocol version 2024-11-05 and exposes tools in four categories: +The server implements MCP protocol version 2024-11-05. -### 1. Plugin Hook Tools (Dynamic) +### What It Exposes -Every hook defined in an installed plugin automatically becomes an MCP tool. When you install `typescript`, Claude immediately gains access to a `typescript_typecheck` tool. +The main `han mcp` server exposes a deliberately small surface: -Example from `bun/han-plugin.yml`: +| Tool | Purpose | +|------|---------| +| `memory` | Query memory with auto-routing across personal sessions, team knowledge, and project conventions | -```yaml -hooks: - test: - command: bun test --only-failures - dirsWith: [bun.lock, bun.lockb] - description: Run Bun tests -``` - -This becomes an MCP tool: - -```javascript -{ - name: "bun_test", - description: "Run Bun tests. Triggers: 'run the tests'...", - inputSchema: { - type: "object", - properties: { - cache: { type: "boolean" }, - directory: { type: "string" }, - verbose: { type: "boolean" } - } - } -} -``` - -All plugin hooks support three standard parameters: - -| Parameter | Default | Purpose | -|-----------|---------|---------| -| `cache` | `true` | Skip if files unchanged since last run | -| `directory` | all | Target specific directory | -| `verbose` | `false` | Stream output in real-time | - -### 2. Memory Tools (Self-Learning) - -Han's memory system lets Claude write to `.claude/rules/` to capture project knowledge. - -**`learn`** - Capture project knowledge: +That is the server's only tool of its own. It takes a `question` string and an optional `session_id`, and routes the question to the right memory layer itself, so there is no separate tool per layer: ```javascript -learn({ - content: "# API Rules\n\n- Validate all inputs with zod", - domain: "api", - paths: ["src/api/**/*.ts"], // Optional: path-specific rules - scope: "project", // or "user" for personal preferences - append: true // Add to existing file -}) +memory({ question: "what was I working on?" }) +memory({ question: "who knows about authentication?" }) +memory({ question: "how do we handle errors?" }) ``` -**`memory_list`** - See existing domains: +`memory` disappears from the tool list when `memory.enabled` is `false` in `han.yml`. -```javascript -memory_list({ scope: "project" }) -// Returns: ["api", "testing", "commands"] -``` +### Re-exposed Backend Tools -**`memory_read`** - Read domain content: +Beyond its own tool, `han mcp` re-exposes the tools of any installed plugin whose MCP server sets `expose: true`. Those arrive prefixed with the server name, so `context7`'s `resolve-library-id` becomes `context7_resolve-library-id`, and their descriptions are tagged with the originating server. -```javascript -memory_read({ domain: "api", scope: "project" }) -``` +This is how a single `han` entry in `mcpServers` can front several backends without you registering each one. -Memory scopes: +## Specialized MCP Servers -| Scope | Location | Purpose | -|-------|----------|---------| -| `project` | `.claude/rules/` | Team knowledge, git-tracked | -| `user` | `~/.claude/rules/` | Personal preferences | - -### 3. Metrics Tools (Self-Awareness) - -Task tracking with confidence calibration. See [Local Metrics](/docs/metrics) for details. - -**`start_task`** - Begin tracking: - -```javascript -start_task({ - description: "Fix authentication timeout bug", - type: "fix", - estimated_complexity: "moderate" -}) -``` +Two further servers ship alongside the main one and are started explicitly. -**`complete_task`** - Record outcome: +### `han mcp blueprints` -```javascript -complete_task({ - task_id: "task_abc123", - outcome: "success", - confidence: 0.85, - files_modified: ["src/auth/session.ts"] -}) -``` +Technical blueprint documentation management. -**`query_metrics`** - Analyze performance: +| Tool | Purpose | +|------|---------| +| `list_blueprints` | List every blueprint in the repository with summaries | +| `search_blueprints` | Filter blueprints by keyword, to avoid duplicating an existing one | +| `read_blueprint` | Read one blueprint's full markdown content | +| `write_blueprint` | Create or update a blueprint, managing frontmatter automatically | -```javascript -query_metrics({ - period: "week", - task_type: "fix" -}) -``` +### `han mcp memory` -### 4. Checkpoint Tools (Smart Caching) +The read-only Memory Data Access Layer, for agents that need to drive search directly rather than through the auto-routing `memory` tool. -Track file changes since last hook run for intelligent caching. +| Tool | Purpose | +|------|---------| +| `memory_search_multi_strategy` | Recommended. Runs several strategies in parallel and fuses them with Reciprocal Rank Fusion | +| `memory_search_with_fallbacks` | Multi-strategy search that escalates through fallbacks when nothing matches | +| `memory_search_hybrid` | FTS plus vector similarity, fused | +| `memory_search_fts` | Full-text BM25 search, best for exact phrases | +| `memory_search_vector` | Semantic similarity search | +| `memory_grep_transcripts` | Raw grep over transcript JSONL, a last resort | +| `memory_scan_recent_sessions` | Scan most recently modified sessions, for temporal queries | +| `memory_list_layers` | List which memory layers have data | -**`checkpoint_list`** - See existing checkpoints -**`checkpoint_clean`** - Remove stale checkpoints +The searchable layers are `rules` (project conventions from `.claude/rules/`), `transcripts` (past sessions), `summaries` (session summaries with topics), and `team` (git commits and PRs, available when a git remote is configured). ## External MCP Servers (Integration Plugins) Integration plugins connect Claude to external services via MCP. Each integration plugin provides tools for interacting with a specific service, with Han managing how those tools are exposed to Claude Code. -### Dual-Mode Architecture +### How Integration Plugins Are Wired -Han uses a **dual-mode architecture** for integration plugins that optimizes context usage: +An integration plugin ships a `.mcp.json` (or an `mcp_servers:` block in `han-plugin.yml`) declaring its backend server. Claude Code can talk to that server directly, and Han additionally re-exposes it through `han mcp` when the plugin marks it `expose: true`: -| Mode | When Active | Behavior | -|------|-------------|----------| -| **Orchestrator** (default) | `orchestrator.enabled: true` | Han manages all tools centrally. Integration MCP servers return no tools—Han's orchestrator exposes a unified workflow interface. | -| **Direct** | `orchestrator.enabled: false` | Each integration plugin proxies directly to its MCP server, exposing all tools individually. | - -**Why this matters:** - -- **Orchestrator mode** reduces context overhead by exposing ~5 tools instead of 50+ from multiple backends -- **Direct mode** gives you full access to individual MCP tools when needed -- Switch between modes via `han.yml` configuration +```yaml +# han-plugin.yml +mcp_servers: + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + expose: true +``` -### How It Works +With `expose: true`, Han opens a pooled connection to the backend, prefixes each of its tools with the server name, and serves them from the single `han` MCP entry. Without it, the plugin's server stands on its own and Claude Code connects to it directly. -When you install an integration plugin, it registers an MCP server that routes through Han: +Pool behaviour is tunable in `han.yml`: -```json -{ - "mcpServers": { - "github": { - "command": "han", - "args": ["mcp", "github", "github"] - } - } -} +```yaml +orchestrator: + backends: + idle_timeout: 300 # Seconds before an idle backend connection is closed + max_connections: 10 ``` -Han then decides what to expose based on orchestrator configuration: - -- **Orchestrator enabled**: Returns a stub MCP with no tools. Han's main MCP server provides a `han_workflow` tool that can invoke any backend capability. -- **Orchestrator disabled**: Proxies to the actual MCP server (e.g., GitHub's official MCP), exposing all its tools directly. +`orchestrator.backends` is the only `orchestrator` subsection Han reads. Earlier documentation described an `orchestrator.enabled` switch and a unified `han_workflow` tool that toggled between "orchestrator" and "direct" modes; neither exists in the code. ### Available Integration Plugins @@ -214,22 +145,15 @@ This adds the MCP server to your Claude Code configuration, routing through Han: } ``` -### Configuring the Orchestrator +### Tuning the Backend Pool -Control orchestrator behavior in `han.yml`: +The only MCP behaviour you configure in `han.yml` is the connection pool Han keeps for exposed backends: ```yaml -# Enable orchestrator (default) - Han manages all tools -orchestrator: - enabled: true - workflow: - enabled: true - max_steps: 20 - timeout: 300 - -# Or disable to use direct MCP access orchestrator: - enabled: false + backends: + idle_timeout: 300 # Seconds before an idle backend connection is closed + max_connections: 10 ``` ### How MCP Tools Appear in Claude Code diff --git a/website/content/docs/metrics/index.md b/website/content/docs/metrics/index.md index f43930594..46f03cad7 100644 --- a/website/content/docs/metrics/index.md +++ b/website/content/docs/metrics/index.md @@ -104,7 +104,7 @@ Returns aggregated statistics including success rates by confidence bucket, enab ## What Gets Stored -All metrics are stored locally in SQLite at `~/.claude/han/han.db`. **No data leaves your machine.** You control it entirely. +All metrics are stored locally in SQLite at `~/.han/han.db`, or under `$HAN_DATA_DIR` when that is set. **No data leaves your machine.** You control it entirely. The database contains tables for: diff --git a/website/content/docs/metrics/opentelemetry.md b/website/content/docs/metrics/opentelemetry.md index e27c7fd67..ee5b1c721 100644 --- a/website/content/docs/metrics/opentelemetry.md +++ b/website/content/docs/metrics/opentelemetry.md @@ -11,7 +11,7 @@ OpenTelemetry (OTel) is an open-source standard for collecting telemetry data (m ## Why Export Han Metrics? -While Han stores metrics locally in `~/.claude/han/metrics/`, exporting to OpenTelemetry enables: +While Han stores metrics locally in `~/.han/han.db`, exporting to OpenTelemetry enables: - **Dashboards** - Visualize task success rates, hook failures, and calibration trends - **Alerting** - Get notified when hook failure rates spike or calibration degrades @@ -183,7 +183,7 @@ unset CLAUDE_CODE_ENABLE_TELEMETRY export CLAUDE_CODE_ENABLE_TELEMETRY=0 ``` -Local metrics in `~/.claude/han/metrics/` continue to work regardless of OpenTelemetry settings. +Local metrics in `~/.han/han.db` continue to work regardless of OpenTelemetry settings. ## Getting Started diff --git a/website/content/docs/plugin-categories.md b/website/content/docs/plugin-categories.md index a38d52c87..004c2b415 100644 --- a/website/content/docs/plugin-categories.md +++ b/website/content/docs/plugin-categories.md @@ -25,7 +25,7 @@ The essential infrastructure that powers Han. Always required. **When to install:** -Always. Core is required for Han to function. Bushido is optional—install only if its philosophical approach resonates with you. All technical capabilities come from core. +Always. Core is required for Han to function. Bushido is optional, so install it only if its philosophical approach resonates with you. All technical capabilities come from core. **Installation:** @@ -212,16 +212,11 @@ Integration plugins connect Claude to external services via MCP (Model Context P - **playwright-mcp** - Browser automation via MCP - **blueprints** - Technical documentation management -**Dual-Mode Operation:** +**How tools reach Claude:** -Integration plugins support two operation modes: +An integration plugin declares a backend MCP server. When that server is marked `expose: true`, Han fronts it through its own `han mcp` entry and prefixes each tool with the server name (`context7_resolve-library-id`). Otherwise Claude Code connects to the plugin's server directly. -| Mode | Tools Visible | Use Case | -|------|---------------|----------| -| **Orchestrator** (default) | None—Han manages all tools via `han_workflow` | Reduced context, unified interface | -| **Direct** | All backend MCP tools exposed individually | Full tool access when needed | - -In orchestrator mode, Han exposes a single `han_workflow` tool that can invoke any backend capability. This reduces context usage from 50+ tools to ~5. +Either way you get the backend's real tools. There is no "orchestrator" mode that collapses them into a single `han_workflow` tool; that was described in earlier documentation but never existed. **When to install:** @@ -230,20 +225,22 @@ Install integration plugins for external services you use in your workflow. They **Installation:** ```bash -# Install to user settings (recommended for MCP servers) +# Integration plugins install at project scope like everything else han plugin install github han plugin install playwright-mcp -# Or specify scope explicitly -han plugin install blueprints --scope user +# Or keep one to yourself +han plugin install blueprints --scope local ``` -**Switching modes:** +**Tuning the backend pool:** ```yaml -# han.yml - disable orchestrator for direct MCP access +# han.yml orchestrator: - enabled: false + backends: + idle_timeout: 300 + max_connections: 10 ``` ## Discipline - Specialized AI Agents diff --git a/website/content/docs/plugin-development/distribution.md b/website/content/docs/plugin-development/distribution.md index 51e136416..15da07db0 100644 --- a/website/content/docs/plugin-development/distribution.md +++ b/website/content/docs/plugin-development/distribution.md @@ -3,154 +3,85 @@ title: "Distribution" description: "How to share your Han plugins via local paths, Git repositories, URLs, or the Han marketplace." --- -Once your plugin is tested and ready, you have several options for distribution. This guide covers all methods from simple local sharing to marketplace submission. +Once your plugin is tested and ready, you have two ways to distribute it: submit it to the Han marketplace, or publish it in a marketplace repository of your own. + +`han plugin install` resolves plugin names against a marketplace. It has no `--path`, `--git`, `--url`, `--branch`, or `--tag` flags; earlier documentation described those and none of them exist. The full option set is `--auto`, `--no-analyze`, `--scope`, and `--from`. ## Distribution Methods | Method | Best For | Installation | |--------|----------|--------------| -| Local Path | Development, private plugins | `han plugin install --path ./my-plugin` | -| Git Repository | Team sharing, open source | `han plugin install --git https://github.com/...` | -| URL Archive | Quick sharing, no Git | `han plugin install --url https://.../plugin.tar.gz` | -| Han Marketplace | Public distribution | `han plugin install my-plugin` | - -## Local Path Distribution +| Han marketplace | Public distribution | `han plugin install my-plugin` | +| Your own marketplace repo | Team or private plugins | `han plugin install my-plugin --from myorg/my-plugins` | +| Local development | Building and testing a plugin | Run from a checkout of the marketplace repo | -The simplest method - share a directory: +## External Marketplace Repositories -### Installation +`--from` points Han at another GitHub repository that carries its own `.claude-plugin/marketplace.json`. This is how you ship plugins to a team without landing them in the public Han marketplace. ```bash -# From absolute path -han plugin install --path /Users/me/plugins/my-plugin - -# From relative path -han plugin install --path ./my-plugin +# Install from another org's marketplace +han plugin install ai-dlc --from thebushidocollective/ai-dlc -# With scope -han plugin install --path ./my-plugin --scope project +# Your own private plugin set +han plugin install internal-linter --from myorg/claude-plugins ``` -### Use Cases - -- Local development and testing -- Private plugins within an organization -- Plugins embedded in monorepos - -### Sharing - -Share the plugin directory via: - -- File sharing (Dropbox, Google Drive) -- Internal network drives -- Copying to team members - -## Git Repository Distribution - -Host your plugin in a Git repository for version control and easy updates. - ### Repository Structure -Your repository should contain the plugin at its root: - -``` -my-plugin/ -├── .claude-plugin/ -│ └── plugin.json -├── han-plugin.yml -├── skills/ -├── README.md -└── CHANGELOG.md -``` - -Or in a subdirectory: +Mirror the Han marketplace layout: plugins in category directories, all of them registered in a root manifest. ``` my-plugins-repo/ -├── biome/ -│ └── ... -├── eslint/ -│ └── ... +├── .claude-plugin/ +│ └── marketplace.json # Registers every plugin below +├── validation/ +│ └── internal-linter/ +│ ├── .claude-plugin/ +│ │ └── plugin.json +│ ├── han-plugin.yml +│ ├── skills/ +│ ├── README.md +│ └── CHANGELOG.md └── README.md ``` -### Installation - -```bash -# From GitHub (default branch) -han plugin install --git https://github.com/user/my-plugin +Each entry in `marketplace.json` needs `name`, `description`, `source` (the path to the plugin directory), `category`, and `keywords`: -# Specific branch -han plugin install --git https://github.com/user/my-plugin --branch main - -# Specific tag -han plugin install --git https://github.com/user/my-plugin --tag v1.0.0 - -# Subdirectory -han plugin install --git https://github.com/user/plugins --path biome +```json +{ + "name": "internal-linter", + "description": "House lint rules for our services.", + "source": "./validation/internal-linter", + "category": "Validation", + "keywords": ["lint", "internal"] +} ``` -### Version Tags +### Versioning Through Git -Use Git tags for versioning: +Han reads the repository's default branch, so tag releases for your own record-keeping and merge to the default branch to publish: ```bash -# Create version tag git tag v1.0.0 git push origin v1.0.0 - -# Users install specific version -han plugin install --git https://github.com/user/my-plugin --tag v1.0.0 ``` -### Branch Strategy - -- `main` - Stable releases -- `develop` - Development versions -- `v*` tags - Specific versions - -## URL Archive Distribution +There is no `--tag` or `--branch` flag to pin an install to a specific ref. -Distribute plugins as downloadable archives. +## Local Development -### Creating Archives +To iterate on a plugin, work inside a checkout of the marketplace repository that contains it. Han discovers plugins from the marketplace manifest, so a plugin registered in the local `.claude-plugin/marketplace.json` is visible to `han plugin install`, `han hook list`, and `han plugin validate` without any extra install step. ```bash -# Create tar.gz -tar -czvf my-plugin-1.0.0.tar.gz -C /path/to my-plugin - -# Create zip -zip -r my-plugin-1.0.0.zip my-plugin -``` - -### Hosting Options - -- GitHub Releases -- S3 / Cloud Storage -- Personal website -- CDN - -### Installation - -```bash -# From URL -han plugin install --url https://example.com/plugins/my-plugin-1.0.0.tar.gz - -# From GitHub Release -han plugin install --url https://github.com/user/repo/releases/download/v1.0.0/my-plugin.tar.gz -``` - -### Archive Structure +# From the plugin directory +han plugin validate -Archives should extract to a single directory: +# Regenerate hooks.json after editing han-plugin.yml +han plugin generate-hooks -``` -my-plugin-1.0.0.tar.gz -└── my-plugin/ - ├── .claude-plugin/ - │ └── plugin.json - ├── han-plugin.yml - └── ... +# Run the hook you just defined +han hook run my-plugin lint --verbose ``` ## Han Marketplace Submission @@ -159,7 +90,7 @@ For maximum visibility, submit to the Han marketplace. ### Prerequisites -1. Plugin passes validation (`han plugin validate .`) +1. Plugin passes validation (run `han plugin validate` from the plugin directory) 2. Comprehensive README 3. Proper versioning (semver) 4. License specified @@ -344,42 +275,33 @@ MIT For organization-internal plugins: -### Option 1: Private Git Repository +### Option 1: Private Marketplace Repository -```bash -# Users install with credentials -han plugin install --git https://github.com/org/private-plugin +Put your plugins in a private GitHub repository with its own `.claude-plugin/marketplace.json`, then install by name with `--from`. Users need read access to the repo and a working `gh` or git credential for it. -# Or via SSH -han plugin install --git git@github.com:org/private-plugin.git +```bash +han plugin install internal-linter --from org/private-plugins ``` -### Option 2: Private Registry - -Set up an internal plugin registry: +### Option 2: Monorepo Embedding -1. Host plugin archives internally -2. Configure registry URL -3. Users install normally - -### Option 3: Monorepo Embedding - -Include plugins in your project: +Keep plugins in the project that uses them, registered in the repository's own marketplace manifest: ``` your-project/ -├── .claude/ -│ └── plugins/ -│ └── my-internal-plugin/ -│ └── ... +├── .claude-plugin/ +│ └── marketplace.json +├── plugins/ +│ └── internal-linter/ +│ ├── .claude-plugin/ +│ │ └── plugin.json +│ └── han-plugin.yml └── ... ``` -Install with: +Han discovers plugins through the marketplace manifest, so a plugin registered here is available to `han hook run` and `han plugin validate` from inside the repo with no install step. -```bash -han plugin install --path .claude/plugins/my-internal-plugin --scope project -``` +There is no private registry protocol and no `--path` or `--git` install flag. Both were described in earlier documentation; neither exists. ## Distribution Checklist diff --git a/website/content/docs/plugin-development/hooks.md b/website/content/docs/plugin-development/hooks.md index 6b0e8482a..07231e3fe 100644 --- a/website/content/docs/plugin-development/hooks.md +++ b/website/content/docs/plugin-development/hooks.md @@ -161,6 +161,7 @@ Hooks run at specific points during Claude Code sessions: | `InstructionsLoaded` | CLAUDE.md/rules file loads (2.1.69+) | Observability | | `ConfigChange` | Configuration modified (2.1.49+) | Audit trails, config monitoring | | `CwdChanged` | Working directory changes (2.1.83+) | Reactive env management (direnv-style) | +| `DirectoryAdded` | Working directory added mid-session via `/add-dir` or the SDK `register_repo_root` control request (2.1.219+) | Registering markers or hooks for a newly added root | | `FileChanged` | Watched file changes on disk (2.1.83+) | Reactive reloads | | `PreCompact` | Before context compaction (~2.1.50+, blocking since 2.1.105) | Save state before compaction | | `PostCompact` | After context compaction (2.1.76+) | Post-compaction cleanup | @@ -170,7 +171,7 @@ Hooks run at specific points during Claude Code sessions: | `WorktreeRemove` | Worktree removed (2.1.50+) | Cleanup automation | | `SessionEnd` | Session ends | Cleanup | -By default, validation and tool plugin hooks run at `Stop` and `SubagentStop`. Claude Code executes plugin hooks directly - you just define what to run. Han's `han-plugin.yml` accepts every event above as of Claude Code 2.1.215. +This table is the canonical list of every event Han's `han-plugin.yml` accepts. It mirrors `HOOK_EVENT_TYPES` in `packages/han/lib/hooks/hook-config.ts` and is complete as of Claude Code 2.1.228. Event semantics follow the upstream [Claude Code hooks reference](https://code.claude.com/docs/en/hooks). By default, validation and tool plugin hooks run at `Stop` and `SubagentStop`. Claude Code executes plugin hooks directly - you just define what to run. ### New Hook Events (Claude Code 2.1.33+) @@ -255,7 +256,7 @@ Fired when a task is marked as completed via `TaskUpdate`. Useful for task track #### WorktreeCreate (2.1.50+) -Fired when a worktree is being created via `--worktree` flag or `isolation: "worktree"` in an agent definition. When configured, **replaces default git worktree behavior** — enabling non-git VCS support (SVN, Perforce, Mercurial). +Fired when a worktree is being created via `--worktree` flag or `isolation: "worktree"` in an agent definition. When configured, **replaces default git worktree behavior**, enabling non-git VCS support (SVN, Perforce, Mercurial). The hook receives a `name` slug and **must print the absolute path** to the created worktree directory on stdout. Non-zero exit blocks creation. Only `type: "command"` hooks supported; no matchers. @@ -270,7 +271,7 @@ The hook receives a `name` slug and **must print the absolute path** to the crea #### WorktreeRemove (2.1.50+) -Fired when a worktree is being removed. Receives the `worktree_path` that was originally created. **Cannot block** removal — failures are logged in debug mode only. Only `type: "command"` hooks supported; no matchers. +Fired when a worktree is being removed. Receives the `worktree_path` that was originally created. **Cannot block** removal; failures are logged in debug mode only. Only `type: "command"` hooks supported; no matchers. ```json { diff --git a/website/content/docs/plugin-development/index.md b/website/content/docs/plugin-development/index.md index e359f0638..06b8aa858 100644 --- a/website/content/docs/plugin-development/index.md +++ b/website/content/docs/plugin-development/index.md @@ -37,18 +37,18 @@ Han plugins are organized into nine categories based on their technical layer: ## Plugin Structure -All plugins share a common base structure: +What a Han plugin typically contains: ``` your-plugin/ ├── .claude-plugin/ -│ └── plugin.json # Required: Plugin metadata -├── han-plugin.yml # Hook configuration (optional) +│ └── plugin.json # Plugin manifest +├── han-plugin.yml # Han hook configuration (optional) +├── hooks/ +│ └── hooks.json # Generated from han-plugin.yml ├── skills/ # Skills (optional) │ └── skill-name/ │ └── SKILL.md -├── commands/ # Slash commands (optional) -│ └── command-name.md ├── agents/ # Agents for discipline plugins (optional) │ └── agent-name.md ├── .mcp.json # MCP server config for integration plugins @@ -56,6 +56,35 @@ your-plugin/ └── CHANGELOG.md # Version history ``` +### The Full Claude Code Component Set + +Han plugins use a subset of what Claude Code supports. The complete set of components a plugin can ship, and where each lives: + +| Component | Location | Han's usage | +|-----------|----------|-------------| +| Manifest | `.claude-plugin/plugin.json` | Required by Han's marketplace | +| Skills | `skills//SKILL.md` | Han's convention for plugin knowledge | +| Commands | `commands/.md` | Skills as flat markdown. Valid, but no Han plugin uses it | +| Agents | `agents/.md` | Used by discipline plugins | +| Hooks | `hooks/hooks.json` | Generated from `han-plugin.yml` | +| MCP servers | `.mcp.json` | Used by integration plugins | +| LSP servers | `.lsp.json` | Not modeled by Han | +| Workflows | `workflows/` | Not modeled by Han | +| Output styles | `output-styles/` | Not modeled by Han | +| Themes | `themes/` | Experimental upstream. Not modeled by Han | +| Monitors | `monitors/monitors.json` | Experimental upstream. Not modeled by Han | +| Executables | `bin/` | Files here join the Bash tool's `PATH` and are invokable as bare commands while the plugin is enabled. Not modeled by Han | +| Settings | `settings.json` | Default configuration applied when the plugin is enabled. Only the `agent` and `subagentStatusLine` keys are supported. Not modeled by Han | + +"Not modeled by Han" means Han's own tooling does not generate or validate the component; Claude Code still loads it normally if your plugin ships one. + +Two notes on the ones that overlap with Han's conventions: + +- `commands/` is accepted by Claude Code, so a plugin containing one is not invalid. It is not Han's convention: every plugin in the marketplace uses `skills/` with a `SKILL.md`, and none ships a `commands/` directory. Write a skill unless you specifically need explicit `/command` invocation. +- `hooks/hooks.json` should be generated with `han plugin generate-hooks` rather than hand-written, so `han-plugin.yml` stays the single source of truth. + +Everything except `.claude-plugin/plugin.json` lives at the plugin root, not inside `.claude-plugin/`. + ## Required Files ### plugin.json diff --git a/website/content/docs/plugin-development/skills.md b/website/content/docs/plugin-development/skills.md index 22c2a503c..72f7846a6 100644 --- a/website/content/docs/plugin-development/skills.md +++ b/website/content/docs/plugin-development/skills.md @@ -1,19 +1,23 @@ --- -title: "Skills and Commands" -description: "How to create skills and slash commands that teach Claude specialized knowledge and workflows." +title: "Skills" +description: "How to create skills that teach Claude specialized knowledge and workflows." --- -Skills and commands give Claude specialized knowledge about tools and workflows. They're markdown files with YAML frontmatter that Claude reads when relevant topics come up. +Skills give Claude specialized knowledge about tools and workflows. They're markdown files with YAML frontmatter that Claude reads when relevant topics come up. -## Skills vs Commands +## Skills, Not Commands -| Feature | Skills | Commands | -|---------|--------|----------| +Han plugins package their knowledge as skills. Claude Code also supports a `commands/` directory of slash-command markdown files, and a plugin that ships one still loads correctly, but that is not Han's convention: every plugin in the marketplace uses `skills/` and none ships `commands/`. + +| Feature | Skills (Han's convention) | Commands (Claude Code, unused by Han) | +|---------|---------------------------|----------------------------------------| | Location | `skills/skill-name/SKILL.md` | `commands/command-name.md` | | Purpose | Deep knowledge about a topic | Specific workflow or action | | Invocation | Automatic when topic is relevant | Explicit via `/command` | | Length | Typically longer, more comprehensive | Usually focused and actionable | +Reach for a skill by default. A skill surfaces itself when the topic is relevant, which is what you want from a plugin that teaches Claude how to use a tool. + ## Creating Skills Skills are organized in directories under `skills/`: @@ -142,9 +146,9 @@ When rules conflict with your codebase: 3. Document disabled rules with comments ``` -## Creating Commands +## Appendix: Slash Commands -Commands are slash commands users invoke directly: +No Han plugin uses this. It is documented only so you recognize the shape if you meet it in a third-party plugin, or genuinely need explicit `/command` invocation. Claude Code reads slash commands from a `commands/` directory: ``` your-plugin/ diff --git a/website/content/docs/plugin-development/testing.md b/website/content/docs/plugin-development/testing.md index 219175ddf..db91e8015 100644 --- a/website/content/docs/plugin-development/testing.md +++ b/website/content/docs/plugin-development/testing.md @@ -51,21 +51,25 @@ Checking skills... Plugin validation passed! ``` -## Local Installation +## Testing Locally -Install your plugin locally to test it in Claude Code: +`han plugin install` resolves names against a marketplace; there is no `--path` flag for pointing it at a working directory. To exercise a plugin you are writing, work inside a checkout of the marketplace repository that registers it. -### Method 1: Local Path Installation +### Method 1: Run From the Marketplace Checkout + +Han discovers plugins through `.claude-plugin/marketplace.json`, so once your plugin is registered there you can drive it directly: ```bash -# Install from local directory -han plugin install --path ./my-plugin +# From the plugin directory +han plugin validate +han plugin generate-hooks -# Or with explicit scope -han plugin install --path ./my-plugin --scope project +# Then exercise the hook +han hook run my-plugin lint --verbose +han hook list --plugin my-plugin ``` -This creates a symlink to your plugin, so changes are reflected immediately. +Edits to `han-plugin.yml` take effect as soon as you regenerate `hooks/hooks.json`. ### Method 2: Direct Configuration @@ -104,8 +108,8 @@ han hook run my-plugin lint --verbose # Skip caching (force re-run) han hook run my-plugin lint --no-cache -# Run in a specific directory -han hook run my-plugin lint --directory ./packages/web +# Run in a specific directory only +han hook run my-plugin lint --only ./packages/web ``` ### Verify Hook Conditions @@ -241,13 +245,14 @@ npx -y @your-org/mcp-server-your-service --help ### Test Memory Provider -If you configured a memory provider: +If you configured a memory provider, start the Han MCP server and call its `memory` tool from a Claude Code session: ```bash -# Query memory -han memory query "search term relevant to your service" +han mcp ``` +There is no `han memory query` subcommand. Bare `han memory` prints the memory system's status, and `han browse` gives you a searchable UI over indexed sessions. + ## Debugging ### Verbose Mode @@ -263,28 +268,42 @@ han hook run my-plugin lint --verbose View hook configuration and state: ```bash -han hook info my-plugin lint +# Every configured hook, Han plugins plus Claude Code settings +han hook explain + +# Just the hooks discovered from installed plugins +han hook list --plugin my-plugin + +# Machine-readable +han hook list --plugin my-plugin --json ``` -Output includes: +Output includes the hook command, its conditions (`dirs_with`, `if_changed`), and its event. + +### Run Hooks As Claude Code Would + +`han hook test` executes hooks with a simulated Claude Code payload, which reproduces failures that only appear under a real event: -- Hook command -- Conditions (dirs_with, if_changed) -- Cache status -- Last run result +```bash +han hook test Stop + +# Show the stdin payload each hook receives +han hook test Stop --payload + +# Narrow to your plugin's command +han hook test --command my-plugin +``` ### View Cache Inspect the hook cache: ```bash -# Cache is stored in ~/.han/cache/ -ls -la ~/.han/cache/hooks/ - -# Clear cache to force re-runs -han cache clear --hooks +ls -la .claude/cache/hooks/ ``` +There is no `han cache clear`. Force a re-run with `han hook run my-plugin lint --no-cache`, or delete the cache directory; Han rebuilds it on the next run. + ### Check Plugin Registration Verify your plugin is registered: @@ -293,10 +312,12 @@ Verify your plugin is registered: # List all plugins han plugin list -# Show plugin details -han plugin info my-plugin +# Validate the plugin in the current directory +han plugin validate ``` +There is no `han plugin info`. `han plugin validate` reports structural and manifest problems for the plugin you are standing in. + ### Debug Hook Failures When hooks fail: From c556ad5e04d078f64321211a9c3574396b130e0a Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Wed, 12 Aug 2026 03:15:51 -0600 Subject: [PATCH 10/10] fix(cli): keep the hook event catalogue out of the async module graph The event list lived in hook-config.ts, whose transitive graph makes any module importing it async. hook-explain-ui.tsx is re-exported from hooks/index.ts, so importing the list there turned the barrel async for CI's Bun and broke a test that require()s it. The catalogue now lives in a dependency-free hooks/hook-events.ts that every consumer imports directly, so the single source of truth survives without dragging a module graph behind it. The barrel was already async on main under Bun 1.3, so require() of it was always the wrong call and only worked by accident of how older Bun classified it. That test now imports findDirectoriesWithMarkers from hook-cache.ts, the module that owns it. --- packages/han/lib/commands/hook/test.tsx | 2 +- .../han/lib/commands/plugin/generate-hooks.ts | 4 +- packages/han/lib/hook-explain-ui.tsx | 3 +- packages/han/lib/hooks/hook-config.ts | 49 +--------------- packages/han/lib/hooks/hook-events.ts | 56 +++++++++++++++++++ .../han/test/validate-integration.test.ts | 11 +++- 6 files changed, 71 insertions(+), 54 deletions(-) create mode 100644 packages/han/lib/hooks/hook-events.ts diff --git a/packages/han/lib/commands/hook/test.tsx b/packages/han/lib/commands/hook/test.tsx index eebe4fc6e..5a35b749f 100644 --- a/packages/han/lib/commands/hook/test.tsx +++ b/packages/han/lib/commands/hook/test.tsx @@ -19,9 +19,9 @@ import { readSettingsFile, type SettingsScope, } from '../../config/claude-settings.ts'; +import { HOOK_EVENT_TYPES } from '../../hooks/hook-events.ts'; import { getHookEvents, - HOOK_EVENT_TYPES, loadPluginConfig, type PluginHookDefinition, } from '../../hooks/index.ts'; diff --git a/packages/han/lib/commands/plugin/generate-hooks.ts b/packages/han/lib/commands/plugin/generate-hooks.ts index 9f536fb70..88c8f46d9 100644 --- a/packages/han/lib/commands/plugin/generate-hooks.ts +++ b/packages/han/lib/commands/plugin/generate-hooks.ts @@ -20,11 +20,11 @@ import { import { basename, join } from 'node:path'; import type { Command } from 'commander'; import { parse as parseYaml } from 'yaml'; +import { parseEventShorthands } from '../../hooks/hook-config.ts'; import { HOOK_EVENT_TYPES, type HookEventType, - parseEventShorthands, -} from '../../hooks/hook-config.ts'; +} from '../../hooks/hook-events.ts'; /** * Claude Code hooks.json structure diff --git a/packages/han/lib/hook-explain-ui.tsx b/packages/han/lib/hook-explain-ui.tsx index 691f306a8..bd561ce2c 100644 --- a/packages/han/lib/hook-explain-ui.tsx +++ b/packages/han/lib/hook-explain-ui.tsx @@ -1,7 +1,8 @@ import { Box, Text } from 'ink'; import type React from 'react'; import type { SettingsScope } from './config/claude-settings.ts'; -import { HOOK_EVENT_TYPES, type HookDependency } from './hooks/hook-config.ts'; +import type { HookDependency } from './hooks/hook-config.ts'; +import { HOOK_EVENT_TYPES } from './hooks/hook-events.ts'; /** * Hook types where stdout is meant to inject context into Claude's conversation. diff --git a/packages/han/lib/hooks/hook-config.ts b/packages/han/lib/hooks/hook-config.ts index 2f67d1928..5f871dd61 100644 --- a/packages/han/lib/hooks/hook-config.ts +++ b/packages/han/lib/hooks/hook-config.ts @@ -10,54 +10,9 @@ import { import { getPluginHookSettings } from '../config/han-settings.ts'; import { getPluginNameFromRoot } from '../shared/index.ts'; import { findDirectoriesWithMarkers } from './hook-cache.ts'; +import { HOOK_EVENT_TYPES, type HookEventType } from './hook-events.ts'; -/** - * Every Claude Code hook event, in the order han writes them into a generated - * `hooks.json`. This is the single source of truth: the `HookEventType` union, - * shorthand validation, and hooks.json key ordering all derive from it, so a - * new upstream event is added in exactly one place. - * - * Complete as of Claude Code 2.1.228. - * See https://code.claude.com/docs/en/plugins-reference#hooks - */ -export const HOOK_EVENT_TYPES = [ - 'SessionStart', - 'Setup', - 'UserPromptSubmit', - 'UserPromptExpansion', - 'PreToolUse', - 'PermissionRequest', - 'PermissionDenied', - 'PostToolUse', - 'PostToolUseFailure', - 'PostToolBatch', - 'Notification', - 'MessageDisplay', - 'Stop', - 'SubagentStop', - 'SubagentStart', - 'TaskCreated', - 'TaskCompleted', - 'StopFailure', - 'TeammateIdle', - 'InstructionsLoaded', - 'ConfigChange', - 'CwdChanged', - 'DirectoryAdded', - 'FileChanged', - 'WorktreeCreate', - 'WorktreeRemove', - 'PreCompact', - 'PostCompact', - 'Elicitation', - 'ElicitationResult', - 'SessionEnd', -] as const; - -/** - * Claude Code hook event types that can trigger han hooks. - */ -export type HookEventType = (typeof HOOK_EVENT_TYPES)[number]; +export { HOOK_EVENT_TYPES, type HookEventType } from './hook-events.ts'; /** * Valid base event types for shorthand parsing validation. diff --git a/packages/han/lib/hooks/hook-events.ts b/packages/han/lib/hooks/hook-events.ts new file mode 100644 index 000000000..374b7106d --- /dev/null +++ b/packages/han/lib/hooks/hook-events.ts @@ -0,0 +1,56 @@ +/** + * The Claude Code hook event catalogue. + * + * This module is deliberately dependency-free. Consumers include UI modules + * that are re-exported from `hooks/index.ts`, and pulling a module with a + * transitive graph into that barrel makes it an async module, which breaks + * `require()` of the barrel on older Bun releases. + */ + +/** + * Every Claude Code hook event, in the order han writes them into a generated + * `hooks.json`. This is the single source of truth: the `HookEventType` union, + * shorthand validation, and hooks.json key ordering all derive from it, so a + * new upstream event is added in exactly one place. + * + * Complete as of Claude Code 2.1.228. + * See https://code.claude.com/docs/en/plugins-reference#hooks + */ +export const HOOK_EVENT_TYPES = [ + 'SessionStart', + 'Setup', + 'UserPromptSubmit', + 'UserPromptExpansion', + 'PreToolUse', + 'PermissionRequest', + 'PermissionDenied', + 'PostToolUse', + 'PostToolUseFailure', + 'PostToolBatch', + 'Notification', + 'MessageDisplay', + 'Stop', + 'SubagentStop', + 'SubagentStart', + 'TaskCreated', + 'TaskCompleted', + 'StopFailure', + 'TeammateIdle', + 'InstructionsLoaded', + 'ConfigChange', + 'CwdChanged', + 'DirectoryAdded', + 'FileChanged', + 'WorktreeCreate', + 'WorktreeRemove', + 'PreCompact', + 'PostCompact', + 'Elicitation', + 'ElicitationResult', + 'SessionEnd', +] as const; + +/** + * Claude Code hook event types that can trigger han hooks. + */ +export type HookEventType = (typeof HOOK_EVENT_TYPES)[number]; diff --git a/packages/han/test/validate-integration.test.ts b/packages/han/test/validate-integration.test.ts index cce6bbb47..ddc0459f2 100644 --- a/packages/han/test/validate-integration.test.ts +++ b/packages/han/test/validate-integration.test.ts @@ -171,8 +171,11 @@ describe('validate.ts helper functions', () => { writeFileSync(join(pkg1, 'package.json'), '{}'); writeFileSync(join(pkg2, 'package.json'), '{}'); - // Import and test - const { findDirectoriesWithMarkers } = require('../lib/hooks/index.ts'); + // Import from the module that owns it. Requiring the hooks barrel pulls + // in an async module graph, which require() cannot load. + const { + findDirectoriesWithMarkers, + } = require('../lib/hooks/hook-cache.ts'); const result = findDirectoriesWithMarkers(projectDir, ['package.json']); expect(result.length).toBe(2); @@ -188,7 +191,9 @@ describe('validate.ts helper functions', () => { mkdirSync(nodeModules, { recursive: true }); writeFileSync(join(nodeModules, 'package.json'), '{}'); - const { findDirectoriesWithMarkers } = require('../lib/hooks/index.ts'); + const { + findDirectoriesWithMarkers, + } = require('../lib/hooks/hook-cache.ts'); const result = findDirectoriesWithMarkers(projectDir, ['package.json']); expect(result.length).toBe(0);