/** * Shared utilities for ClaudeKit hooks * * Contains config loading, path sanitization, and common constants * used by session-init.cjs and dev-rules-reminder.cjs */ const fs = require('fs'); const path = require('path'); const os = require('os'); const { execFileSync } = require('child_process'); const LOCAL_CONFIG_PATH = '.opencode/.ck.json'; const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.claude', '.ck.json'); const SESSION_STATE_LOCK_TIMEOUT_MS = 500; const SESSION_STATE_LOCK_RETRY_MS = 10; const SESSION_STATE_LOCK_STALE_MS = 5000; // Legacy export for backward compatibility const CONFIG_PATH = LOCAL_CONFIG_PATH; const DEFAULT_CONFIG = { plan: { namingFormat: '{date}-{issue}-{slug}', dateFormat: 'YYMMDD-HHmm', issuePrefix: null, reportsDir: 'reports', resolution: { // CHANGED: Removed 'mostRecent' - only explicit session state activates plans // Branch matching now returns 'suggested' not 'active' order: ['session', 'branch'], branchPattern: '(?:feat|fix|chore|refactor|docs)/(?:[^/]+/)?(.+)' }, validation: { mode: 'prompt', // 'auto' | 'prompt' | 'off' minQuestions: 3, maxQuestions: 8, focusAreas: ['assumptions', 'risks', 'tradeoffs', 'architecture'] } }, paths: { docs: 'docs', plans: 'plans' }, docs: { maxLoc: 800 // Maximum lines of code per doc file before warning }, locale: { thinkingLanguage: null, // Language for reasoning (e.g., "en" for precision) responseLanguage: null // Language for user-facing output (e.g., "vi") }, trust: { passphrase: null, enabled: false }, project: { type: 'auto', packageManager: 'auto', framework: 'auto' }, skills: { research: { useGemini: false // Opt-in: set true only with working Gemini CLI } }, assertions: [], statusline: 'full', statuslineColors: true, statuslineQuota: true, hooks: { 'session-init': true, 'subagent-init': true, 'dev-rules-reminder': true, 'usage-context-awareness': true, 'context-tracking': true, 'scout-block': true, 'privacy-block': true, 'post-edit-simplify-reminder': true, 'task-completed-handler': true, 'teammate-idle-handler': true, 'session-state': true } }; /** * Deep merge objects (source values override target, nested objects merged recursively) * Arrays are replaced entirely (not concatenated) to avoid duplicate entries * * IMPORTANT: Empty objects {} are treated as "inherit from parent", not "replace with empty". * This allows global config to set hooks.foo: false and have it persist even when * local config has hooks: {} (empty = inherit, not reset to defaults). * * @param {Object} target - Base object * @param {Object} source - Object to merge (takes precedence) * @returns {Object} Merged object */ function deepMerge(target, source) { if (!source || typeof source !== 'object') return target; if (!target || typeof target !== 'object') return source; const result = { ...target }; for (const key of Object.keys(source)) { const sourceVal = source[key]; const targetVal = target[key]; // Arrays: replace entirely (don't concatenate) if (Array.isArray(sourceVal)) { result[key] = [...sourceVal]; } // Objects: recurse (but not null) // SKIP empty objects - treat {} as "inherit from parent" else if (sourceVal !== null && typeof sourceVal === 'object' && !Array.isArray(sourceVal)) { // Empty object = inherit (don't override parent values) if (Object.keys(sourceVal).length === 0) { // Keep target value unchanged - empty source means "no override" continue; } result[key] = deepMerge(targetVal || {}, sourceVal); } // Primitives: source wins else { result[key] = sourceVal; } } return result; } /** * Load config from a specific file path * @param {string} configPath - Path to config file * @returns {Object|null} Parsed config or null if not found/invalid */ function loadConfigFromPath(configPath) { try { if (!fs.existsSync(configPath)) return null; return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (e) { return null; } } /** * Get session temp file path * @param {string} sessionId - Session identifier * @returns {string} Path to session temp file */ function getSessionTempPath(sessionId) { return path.join(os.tmpdir(), `ck-session-${sessionId}.json`); } /** * Read session state from temp file * @param {string} sessionId - Session identifier * @returns {Object|null} Session state or null */ function readSessionState(sessionId) { if (!sessionId) return null; const tempPath = getSessionTempPath(sessionId); try { if (!fs.existsSync(tempPath)) return null; return JSON.parse(fs.readFileSync(tempPath, 'utf8')); } catch (e) { return null; } } /** * Write session state atomically to temp file * @param {string} sessionId - Session identifier * @param {Object} state - State object to persist * @returns {boolean} Success status */ function writeSessionState(sessionId, state) { if (!sessionId) return false; const tempPath = getSessionTempPath(sessionId); const tmpFile = tempPath + '.' + Math.random().toString(36).slice(2); try { fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2)); fs.renameSync(tmpFile, tempPath); return true; } catch (e) { try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ } return false; } } function sleepSync(ms) { if (ms <= 0) return; if (typeof SharedArrayBuffer === 'function' && typeof Atomics === 'object' && typeof Atomics.wait === 'function') { const signal = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(signal, 0, 0, ms); return; } const end = Date.now() + ms; while (Date.now() < end) { // Busy wait is a last-resort fallback when Atomics.wait is unavailable. } } function getSessionStateLockPath(sessionId) { return `${getSessionTempPath(sessionId)}.lock`; } function removeStaleSessionStateLock(lockPath, now = Date.now()) { try { const stats = fs.statSync(lockPath); if (now - stats.mtimeMs < SESSION_STATE_LOCK_STALE_MS) return false; fs.unlinkSync(lockPath); return true; } catch { return false; } } function acquireSessionStateLock(sessionId) { const lockPath = getSessionStateLockPath(sessionId); const deadline = Date.now() + SESSION_STATE_LOCK_TIMEOUT_MS; while (Date.now() <= deadline) { try { const fd = fs.openSync(lockPath, 'wx'); fs.writeFileSync(fd, String(process.pid)); return { fd, lockPath }; } catch (error) { if (error?.code !== 'EEXIST') return null; removeStaleSessionStateLock(lockPath); sleepSync(SESSION_STATE_LOCK_RETRY_MS); } } return null; } function releaseSessionStateLock(lock) { if (!lock) return; try { fs.closeSync(lock.fd); } catch (_) { /* ignore */ } try { fs.unlinkSync(lock.lockPath); } catch (_) { /* ignore */ } } /** * Update session state by merging or transforming the existing value. * @param {string} sessionId - Session identifier * @param {Object|Function} updater - Partial state or transform function * @returns {boolean} Success status */ function updateSessionState(sessionId, updater) { if (!sessionId) return false; const lock = acquireSessionStateLock(sessionId); if (!lock) return false; try { const current = readSessionState(sessionId) || {}; const next = typeof updater === 'function' ? updater({ ...current }) : { ...current, ...(updater || {}) }; if (!next || typeof next !== 'object') return false; return writeSessionState(sessionId, next); } finally { releaseSessionStateLock(lock); } } /** * Characters invalid in filenames across Windows, macOS, Linux * Windows: < > : " / \ | ? * * macOS/Linux: / and null byte * Also includes control characters and other problematic chars */ const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1f\x7f]/g; /** * Sanitize slug for safe filesystem usage * - Removes invalid filename characters * - Replaces non-alphanumeric (except hyphen) with hyphen * - Collapses multiple hyphens * - Removes leading/trailing hyphens * - Limits length to prevent filesystem issues * * @param {string} slug - Slug to sanitize * @returns {string} Sanitized slug (empty string if nothing valid remains) */ function sanitizeSlug(slug) { if (!slug || typeof slug !== 'string') return ''; let sanitized = slug // Remove invalid filename chars first .replace(INVALID_FILENAME_CHARS, '') // Replace any non-alphanumeric (except hyphen) with hyphen .replace(/[^a-z0-9-]/gi, '-') // Collapse multiple consecutive hyphens .replace(/-+/g, '-') // Remove leading/trailing hyphens .replace(/^-+|-+$/g, '') // Limit length (most filesystems support 255, but keep reasonable) .slice(0, 100); return sanitized; } /** * Extract feature slug from git branch name * Pattern: (?:feat|fix|chore|refactor|docs)/(?:[^/]+/)?(.+) * @param {string} branch - Git branch name * @param {string} pattern - Regex pattern (optional) * @returns {string|null} Extracted slug or null */ function extractSlugFromBranch(branch, pattern) { if (!branch) return null; const defaultPattern = /(?:feat|fix|chore|refactor|docs)\/(?:[^\/]+\/)?(.+)/; const regex = pattern ? new RegExp(pattern) : defaultPattern; const match = branch.match(regex); return match ? sanitizeSlug(match[1]) : null; } /** * Find most recent plan folder by timestamp prefix * @param {string} plansDir - Plans directory path * @returns {string|null} Most recent plan path or null */ function findMostRecentPlan(plansDir) { try { if (!fs.existsSync(plansDir)) return null; const entries = fs.readdirSync(plansDir, { withFileTypes: true }); const planDirs = entries .filter(e => e.isDirectory() && /^\d{6}/.test(e.name)) .map(e => e.name) .sort() .reverse(); return planDirs.length > 0 ? path.join(plansDir, planDirs[0]) : null; } catch (e) { return null; } } /** * Default timeout for git commands (5 seconds) * Prevents indefinite hangs on network mounts or corrupted repos */ const DEFAULT_EXEC_TIMEOUT_MS = 5000; /** * Safely execute shell command (internal helper) * SECURITY: Only accepts whitelisted git read commands * @param {string} cmd - Command to execute * @param {Object} options - Execution options * @param {string} options.cwd - Working directory (optional) * @param {number} options.timeout - Timeout in ms (default: 5000) * @returns {string|null} Command output or null */ function execSafe(cmd, options = {}) { const allowedCommands = { 'git branch --show-current': ['git', ['branch', '--show-current']], 'git rev-parse --abbrev-ref HEAD': ['git', ['rev-parse', '--abbrev-ref', 'HEAD']], 'git rev-parse --show-toplevel': ['git', ['rev-parse', '--show-toplevel']] }; const commandSpec = allowedCommands[cmd]; if (!commandSpec) { return null; } const { cwd = undefined, timeout = DEFAULT_EXEC_TIMEOUT_MS } = options; const [file, args] = commandSpec; try { return execFileSync(file, args, { encoding: 'utf8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim(); } catch (e) { return null; } } /** * Resolve active plan path using cascading resolution with tracking * * Resolution semantics: * - 'session': Explicitly set via set-active-plan.cjs → ACTIVE (directive) * - 'branch': Matched from git branch name → SUGGESTED (hint only) * - 'mostRecent': REMOVED - was causing stale plan pollution * * @param {string} sessionId - Session identifier (optional) * @param {Object} config - ClaudeKit config * @returns {{ path: string|null, resolvedBy: 'session'|'branch'|null }} Resolution result with tracking */ function resolvePlanPath(sessionId, config) { const plansDir = config?.paths?.plans || 'plans'; const resolution = config?.plan?.resolution || {}; const order = resolution.order || ['session', 'branch']; const branchPattern = resolution.branchPattern; for (const method of order) { switch (method) { case 'session': { const state = readSessionState(sessionId); if (state?.activePlan) { // Issue #335: Handle both absolute and relative paths // - Absolute paths (from updated set-active-plan.cjs): use as-is // - Relative paths (legacy): resolve using sessionOrigin if available let resolvedPath = state.activePlan; if (!path.isAbsolute(resolvedPath) && state.sessionOrigin) { // Resolve relative path using session origin directory resolvedPath = path.join(state.sessionOrigin, resolvedPath); } return { path: resolvedPath, resolvedBy: 'session' }; } break; } case 'branch': { try { const branch = execSafe('git branch --show-current'); const slug = extractSlugFromBranch(branch, branchPattern); if (slug && fs.existsSync(plansDir)) { const entries = fs.readdirSync(plansDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.includes(slug)); if (entries.length > 0) { return { path: path.join(plansDir, entries[entries.length - 1].name), resolvedBy: 'branch' }; } } } catch (e) { // Ignore errors reading plans dir } break; } // NOTE: 'mostRecent' case intentionally removed - was causing stale plan pollution } } return { path: null, resolvedBy: null }; } /** * Normalize path value (trim, remove trailing slashes, handle empty) * @param {string} pathValue - Path to normalize * @returns {string|null} Normalized path or null if invalid */ function normalizePath(pathValue) { if (!pathValue || typeof pathValue !== 'string') return null; // Trim whitespace let normalized = pathValue.trim(); // Empty after trim = invalid if (!normalized) return null; // Remove trailing slashes (but keep root "/" or "C:\") normalized = normalized.replace(/[/\\]+$/, ''); // If it became empty (was just slashes), return null if (!normalized) return null; return normalized; } /** * Check if path is absolute * @param {string} pathValue - Path to check * @returns {boolean} True if absolute path */ function isAbsolutePath(pathValue) { if (!pathValue) return false; // Unix absolute: starts with / // Windows absolute: starts with drive letter (C:\) or UNC (\\) return path.isAbsolute(pathValue); } /** * Sanitize path values * - Normalizes path (trim, remove trailing slashes) * - Allows absolute paths (for consolidated plans use case) * - Prevents obvious security issues (null bytes, etc.) * * @param {string} pathValue - Path to sanitize * @param {string} projectRoot - Project root for relative path resolution * @returns {string|null} Sanitized path or null if invalid */ function sanitizePath(pathValue, projectRoot) { // Normalize first const normalized = normalizePath(pathValue); if (!normalized) return null; // Block null bytes and other dangerous chars if (/[\x00]/.test(normalized)) return null; // Allow absolute paths (user explicitly wants consolidated plans elsewhere) if (isAbsolutePath(normalized)) { return normalized; } // For relative paths, resolve and validate const resolved = path.resolve(projectRoot, normalized); // Prevent path traversal outside project (../ attacks) // But allow if user explicitly set absolute path if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) { // This is a relative path trying to escape - block it return null; } return normalized; } /** * Validate and sanitize config paths */ function sanitizeConfig(config, projectRoot) { const result = { ...config }; if (result.plan) { result.plan = { ...result.plan }; if (!sanitizePath(result.plan.reportsDir, projectRoot)) { result.plan.reportsDir = DEFAULT_CONFIG.plan.reportsDir; } // Merge resolution defaults result.plan.resolution = { ...DEFAULT_CONFIG.plan.resolution, ...result.plan.resolution }; // Merge validation defaults result.plan.validation = { ...DEFAULT_CONFIG.plan.validation, ...result.plan.validation }; } if (result.paths) { result.paths = { ...result.paths }; if (!sanitizePath(result.paths.docs, projectRoot)) { result.paths.docs = DEFAULT_CONFIG.paths.docs; } if (!sanitizePath(result.paths.plans, projectRoot)) { result.paths.plans = DEFAULT_CONFIG.paths.plans; } } if (result.locale) { result.locale = { ...result.locale }; } return result; } /** * Load config with cascading resolution: DEFAULT → global → local * * Resolution order (each layer overrides the previous): * 1. DEFAULT_CONFIG (hardcoded defaults) * 2. Global config (~/.opencode/.ck.json) - user preferences * 3. Local config (./.opencode/.ck.json) - project-specific overrides * * @param {Object} options - Options for config loading * @param {boolean} options.includeProject - Include project section (default: true) * @param {boolean} options.includeAssertions - Include assertions (default: true) * @param {boolean} options.includeLocale - Include locale section (default: true) */ function loadConfig(options = {}) { const { includeProject = true, includeAssertions = true, includeLocale = true } = options; const projectRoot = process.cwd(); // Load configs from both locations const globalConfig = loadConfigFromPath(GLOBAL_CONFIG_PATH); const localConfig = loadConfigFromPath(LOCAL_CONFIG_PATH); // No config files found - use defaults if (!globalConfig && !localConfig) { return getDefaultConfig(includeProject, includeAssertions, includeLocale); } try { // Deep merge: DEFAULT → global → local (local wins) let merged = deepMerge({}, DEFAULT_CONFIG); if (globalConfig) merged = deepMerge(merged, globalConfig); if (localConfig) merged = deepMerge(merged, localConfig); // Build result with optional sections const result = { plan: merged.plan || DEFAULT_CONFIG.plan, paths: merged.paths || DEFAULT_CONFIG.paths, docs: merged.docs || DEFAULT_CONFIG.docs }; if (includeLocale) { result.locale = merged.locale || DEFAULT_CONFIG.locale; } // Always include trust config for verification result.trust = merged.trust || DEFAULT_CONFIG.trust; if (includeProject) { result.project = merged.project || DEFAULT_CONFIG.project; } if (includeAssertions) { result.assertions = merged.assertions || []; } // Coding level for output style selection (-1 to 5, default: -1 = disabled) // -1 = disabled (no injection, saves tokens) // 0-5 = inject corresponding level guidelines result.codingLevel = merged.codingLevel ?? -1; // Skills configuration result.skills = merged.skills || DEFAULT_CONFIG.skills; // Hooks configuration result.hooks = merged.hooks || DEFAULT_CONFIG.hooks; // Statusline mode result.statusline = merged.statusline || 'full'; result.statuslineColors = merged.statuslineColors ?? true; result.statuslineQuota = merged.statuslineQuota ?? true; result.statuslineLayout = merged.statuslineLayout || undefined; return sanitizeConfig(result, projectRoot); } catch (e) { return getDefaultConfig(includeProject, includeAssertions, includeLocale); } } /** * Get default config with optional sections */ function getDefaultConfig(includeProject = true, includeAssertions = true, includeLocale = true) { const result = { plan: { ...DEFAULT_CONFIG.plan }, paths: { ...DEFAULT_CONFIG.paths }, docs: { ...DEFAULT_CONFIG.docs }, codingLevel: -1, // Default: disabled (no injection, saves tokens) skills: { ...DEFAULT_CONFIG.skills }, hooks: { ...DEFAULT_CONFIG.hooks }, statusline: 'full', statuslineColors: true, statuslineQuota: true }; if (includeLocale) { result.locale = { ...DEFAULT_CONFIG.locale }; } if (includeProject) { result.project = { ...DEFAULT_CONFIG.project }; } if (includeAssertions) { result.assertions = []; } return result; } /** * Escape shell special characters for env file values * Handles: backslash, double quote, dollar sign, backtick */ function escapeShellValue(str) { if (typeof str !== 'string') return str; return str .replace(/\\/g, '\\\\') // Backslash first .replace(/"/g, '\\"') // Double quotes .replace(/\$/g, '\\$') // Dollar sign .replace(/`/g, '\\`'); // Backticks (command substitution) } /** * Write environment variable to CLAUDE_ENV_FILE (with escaping) */ function writeEnv(envFile, key, value) { if (envFile && value !== null && value !== undefined) { const escaped = escapeShellValue(String(value)); fs.appendFileSync(envFile, `export ${key}="${escaped}"\n`); } } /** * Get reports path based on plan resolution * Only uses plan-specific path for 'session' resolved plans (explicitly active) * Branch-matched (suggested) plans use default path to avoid pollution * * @param {string|null} planPath - The plan path * @param {string|null} resolvedBy - How plan was resolved ('session'|'branch'|null) * @param {Object} planConfig - Plan configuration * @param {Object} pathsConfig - Paths configuration * @param {string|null} baseDir - Optional base directory for absolute path resolution * @returns {string} Reports path (absolute if baseDir provided, relative otherwise) */ function getReportsPath(planPath, resolvedBy, planConfig, pathsConfig, baseDir = null) { const reportsDir = normalizePath(planConfig?.reportsDir) || 'reports'; const plansDir = normalizePath(pathsConfig?.plans) || 'plans'; let reportPath; // Only use plan-specific reports path if explicitly active (session state) // Issue #327: Validate normalized path to prevent whitespace-only paths creating invalid directories const normalizedPlanPath = planPath && resolvedBy === 'session' ? normalizePath(planPath) : null; if (normalizedPlanPath) { reportPath = `${normalizedPlanPath}/${reportsDir}`; } else { // Default path for no plan or suggested (branch-matched) plans reportPath = `${plansDir}/${reportsDir}`; } // Return absolute path if baseDir provided // Guard: if reportPath is already absolute (Issue #335 made planPath absolute), // don't double-join with baseDir — path.join concatenates, not resolves if (baseDir) { return path.isAbsolute(reportPath) ? reportPath : path.join(baseDir, reportPath); } return reportPath + '/'; } /** * Format issue ID with prefix */ function formatIssueId(issueId, planConfig) { if (!issueId) return null; return planConfig.issuePrefix ? `${planConfig.issuePrefix}${issueId}` : `#${issueId}`; } /** * Extract issue ID from branch name */ function extractIssueFromBranch(branch) { if (!branch) return null; const patterns = [ /(?:issue|gh|fix|feat|bug)[/-]?(\d+)/i, /[/-](\d+)[/-]/, /#(\d+)/ ]; for (const pattern of patterns) { const match = branch.match(pattern); if (match) return match[1]; } return null; } /** * Format date according to dateFormat config * Supports: YYMMDD, YYMMDD-HHmm, YYYYMMDD, etc. * @param {string} format - Date format string * @returns {string} Formatted date */ function formatDate(format) { const now = new Date(); const pad = (n, len = 2) => String(n).padStart(len, '0'); const tokens = { 'YYYY': now.getFullYear(), 'YY': String(now.getFullYear()).slice(-2), 'MM': pad(now.getMonth() + 1), 'DD': pad(now.getDate()), 'HH': pad(now.getHours()), 'mm': pad(now.getMinutes()), 'ss': pad(now.getSeconds()) }; let result = format; for (const [token, value] of Object.entries(tokens)) { result = result.replace(token, value); } return result; } /** * Validate naming pattern result * Ensures pattern resolves to a usable directory name * * @param {string} pattern - Resolved naming pattern * @returns {{ valid: boolean, error?: string }} Validation result */ function validateNamingPattern(pattern) { if (!pattern || typeof pattern !== 'string') { return { valid: false, error: 'Pattern is empty or not a string' }; } // After removing {slug} placeholder, should still have content const withoutSlug = pattern.replace(/\{slug\}/g, '').replace(/-+/g, '-').replace(/^-|-$/g, ''); if (!withoutSlug) { return { valid: false, error: 'Pattern resolves to empty after removing {slug}' }; } // Check for remaining unresolved placeholders (besides {slug}) const unresolvedMatch = withoutSlug.match(/\{[^}]+\}/); if (unresolvedMatch) { return { valid: false, error: `Unresolved placeholder: ${unresolvedMatch[0]}` }; } // Pattern must contain {slug} for agents to substitute if (!pattern.includes('{slug}')) { return { valid: false, error: 'Pattern must contain {slug} placeholder' }; } return { valid: true }; } /** * Resolve naming pattern with date and optional issue prefix * Keeps {slug} as placeholder for agents to substitute * * Example: namingFormat="{date}-{issue}-{slug}", dateFormat="YYMMDD-HHmm", issue="GH-88" * Returns: "251212-1830-GH-88-{slug}" (if issue exists) * Returns: "251212-1830-{slug}" (if no issue) * * @param {Object} planConfig - Plan configuration * @param {string|null} gitBranch - Current git branch (for issue extraction) * @returns {string} Resolved naming pattern with {slug} placeholder */ function resolveNamingPattern(planConfig, gitBranch) { const { namingFormat, dateFormat, issuePrefix } = planConfig; const formattedDate = formatDate(dateFormat); // Try to extract issue ID from branch name const issueId = extractIssueFromBranch(gitBranch); const fullIssue = issueId && issuePrefix ? `${issuePrefix}${issueId}` : null; // Build pattern by substituting {date} and {issue}, keep {slug} let pattern = namingFormat; pattern = pattern.replace('{date}', formattedDate); if (fullIssue) { pattern = pattern.replace('{issue}', fullIssue); } else { // Remove {issue} and any trailing/leading dash pattern = pattern.replace(/-?\{issue\}-?/, '-').replace(/--+/g, '-'); } // Clean up the result: // - Remove leading/trailing hyphens // - Collapse multiple hyphens (except around {slug}) pattern = pattern .replace(/^-+/, '') // Remove leading hyphens .replace(/-+$/, '') // Remove trailing hyphens .replace(/-+(\{slug\})/g, '-$1') // Single hyphen before {slug} .replace(/(\{slug\})-+/g, '$1-') // Single hyphen after {slug} .replace(/--+/g, '-'); // Collapse other multiple hyphens // Validate the resulting pattern const validation = validateNamingPattern(pattern); if (!validation.valid) { // Log warning but return pattern anyway (fail-safe) if (process.env.CK_DEBUG) { console.error(`[ck-config] Warning: ${validation.error}`); } } return pattern; } /** * Get current git branch (safe execution) * @param {string|null} cwd - Working directory to run git command from (optional) * @returns {string|null} Current branch name or null */ function getGitBranch(cwd = null) { return execSafe('git branch --show-current', { cwd: cwd || undefined }); } /** * Get git repository root directory * @param {string|null} cwd - Working directory to run git command from (optional) * @returns {string|null} Git root absolute path or null if not in git repo */ function getGitRoot(cwd = null) { return execSafe('git rev-parse --show-toplevel', { cwd: cwd || undefined }); } /** * Extract task list ID from plan resolution for Claude Code Tasks coordination * Only returns ID for session-resolved plans (explicitly active, not branch-suggested) * * Cross-platform: path.basename() handles both Unix/Windows separators * * @param {{ path: string|null, resolvedBy: 'session'|'branch'|null }} resolved - Plan resolution result * @returns {string|null} Task list ID (plan directory name) or null */ function extractTaskListId(resolved) { if (!resolved || resolved.resolvedBy !== 'session' || !resolved.path) { return null; } return path.basename(resolved.path); } /** * Check if a hook is enabled in config * Returns true if hook is not defined (default enabled) * * @param {string} hookName - Hook name (script basename without .cjs) * @returns {boolean} Whether hook is enabled */ function isHookEnabled(hookName) { const config = loadConfig({ includeProject: false, includeAssertions: false, includeLocale: false }); const hooks = config.hooks || {}; // Return true if undefined (default enabled), otherwise return the boolean value return hooks[hookName] !== false; } module.exports = { CONFIG_PATH, LOCAL_CONFIG_PATH, GLOBAL_CONFIG_PATH, DEFAULT_CONFIG, INVALID_FILENAME_CHARS, deepMerge, loadConfigFromPath, loadConfig, normalizePath, isAbsolutePath, sanitizePath, sanitizeSlug, sanitizeConfig, escapeShellValue, writeEnv, getSessionTempPath, readSessionState, writeSessionState, updateSessionState, resolvePlanPath, extractSlugFromBranch, findMostRecentPlan, getReportsPath, formatIssueId, extractIssueFromBranch, formatDate, validateNamingPattern, resolveNamingPattern, getGitBranch, getGitRoot, extractTaskListId, isHookEnabled };