This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,926 @@
/**
* 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
};

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env node
'use strict';
/**
* ANSI Terminal Colors - Cross-platform color support for statusline
* Supports NO_COLOR, FORCE_COLOR, COLORTERM auto-detection
* @module colors
*/
// ANSI escape codes (standard + bright palette)
const RESET = '\x1b[0m';
const DIM = '\x1b[2m';
const CLEAR_INTENSITY = '\x1b[22m';
const CLEAR_FOREGROUND = '\x1b[39m';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const BLUE = '\x1b[34m';
const MAGENTA = '\x1b[35m';
const CYAN = '\x1b[36m';
const BRIGHT_RED = '\x1b[91m';
const BRIGHT_GREEN = '\x1b[92m';
const BRIGHT_YELLOW = '\x1b[93m';
const BRIGHT_BLUE = '\x1b[94m';
const BRIGHT_MAGENTA = '\x1b[95m';
const BRIGHT_CYAN = '\x1b[96m';
const BRIGHT_WHITE = '\x1b[97m';
const STABLE_PREFIX = `${CLEAR_INTENSITY}${CLEAR_FOREGROUND}`;
const STABLE_SUFFIX = `${RESET}${CLEAR_INTENSITY}${CLEAR_FOREGROUND}`;
const COLOR_CODES = {
green: GREEN,
yellow: YELLOW,
red: RED,
blue: BLUE,
cyan: CYAN,
magenta: MAGENTA,
dim: DIM,
brightRed: BRIGHT_RED,
brightGreen: BRIGHT_GREEN,
brightYellow: BRIGHT_YELLOW,
brightBlue: BRIGHT_BLUE,
brightMagenta: BRIGHT_MAGENTA,
brightCyan: BRIGHT_CYAN,
brightWhite: BRIGHT_WHITE,
};
// Detect color support at module load (cached)
// Claude Code statusline runs via pipe but output displays in TTY - default to true
const shouldUseColor = (() => {
if (process.env.NO_COLOR) return false;
if (process.env.FORCE_COLOR) return true;
// Default true for statusline context (Claude Code handles TTY display)
return true;
})();
// Mutable override (set by statusline.cjs from config)
// null = use env detection, true/false = explicit override
let _colorOverride = null;
/**
* Set explicit color enable/disable override (from config)
* Pass null to revert to env-var detection
* @param {boolean} enabled
*/
function setColorEnabled(enabled) {
_colorOverride = enabled;
}
/**
* Determine if colors should be rendered, respecting env vars and config override
* NO_COLOR env var always takes precedence over config override
* @returns {boolean}
*/
function isColorEnabled() {
// NO_COLOR env var is a hard override that always wins
if (process.env.NO_COLOR) return false;
if (_colorOverride !== null) return _colorOverride;
return shouldUseColor;
}
// Detect 256-color support via COLORTERM
const has256Color = (() => {
const ct = process.env.COLORTERM;
return ct === 'truecolor' || ct === '24bit' || ct === '256color';
})();
/**
* Wrap text with ANSI color code
* @param {string} text - Text to colorize
* @param {string} code - ANSI escape code
* @returns {string} Colorized text or plain text if colors disabled
*/
function colorize(text, code) {
if (!isColorEnabled() || !code) return String(text);
return `${STABLE_PREFIX}${code}${text}${STABLE_SUFFIX}`;
}
function green(text) { return colorize(text, GREEN); }
function yellow(text) { return colorize(text, YELLOW); }
function red(text) { return colorize(text, RED); }
function blue(text) { return colorize(text, BLUE); }
function cyan(text) { return colorize(text, CYAN); }
function magenta(text) { return colorize(text, MAGENTA); }
function dim(text) { return colorize(text, DIM); }
function brightRed(text) { return colorize(text, BRIGHT_RED); }
function brightGreen(text) { return colorize(text, BRIGHT_GREEN); }
function brightYellow(text) { return colorize(text, BRIGHT_YELLOW); }
function brightBlue(text) { return colorize(text, BRIGHT_BLUE); }
function brightMagenta(text) { return colorize(text, BRIGHT_MAGENTA); }
function brightCyan(text) { return colorize(text, BRIGHT_CYAN); }
function brightWhite(text) { return colorize(text, BRIGHT_WHITE); }
/**
* Get color code based on context percentage threshold
* @param {number} percent - Context usage percentage (0-100)
* @returns {string} ANSI color code
*/
function resolveColorCode(colorName) {
if (colorName === 'white' || colorName === 'none' || colorName === 'default') return '';
return COLOR_CODES[colorName] || '';
}
function getContextColor(percent, palette = {}) {
const high = resolveColorCode(palette.high || 'red') || RED;
const mid = resolveColorCode(palette.mid || 'yellow') || YELLOW;
const low = resolveColorCode(palette.low || 'green') || GREEN;
if (percent >= 85) return high;
if (percent >= 70) return mid;
return low;
}
/**
* Generate colored progress bar for context window
* Uses ▰▱ characters (smooth horizontal rectangles) for consistent rendering
* @param {number} percent - Usage percentage (0-100)
* @param {number} width - Bar width in characters (default 12)
* @returns {string} Unicode progress bar with threshold-based colors
*/
function coloredBar(percent, width = 12, palette = {}) {
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.round((clamped / 100) * width);
const empty = width - filled;
if (!isColorEnabled()) {
return '▰'.repeat(filled) + '▱'.repeat(empty);
}
const color = getContextColor(percent, palette);
return `${STABLE_PREFIX}${color}${'▰'.repeat(filled)}${STABLE_PREFIX}${DIM}${'▱'.repeat(empty)}${STABLE_SUFFIX}`;
}
/**
* Resolve a color name from theme config to its color function.
* Used by section renderers to apply theme-configurable colors.
* Falls back to identity function (no color) for unknown names.
* @param {string} colorName - Color name (e.g. "green", "yellow", "dim")
* @returns {Function} Color function (string) => string
*/
function resolveColor(colorName) {
const code = resolveColorCode(colorName);
return code ? (s) => colorize(s, code) : (s) => String(s);
}
module.exports = {
RESET,
green,
yellow,
red,
cyan,
magenta,
dim,
getContextColor,
coloredBar,
shouldUseColor,
has256Color,
setColorEnabled,
isColorEnabled,
resolveColorCode,
resolveColor,
};

View File

@@ -0,0 +1,842 @@
#!/usr/bin/env node
/**
* context-builder.cjs - Context/reminder building for session injection
*
* Extracted from dev-rules-reminder.cjs for reuse in both Claude hooks and OpenCode plugins.
* Builds session context, rules, paths, and plan information.
*
* @module context-builder
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');
// Usage cache file path (written by usage-context-awareness.cjs hook)
const USAGE_CACHE_FILE = path.join(os.tmpdir(), 'ck-usage-limits-cache.json');
const RECENT_INJECTION_TTL_MS = 5 * 60 * 1000;
const PENDING_INJECTION_TTL_MS = 30 * 1000;
const WARN_THRESHOLD = 70;
const CRITICAL_THRESHOLD = 90;
const {
loadConfig,
resolvePlanPath,
getReportsPath,
resolveNamingPattern,
normalizePath,
getGitBranch,
readSessionState,
updateSessionState
} = require('./ck-config-utils.cjs');
function execSafe(cmd) {
try {
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch {
return null;
}
}
/**
* Resolve rules file path (local or global) with backward compat
* @param {string} filename - Rules filename
* @param {string} [configDirName='.claude'] - Config directory name
* @returns {string|null} Resolved path or null
*/
function resolveRulesPath(filename, configDirName = '.claude') {
// Try rules/ first (new location)
const localRulesPath = path.join(process.cwd(), configDirName, 'rules', filename);
const globalRulesPath = path.join(os.homedir(), '.claude', 'rules', filename);
if (fs.existsSync(localRulesPath)) return `${configDirName}/rules/${filename}`;
if (fs.existsSync(globalRulesPath)) return `~/.opencode/rules/${filename}`;
// Backward compat: try workflows/ (legacy location)
const localWorkflowsPath = path.join(process.cwd(), configDirName, 'workflows', filename);
const globalWorkflowsPath = path.join(os.homedir(), '.claude', 'workflows', filename);
if (fs.existsSync(localWorkflowsPath)) return `${configDirName}/workflows/${filename}`;
if (fs.existsSync(globalWorkflowsPath)) return `~/.opencode/workflows/${filename}`;
return null;
}
/**
* Resolve script file path (local or global)
* @param {string} filename - Script filename
* @param {string} [configDirName='.claude'] - Config directory name
* @returns {string|null} Resolved path or null
*/
function resolveScriptPath(filename, configDirName = '.claude') {
const localPath = path.join(process.cwd(), configDirName, 'scripts', filename);
const globalPath = path.join(os.homedir(), '.claude', 'scripts', filename);
if (fs.existsSync(localPath)) return `${configDirName}/scripts/${filename}`;
if (fs.existsSync(globalPath)) return `~/.opencode/scripts/${filename}`;
return null;
}
/**
* Resolve skills venv Python path (local or global)
* @param {string} [configDirName='.claude'] - Config directory name
* @returns {string|null} Resolved venv Python path or null
*/
function resolveSkillsVenv(configDirName = '.claude') {
const isWindows = process.platform === 'win32';
const venvBin = isWindows ? 'Scripts' : 'bin';
const pythonExe = isWindows ? 'python.exe' : 'python3';
const localVenv = path.join(process.cwd(), configDirName, 'skills', '.venv', venvBin, pythonExe);
const globalVenv = path.join(os.homedir(), '.claude', 'skills', '.venv', venvBin, pythonExe);
if (fs.existsSync(localVenv)) {
return isWindows
? `${configDirName}\\skills\\.venv\\Scripts\\python.exe`
: `${configDirName}/skills/.venv/bin/python3`;
}
if (fs.existsSync(globalVenv)) {
return isWindows
? '~\\.claude\\skills\\.venv\\Scripts\\python.exe'
: '~/.opencode/skills/.venv/bin/python3';
}
return null;
}
/**
* Build plan context from config and git info
* @param {string|null} sessionId - Session ID
* @param {Object} config - Loaded config
* @returns {Object} Plan context object
*/
function buildPlanContext(sessionId, config) {
const { plan, paths } = config;
const gitBranch = getGitBranch();
const resolved = resolvePlanPath(sessionId, config);
const reportsPath = getReportsPath(resolved.path, resolved.resolvedBy, plan, paths);
// Compute naming pattern directly for reliable injection
const namePattern = resolveNamingPattern(plan, gitBranch);
const planLine = resolved.resolvedBy === 'session'
? `- Plan: ${resolved.path}`
: resolved.resolvedBy === 'branch'
? `- Plan: none | Suggested: ${resolved.path}`
: `- Plan: none`;
// Validation config (injected so LLM can reference it)
const validation = plan.validation || {};
const validationMode = validation.mode || 'prompt';
const validationMin = validation.minQuestions || 3;
const validationMax = validation.maxQuestions || 8;
return { reportsPath, gitBranch, planLine, namePattern, validationMode, validationMin, validationMax };
}
/**
* Build a scope key for reminder dedup so cwd-sensitive output can re-inject when needed.
* @param {Object} params
* @param {string} [params.baseDir] - Working directory for the hook invocation
* @returns {string} Stable scope key
*/
function buildInjectionScopeKey({ baseDir } = {}) {
const cwdKey = normalizePath(path.resolve(baseDir || process.cwd())) || process.cwd();
return cwdKey;
}
function parseTimestamp(value) {
if (typeof value === 'number') return value;
if (typeof value === 'string') return Date.parse(value);
return NaN;
}
function getReminderScopeState(reminderState, scopeKey) {
const scopes = reminderState?.scopes;
if (!scopes || typeof scopes !== 'object') return null;
const scopeState = scopes[scopeKey];
return scopeState && typeof scopeState === 'object' ? scopeState : null;
}
function hasRecentInjection(scopeState, now = Date.now()) {
const injectedTs = parseTimestamp(scopeState?.lastInjectedAt);
return Number.isFinite(injectedTs) && now - injectedTs < RECENT_INJECTION_TTL_MS;
}
function hasPendingInjection(scopeState, now = Date.now()) {
const pendingTs = parseTimestamp(scopeState?.pendingAt);
return Number.isFinite(pendingTs) && now - pendingTs < PENDING_INJECTION_TTL_MS;
}
function pruneReminderScopes(scopes, now = Date.now()) {
const nextScopes = {};
for (const [scopeKey, scopeState] of Object.entries(scopes || {})) {
if (!scopeState || typeof scopeState !== 'object') continue;
if (hasRecentInjection(scopeState, now) || hasPendingInjection(scopeState, now)) {
nextScopes[scopeKey] = scopeState;
}
}
return nextScopes;
}
function wasTranscriptRecentlyInjected(transcriptPath, scopeKey = null) {
try {
if (!transcriptPath || !fs.existsSync(transcriptPath)) return false;
const tail = fs.readFileSync(transcriptPath, 'utf-8').split('\n').slice(-150);
const hasReminderMarker = tail.some(line => line.includes('[IMPORTANT] Consider Modularization'));
if (!hasReminderMarker) return false;
if (!scopeKey) return true;
// The reminder output is cwd-sensitive; only treat transcript fallback as a match
// when the same cwd-specific session lines were already injected recently.
return tail.some(line => line === `- CWD: ${scopeKey}` || line === `- Working directory: ${scopeKey}`);
} catch {
return false;
}
}
/**
* Check if context was recently injected (prevent duplicate injection).
* Uses session-scoped markers when a session ID is available, otherwise falls back to transcript scan.
* @param {string} transcriptPath - Path to transcript file
* @param {string|null} [sessionId] - Session identifier for temp-state dedup
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @returns {boolean} true if recently injected
*/
function wasRecentlyInjected(transcriptPath, sessionId = null, scopeKey = 'session') {
try {
if (sessionId) {
const reminderState = readSessionState(sessionId)?.devRulesReminder;
if (hasRecentInjection(getReminderScopeState(reminderState, scopeKey))) {
return true;
}
}
return wasTranscriptRecentlyInjected(transcriptPath, scopeKey);
} catch {
return false;
}
}
/**
* Reserve an injection slot atomically so concurrent hooks do not double-inject.
* @param {string|null} sessionId - Session identifier
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @param {string|null} [transcriptPath] - Transcript path for legacy fallback when no session ID exists
* @returns {{ shouldInject: boolean, reserved: boolean }} Whether to inject and whether a pending reservation was written
*/
function reserveInjectionScope(sessionId, scopeKey = 'session', transcriptPath = null) {
const transcriptAlreadyInjected = wasTranscriptRecentlyInjected(transcriptPath, scopeKey);
if (!sessionId) {
return {
shouldInject: !transcriptAlreadyInjected,
reserved: false
};
}
try {
let shouldInject = false;
const now = Date.now();
const updated = updateSessionState(sessionId, (state) => {
const reminderState = state.devRulesReminder && typeof state.devRulesReminder === 'object'
? state.devRulesReminder
: {};
const scopes = pruneReminderScopes(reminderState.scopes, now);
const scopeState = getReminderScopeState({ scopes }, scopeKey) || {};
if (hasRecentInjection(scopeState, now) || hasPendingInjection(scopeState, now)) {
return state;
}
if (transcriptAlreadyInjected) {
scopes[scopeKey] = {
...scopeState,
lastInjectedAt: new Date(now).toISOString()
};
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
}
shouldInject = true;
scopes[scopeKey] = {
...scopeState,
pendingAt: new Date(now).toISOString()
};
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
});
if (!updated) {
return {
shouldInject: !transcriptAlreadyInjected,
reserved: false
};
}
return { shouldInject, reserved: shouldInject };
} catch {
return {
shouldInject: !transcriptAlreadyInjected,
reserved: false
};
}
}
/**
* Persist a recent injection marker for the current session and clear the pending reservation.
* @param {string|null} sessionId - Session identifier
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @returns {boolean} true when the marker is written
*/
function markRecentlyInjected(sessionId, scopeKey = 'session') {
if (!sessionId) return false;
try {
return updateSessionState(sessionId, (state) => {
const reminderState = state.devRulesReminder && typeof state.devRulesReminder === 'object'
? state.devRulesReminder
: {};
const scopes = pruneReminderScopes(reminderState.scopes);
const scopeState = getReminderScopeState({ scopes }, scopeKey) || {};
scopes[scopeKey] = {
...scopeState,
lastInjectedAt: new Date().toISOString()
};
delete scopes[scopeKey].pendingAt;
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
});
} catch {
return false;
}
}
/**
* Clear a pending reservation when the hook fails after reserving a slot.
* @param {string|null} sessionId - Session identifier
* @param {string|null} [scopeKey='session'] - Scope key for cwd/transcript-aware dedup
* @returns {boolean} true when cleanup succeeds
*/
function clearPendingInjection(sessionId, scopeKey = 'session') {
if (!sessionId) return false;
try {
return updateSessionState(sessionId, (state) => {
const reminderState = state.devRulesReminder && typeof state.devRulesReminder === 'object'
? state.devRulesReminder
: {};
const scopes = pruneReminderScopes(reminderState.scopes);
const scopeState = getReminderScopeState({ scopes }, scopeKey);
if (!scopeState || !scopeState.pendingAt) {
return state;
}
const nextScopeState = { ...scopeState };
delete nextScopeState.pendingAt;
if (Object.keys(nextScopeState).length === 0) {
delete scopes[scopeKey];
} else {
scopes[scopeKey] = nextScopeState;
}
return {
...state,
devRulesReminder: {
...reminderState,
scopes
}
};
});
} catch {
return false;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SECTION BUILDERS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Build language section
* @param {Object} params
* @param {string} [params.thinkingLanguage] - Language for thinking
* @param {string} [params.responseLanguage] - Language for response
* @returns {string[]} Lines for language section
*/
function buildLanguageSection({ thinkingLanguage, responseLanguage }) {
// Auto-default thinkingLanguage to 'en' when only responseLanguage is set
const effectiveThinking = thinkingLanguage || (responseLanguage ? 'en' : null);
const hasThinking = effectiveThinking && effectiveThinking !== responseLanguage;
const hasResponse = responseLanguage;
const lines = [];
if (hasThinking || hasResponse) {
lines.push(`## Language`);
if (hasThinking) {
lines.push(`- Thinking: Use ${effectiveThinking} for reasoning (logic, precision).`);
}
if (hasResponse) {
lines.push(`- Response: Respond in ${responseLanguage} (natural, fluent).`);
}
lines.push(``);
}
return lines;
}
/**
* Build session section
* @param {Object} [staticEnv] - Pre-computed static environment info
* @returns {string[]} Lines for session section
*/
function buildSessionSection(staticEnv = {}) {
const memUsed = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
const memTotal = Math.round(os.totalmem() / 1024 / 1024);
const memPercent = Math.round((memUsed / memTotal) * 100);
const cpuUsage = Math.round((process.cpuUsage().user / 1000000) * 100);
const cpuSystem = Math.round((process.cpuUsage().system / 1000000) * 100);
return [
`## Session`,
`- DateTime: ${new Date().toLocaleString()}`,
`- CWD: ${staticEnv.cwd || process.cwd()}`,
`- Timezone: ${staticEnv.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}`,
`- Working directory: ${staticEnv.cwd || process.cwd()}`,
`- OS: ${staticEnv.osPlatform || process.platform}`,
`- User: ${staticEnv.user || process.env.USERNAME || process.env.USER}`,
`- Locale: ${staticEnv.locale || process.env.LANG || ''}`,
`- Memory usage: ${memUsed}MB/${memTotal}MB (${memPercent}%)`,
`- CPU usage: ${cpuUsage}% user / ${cpuSystem}% system`,
`- Spawning multiple subagents can cause performance issues, spawn and delegate tasks intelligently based on the available system resources.`,
`- Remember that each subagent only has 200K tokens in context window, spawn and delegate tasks intelligently to make sure their context windows don't get bloated.`,
`- IMPORTANT: Include these environment information when prompting subagents to perform tasks.`,
``
];
}
/**
* Read usage limits from cache file (written by usage-context-awareness.cjs)
* @returns {Object|null} Usage data or null if unavailable
*/
function readUsageCache() {
try {
if (fs.existsSync(USAGE_CACHE_FILE)) {
const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf-8'));
// Cache is valid for 5 minutes for injection purposes
if (Date.now() - cache.timestamp < 300000 && cache.data) {
return cache.data;
}
}
} catch { }
return null;
}
/**
* Format time until reset
* @param {string} resetAt - ISO timestamp
* @returns {string|null} Formatted time or null
*/
function formatTimeUntilReset(resetAt) {
if (!resetAt) return null;
const resetTime = new Date(resetAt);
const remaining = Math.floor(resetTime.getTime() / 1000) - Math.floor(Date.now() / 1000);
if (remaining <= 0 || remaining > 18000) return null; // Only show if < 5 hours
const hours = Math.floor(remaining / 3600);
const mins = Math.floor((remaining % 3600) / 60);
return `${hours}h ${mins}m`;
}
/**
* Format percentage with warning level
* @param {number} value - Percentage value
* @param {string} label - Label prefix
* @returns {string} Formatted string with warning if applicable
*/
function formatUsagePercent(value, label) {
const pct = Math.round(value);
if (pct >= CRITICAL_THRESHOLD) return `${label}: ${pct}% [CRITICAL]`;
if (pct >= WARN_THRESHOLD) return `${label}: ${pct}% [WARNING]`;
return `${label}: ${pct}%`;
}
/**
* Build context window section from statusline cache
* @param {string} sessionId - Session ID
* @returns {string[]} Lines for context section
*/
function buildContextSection(sessionId) {
// TEMPORARILY DISABLED
return [];
if (!sessionId) return [];
// RE-ENABLED IF NEEDED IN THE FUTURE
try {
const contextPath = path.join(os.tmpdir(), `ck-context-${sessionId}.json`);
if (!fs.existsSync(contextPath)) return [];
const data = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
// Only use fresh data (< 5 min old - statusline updates every 300ms when active)
if (Date.now() - data.timestamp > 300000) return [];
const lines = [`## Current Session's Context`];
// Format: 48% used (96K/200K tokens)
const usedK = Math.round(data.tokens / 1000);
const sizeK = Math.round(data.size / 1000);
lines.push(`- Context: ${data.percent}% used (${usedK}K/${sizeK}K tokens)`);
lines.push(`- **NOTE:** Optimize the workflow for token efficiency`);
// Warning if high usage
if (data.percent >= CRITICAL_THRESHOLD) {
lines.push(`- **CRITICAL:** Context nearly full. Before compaction hits:`);
lines.push(` 1. Update TodoWrite with current progress (completed + remaining)`);
lines.push(` 2. Be extremely concise — no verbose explanations`);
lines.push(` 3. Session state will auto-restore after compaction`);
} else if (data.percent >= WARN_THRESHOLD) {
lines.push(`- **WARNING:** Context usage moderate - be concise, optimize token efficiency, keep tool outputs short.`);
}
lines.push(``);
return lines;
} catch {
return [];
}
}
/**
* Build usage section from cache
* @returns {string[]} Lines for usage section
*/
function buildUsageSection() {
// TEMPORARILY DISABLED
return [];
// RE-ENABLED IF NEEDED IN THE FUTURE
const usage = readUsageCache();
if (!usage) return [];
const lines = [];
const parts = [];
// 5-hour limit
if (usage.five_hour) {
const util = usage.five_hour.utilization;
if (typeof util === 'number') {
parts.push(formatUsagePercent(util, '5h'));
}
const timeLeft = formatTimeUntilReset(usage.five_hour.resets_at);
if (timeLeft) {
parts.push(`resets in ${timeLeft}`);
}
}
// 7-day limit
if (usage.seven_day?.utilization != null) {
parts.push(formatUsagePercent(usage.seven_day.utilization, '7d'));
}
if (parts.length > 0) {
lines.push(`## Usage Limits`);
lines.push(`- ${parts.join(' | ')}`);
lines.push(``);
}
return lines;
}
/**
* Build rules section
* @param {Object} params
* @param {string} [params.devRulesPath] - Path to dev rules
* @param {string} [params.skillsVenv] - Path to skills venv
* @param {string} [params.plansPath] - Absolute plans path (Issue #476: prevents wrong subdirectory creation)
* @param {string} [params.docsPath] - Absolute docs path
* @returns {string[]} Lines for rules section
*/
function buildRulesSection({ devRulesPath, skillsVenv, plansPath, docsPath }) {
const lines = [`## Rules`];
if (devRulesPath) {
lines.push(`- Read and follow development rules: "${devRulesPath}"`);
}
// Issue #476: Use absolute paths to prevent LLM confusion in multi-CLAUDE.md projects
const plansRef = plansPath || 'plans';
const docsRef = docsPath || 'docs';
lines.push(`- Markdown files are organized in: Plans → "${plansRef}" directory, Docs → "${docsRef}" directory`);
lines.push(`- **IMPORTANT:** DO NOT create markdown files outside of "${plansRef}" or "${docsRef}" UNLESS the user explicitly requests it.`);
if (skillsVenv) {
lines.push(`- Python scripts in .opencode/skills/: Use \`${skillsVenv}\``);
}
lines.push(`- When skills' scripts are failed to execute, always fix them and run again, repeat until success.`);
lines.push(`- Follow **YAGNI (You Aren't Gonna Need It) - KISS (Keep It Simple, Stupid) - DRY (Don't Repeat Yourself)** principles`);
lines.push(`- Sacrifice grammar for the sake of concision when writing reports.`);
lines.push(`- In reports, list any unresolved questions at the end, if any.`);
lines.push(`- IMPORTANT: Ensure token consumption efficiency while maintaining high quality.`);
lines.push(``);
return lines;
}
/**
* Build modularization section
* @returns {string[]} Lines for modularization section
*/
function buildModularizationSection() {
return [
`## **[IMPORTANT] Consider Modularization:**`,
`- Check existing modules before creating new`,
`- Analyze logical separation boundaries (functions, classes, concerns)`,
`- Prefer kebab-case for JS/TS/Python/shell; respect language conventions (C#/Java use PascalCase, Go/Rust use snake_case)`,
`- Write descriptive code comments`,
`- After modularization, continue with main task`,
`- When not to modularize: Markdown files, plain text files, bash scripts, configuration files, environment variables files, etc.`,
``
];
}
/**
* Build paths section
* @param {Object} params
* @param {string} params.reportsPath - Reports path
* @param {string} params.plansPath - Plans path
* @param {string} params.docsPath - Docs path
* @param {number} [params.docsMaxLoc=800] - Max lines of code for docs
* @returns {string[]} Lines for paths section
*/
function buildPathsSection({ reportsPath, plansPath, docsPath, docsMaxLoc = 800 }) {
return [
`## Paths`,
`Reports: ${reportsPath} | Plans: ${plansPath}/ | Docs: ${docsPath}/ | docs.maxLoc: ${docsMaxLoc}`,
``
];
}
/**
* Build plan context section
* @param {Object} params
* @param {string} params.planLine - Plan status line
* @param {string} params.reportsPath - Reports path
* @param {string} [params.gitBranch] - Git branch
* @param {string} params.validationMode - Validation mode
* @param {number} params.validationMin - Min questions
* @param {number} params.validationMax - Max questions
* @returns {string[]} Lines for plan context section
*/
function buildPlanContextSection({ planLine, reportsPath, gitBranch, validationMode, validationMin, validationMax }) {
const lines = [
`## Plan Context`,
planLine,
`- Reports: ${reportsPath}`
];
if (gitBranch) {
lines.push(`- Branch: ${gitBranch}`);
}
lines.push(`- Validation: mode=${validationMode}, questions=${validationMin}-${validationMax}`);
lines.push(``);
return lines;
}
/**
* Build naming section
* @param {Object} params
* @param {string} params.reportsPath - Reports path
* @param {string} params.plansPath - Plans path
* @param {string} params.namePattern - Naming pattern
* @returns {string[]} Lines for naming section
*/
function buildNamingSection({ reportsPath, plansPath, namePattern }) {
return [
`## Naming`,
`- Report: \`${reportsPath}{type}-${namePattern}.md\``,
`- Plan dir: \`${plansPath}/${namePattern}/\``,
`- Replace \`{type}\` with: agent name, report type, or context`,
`- Replace \`{slug}\` in pattern with: descriptive-kebab-slug`
];
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Build full reminder content from all sections
* @param {Object} params - All parameters for building reminder
* @returns {string[]} Array of lines
*/
function buildReminder(params) {
const {
sessionId,
thinkingLanguage,
responseLanguage,
devRulesPath,
skillsVenv,
reportsPath,
plansPath,
docsPath,
docsMaxLoc,
planLine,
gitBranch,
namePattern,
validationMode,
validationMin,
validationMax,
staticEnv,
hooks
} = params;
// Respect hooks config — skip sections when their corresponding hook is disabled
const hooksConfig = hooks || {};
const contextEnabled = hooksConfig['context-tracking'] !== false;
const usageEnabled = hooksConfig['usage-context-awareness'] !== false;
return [
...buildLanguageSection({ thinkingLanguage, responseLanguage }),
...buildSessionSection(staticEnv),
...(contextEnabled ? buildContextSection(sessionId) : []),
...(usageEnabled ? buildUsageSection() : []),
...buildRulesSection({ devRulesPath, skillsVenv, plansPath, docsPath }),
...buildModularizationSection(),
...buildPathsSection({ reportsPath, plansPath, docsPath, docsMaxLoc }),
...buildPlanContextSection({ planLine, reportsPath, gitBranch, validationMode, validationMin, validationMax }),
...buildNamingSection({ reportsPath, plansPath, namePattern })
];
}
/**
* Build complete reminder context (unified entry point for plugins)
*
* @param {Object} [params]
* @param {string} [params.sessionId] - Session ID
* @param {Object} [params.config] - CK config (auto-loaded if not provided)
* @param {Object} [params.staticEnv] - Pre-computed static environment info
* @param {string} [params.configDirName='.claude'] - Config directory name
* @param {string} [params.baseDir] - Base directory for absolute path resolution (Issue #327)
* @returns {{
* content: string,
* lines: string[],
* sections: Object
* }}
*/
function buildReminderContext({ sessionId, config, staticEnv, configDirName = '.claude', baseDir } = {}) {
// Load config if not provided
const cfg = config || loadConfig({ includeProject: false, includeAssertions: false });
// Resolve paths
const devRulesPath = resolveRulesPath('development-rules.md', configDirName);
const skillsVenv = resolveSkillsVenv(configDirName);
// Build plan context
const planCtx = buildPlanContext(sessionId, cfg);
// Issue #327: Use baseDir for absolute path resolution (subdirectory workflow support)
// If baseDir provided, resolve paths as absolute; otherwise use relative paths
const effectiveBaseDir = baseDir || null;
const plansPathRel = normalizePath(cfg.paths?.plans) || 'plans';
const docsPathRel = normalizePath(cfg.paths?.docs) || 'docs';
// Build all parameters with absolute paths if baseDir provided
const params = {
sessionId,
thinkingLanguage: cfg.locale?.thinkingLanguage,
responseLanguage: cfg.locale?.responseLanguage,
devRulesPath,
skillsVenv,
reportsPath: effectiveBaseDir ? path.join(effectiveBaseDir, planCtx.reportsPath) : planCtx.reportsPath,
plansPath: effectiveBaseDir ? path.join(effectiveBaseDir, plansPathRel) : plansPathRel,
docsPath: effectiveBaseDir ? path.join(effectiveBaseDir, docsPathRel) : docsPathRel,
docsMaxLoc: Math.max(1, parseInt(cfg.docs?.maxLoc, 10) || 800),
planLine: planCtx.planLine,
gitBranch: planCtx.gitBranch,
namePattern: planCtx.namePattern,
validationMode: planCtx.validationMode,
validationMin: planCtx.validationMin,
validationMax: planCtx.validationMax,
staticEnv,
hooks: cfg.hooks
};
const lines = buildReminder(params);
// Respect hooks config for sections object too
const hooksConfig = cfg.hooks || {};
const contextEnabled = hooksConfig['context-tracking'] !== false;
const usageEnabled = hooksConfig['usage-context-awareness'] !== false;
return {
content: lines.join('\n'),
lines,
sections: {
language: buildLanguageSection({ thinkingLanguage: params.thinkingLanguage, responseLanguage: params.responseLanguage }),
session: buildSessionSection(staticEnv),
context: contextEnabled ? buildContextSection(sessionId) : [],
usage: usageEnabled ? buildUsageSection() : [],
rules: buildRulesSection({ devRulesPath, skillsVenv, plansPath: params.plansPath, docsPath: params.docsPath }),
modularization: buildModularizationSection(),
paths: buildPathsSection({ reportsPath: params.reportsPath, plansPath: params.plansPath, docsPath: params.docsPath, docsMaxLoc: params.docsMaxLoc }),
planContext: buildPlanContextSection(planCtx),
naming: buildNamingSection({ reportsPath: params.reportsPath, plansPath: params.plansPath, namePattern: params.namePattern })
}
};
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry points
buildReminderContext,
buildReminder,
// Section builders
buildLanguageSection,
buildSessionSection,
buildContextSection,
buildUsageSection,
buildRulesSection,
buildModularizationSection,
buildPathsSection,
buildPlanContextSection,
buildNamingSection,
// Helpers
execSafe,
resolveRulesPath,
resolveScriptPath,
resolveSkillsVenv,
buildPlanContext,
buildInjectionScopeKey,
wasRecentlyInjected,
reserveInjectionScope,
markRecentlyInjected,
clearPendingInjection,
// Backward compat alias
resolveWorkflowPath: resolveRulesPath
};

View File

@@ -0,0 +1,297 @@
#!/usr/bin/env node
/**
* privacy-checker.cjs - Privacy pattern matching logic for sensitive file detection
*
* Extracted from privacy-block.cjs for reuse in both Claude hooks and OpenCode plugins.
* Pure logic module - no stdin/stdout, no exit codes.
*
* @module privacy-checker
*/
const path = require('path');
const fs = require('fs');
// ═══════════════════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════
const APPROVED_PREFIX = 'APPROVED:';
// Safe file patterns - exempt from privacy checks (documentation/template files)
const SAFE_PATTERNS = [
/\.example$/i, // .env.example, config.example
/\.sample$/i, // .env.sample
/\.template$/i, // .env.template
];
// Privacy-sensitive patterns
const PRIVACY_PATTERNS = [
/^\.env$/, // .env
/^\.env\./, // .env.local, .env.production, etc.
/\.env$/, // path/to/.env
/\/\.env\./, // path/to/.env.local
/credentials/i, // credentials.json, etc.
/secrets?\.ya?ml$/i, // secrets.yaml, secret.yml
/\.pem$/, // Private keys
/\.key$/, // Private keys
/id_rsa/, // SSH keys
/id_ed25519/, // SSH keys
];
// ═══════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if path is a safe file (example/sample/template)
* @param {string} testPath - Path to check
* @returns {boolean} true if file matches safe patterns
*/
function isSafeFile(testPath) {
if (!testPath) return false;
const basename = path.basename(testPath);
return SAFE_PATTERNS.some(p => p.test(basename));
}
/**
* Check if path has APPROVED: prefix
* @param {string} testPath - Path to check
* @returns {boolean} true if path starts with APPROVED:
*/
function hasApprovalPrefix(testPath) {
return testPath && testPath.startsWith(APPROVED_PREFIX);
}
/**
* Strip APPROVED: prefix from path
* @param {string} testPath - Path to process
* @returns {string} Path without APPROVED: prefix
*/
function stripApprovalPrefix(testPath) {
if (hasApprovalPrefix(testPath)) {
return testPath.slice(APPROVED_PREFIX.length);
}
return testPath;
}
/**
* Check if stripped path is suspicious (path traversal or absolute)
* @param {string} strippedPath - Path after stripping APPROVED: prefix
* @returns {boolean} true if path looks suspicious
*/
function isSuspiciousPath(strippedPath) {
return strippedPath.includes('..') || path.isAbsolute(strippedPath);
}
/**
* Check if path matches privacy patterns
* @param {string} testPath - Path to check
* @returns {boolean} true if path matches privacy-sensitive patterns
*/
function isPrivacySensitive(testPath) {
if (!testPath) return false;
// Strip prefix for pattern matching
const cleanPath = stripApprovalPrefix(testPath);
let normalized = cleanPath.replace(/\\/g, '/');
// Decode URI components to catch obfuscated paths (%2e = '.')
try {
normalized = decodeURIComponent(normalized);
} catch (e) {
// Invalid encoding, use as-is
}
// Check safe patterns first - exempt example/sample/template files
if (isSafeFile(normalized)) {
return false;
}
const basename = path.basename(normalized);
for (const pattern of PRIVACY_PATTERNS) {
if (pattern.test(basename) || pattern.test(normalized)) {
return true;
}
}
return false;
}
/**
* Extract paths from tool input
* @param {Object} toolInput - Tool input object with file_path, path, pattern, or command
* @returns {Array<{value: string, field: string}>} Array of extracted paths with field names
*/
function extractPaths(toolInput) {
const paths = [];
if (!toolInput) return paths;
if (toolInput.file_path) paths.push({ value: toolInput.file_path, field: 'file_path' });
if (toolInput.path) paths.push({ value: toolInput.path, field: 'path' });
if (toolInput.pattern) paths.push({ value: toolInput.pattern, field: 'pattern' });
// Check bash commands for file paths
if (toolInput.command) {
// Look for APPROVED:.env or .env patterns
const approvedMatch = toolInput.command.match(/APPROVED:[^\s]+/g) || [];
approvedMatch.forEach(p => paths.push({ value: p, field: 'command' }));
// Only look for .env if no APPROVED: version found
if (approvedMatch.length === 0) {
const envMatch = toolInput.command.match(/\.env[^\s]*/g) || [];
envMatch.forEach(p => paths.push({ value: p, field: 'command' }));
// Also check bash variable assignments (FILE=.env, ENV_FILE=.env.local)
const varAssignments = toolInput.command.match(/\w+=[^\s]*\.env[^\s]*/g) || [];
varAssignments.forEach(a => {
const value = a.split('=')[1];
if (value) paths.push({ value, field: 'command' });
});
// Check command substitution containing sensitive patterns - extract .env from inside
const cmdSubst = toolInput.command.match(/\$\([^)]*?(\.env[^\s)]*)[^)]*\)/g) || [];
for (const subst of cmdSubst) {
const inner = subst.match(/\.env[^\s)]*/);
if (inner) paths.push({ value: inner[0], field: 'command' });
}
}
}
return paths.filter(p => p.value);
}
/**
* Load .ck.json config to check if privacy block is disabled
* @param {string} [configDir] - Directory containing .ck.json (defaults to .claude in cwd)
* @returns {boolean} true if privacy block should be skipped
*/
function isPrivacyBlockDisabled(configDir) {
try {
const configPath = configDir
? path.join(configDir, '.ck.json')
: path.join(process.cwd(), '.claude', '.ck.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return config.privacyBlock === false;
} catch {
return false; // Default to enabled on error (file not found or invalid JSON)
}
}
/**
* Build prompt data for AskUserQuestion tool
* @param {string} filePath - Blocked file path
* @returns {Object} Prompt data object
*/
function buildPromptData(filePath) {
const basename = path.basename(filePath);
return {
type: 'PRIVACY_PROMPT',
file: filePath,
basename: basename,
question: {
header: 'File Access',
text: `I need to read "${basename}" which may contain sensitive data (API keys, passwords, tokens). Do you approve?`,
options: [
{ label: 'Yes, approve access', description: `Allow reading ${basename} this time` },
{ label: 'No, skip this file', description: 'Continue without accessing this file' }
]
}
};
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if a tool call accesses privacy-sensitive files
*
* @param {Object} params
* @param {string} params.toolName - Name of tool (Read, Write, Bash, etc.)
* @param {Object} params.toolInput - Tool input with file_path, path, command, etc.
* @param {Object} [params.options]
* @param {boolean} [params.options.disabled] - Skip checks if true
* @param {string} [params.options.configDir] - Directory for .ck.json config
* @param {boolean} [params.options.allowBash] - Allow Bash tool without blocking (default: true)
* @returns {{
* blocked: boolean,
* filePath?: string,
* reason?: string,
* approved?: boolean,
* isBash?: boolean,
* suspicious?: boolean,
* promptData?: Object
* }}
*/
function checkPrivacy({ toolName, toolInput, options = {} }) {
const { disabled, configDir, allowBash = true } = options;
// Check if disabled via options or config
if (disabled || isPrivacyBlockDisabled(configDir)) {
return { blocked: false };
}
const isBashTool = toolName === 'Bash';
const paths = extractPaths(toolInput);
// Check each path
for (const { value: testPath } of paths) {
if (!isPrivacySensitive(testPath)) continue;
// Check for approval prefix
if (hasApprovalPrefix(testPath)) {
const strippedPath = stripApprovalPrefix(testPath);
return {
blocked: false,
approved: true,
filePath: strippedPath,
suspicious: isSuspiciousPath(strippedPath)
};
}
// For Bash: warn but don't block (allows "Yes → bash cat" flow)
if (isBashTool && allowBash) {
return {
blocked: false,
isBash: true,
filePath: testPath,
reason: `Bash command accesses sensitive file: ${testPath}`
};
}
// Block - sensitive file without approval
return {
blocked: true,
filePath: testPath,
reason: `Sensitive file access requires user approval`,
promptData: buildPromptData(testPath)
};
}
// No sensitive paths found
return { blocked: false };
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry point
checkPrivacy,
// Helper functions (for testing and direct use)
isSafeFile,
isPrivacySensitive,
hasApprovalPrefix,
stripApprovalPrefix,
isSuspiciousPath,
extractPaths,
isPrivacyBlockDisabled,
buildPromptData,
// Constants
APPROVED_PREFIX,
SAFE_PATTERNS,
PRIVACY_PATTERNS
};

View File

@@ -0,0 +1,474 @@
#!/usr/bin/env node
/**
* project-detector.cjs - Project and environment detection logic
*
* Extracted from session-init.cjs for reuse in both Claude hooks and OpenCode plugins.
* Detects project type, package manager, framework, and runtime versions.
*
* @module project-detector
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, execFileSync } = require('child_process');
// ═══════════════════════════════════════════════════════════════════════════
// SAFE EXECUTION HELPERS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Safely execute shell command with optional timeout
* @param {string} cmd - Command to execute
* @param {number} [timeoutMs=5000] - Timeout in milliseconds
* @returns {string|null} Output or null on error
*/
function execSafe(cmd, timeoutMs = 5000) {
try {
return execSync(cmd, {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (e) {
return null;
}
}
/**
* Safely execute a binary with arguments (no shell interpolation)
* @param {string} binary - Path to the executable
* @param {string[]} args - Arguments array
* @param {number} [timeoutMs=2000] - Timeout in milliseconds
* @returns {string|null} Output or null on error
*/
function execFileSafe(binary, args, timeoutMs = 2000) {
try {
return execFileSync(binary, args, {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PYTHON DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Validate that a path is a file and doesn't contain shell metacharacters
* @param {string} p - Path to validate
* @returns {boolean}
*/
function isValidPythonPath(p) {
if (!p || typeof p !== 'string') return false;
if (/[;&|`$(){}[\]<>!#*?]/.test(p)) return false;
try {
const stat = fs.statSync(p);
return stat.isFile();
} catch (e) {
return false;
}
}
/**
* Build platform-specific Python paths for fast filesystem check
* @returns {string[]} Array of potential Python paths
*/
function getPythonPaths() {
const paths = [];
if (process.env.PYTHON_PATH) {
paths.push(process.env.PYTHON_PATH);
}
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA;
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
if (localAppData) {
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python.exe'));
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python3.exe'));
for (const ver of ['313', '312', '311', '310', '39']) {
paths.push(path.join(localAppData, 'Programs', 'Python', `Python${ver}`, 'python.exe'));
}
}
for (const ver of ['313', '312', '311', '310', '39']) {
paths.push(path.join(programFiles, `Python${ver}`, 'python.exe'));
paths.push(path.join(programFilesX86, `Python${ver}`, 'python.exe'));
}
paths.push('C:\\Python313\\python.exe');
paths.push('C:\\Python312\\python.exe');
paths.push('C:\\Python311\\python.exe');
paths.push('C:\\Python310\\python.exe');
paths.push('C:\\Python39\\python.exe');
} else {
paths.push('/usr/bin/python3');
paths.push('/usr/local/bin/python3');
paths.push('/opt/homebrew/bin/python3');
paths.push('/opt/homebrew/bin/python');
paths.push('/usr/bin/python');
paths.push('/usr/local/bin/python');
}
return paths;
}
/**
* Find Python binary using fast `which` lookup first, then filesystem check
* @returns {string|null} Python binary path or null
*/
function findPythonBinary() {
// Fast path: try `which` command first (10ms vs 2000ms per path)
if (process.platform !== 'win32') {
const whichPython3 = execSafe('which python3', 500);
if (whichPython3 && isValidPythonPath(whichPython3)) return whichPython3;
const whichPython = execSafe('which python', 500);
if (whichPython && isValidPythonPath(whichPython)) return whichPython;
} else {
// Windows: try `where` command
const wherePython = execSafe('where python', 500);
if (wherePython) {
const firstPath = wherePython.split('\n')[0].trim();
if (isValidPythonPath(firstPath)) return firstPath;
}
}
// Fallback: check known paths
const paths = getPythonPaths();
for (const p of paths) {
if (isValidPythonPath(p)) return p;
}
return null;
}
/**
* Get Python version with optimized detection
* @returns {string|null} Python version string or null
*/
function getPythonVersion() {
const pythonPath = findPythonBinary();
if (pythonPath) {
const result = execFileSafe(pythonPath, ['--version']);
if (result) return result;
}
const commands = ['python3', 'python'];
for (const cmd of commands) {
const result = execFileSafe(cmd, ['--version']);
if (result) return result;
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// GIT DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if current directory is inside a git repository (fast check)
* Uses filesystem traversal instead of git command to avoid command failures
* @param {string} [startDir] - Directory to check from (defaults to cwd)
* @returns {boolean}
*/
function isGitRepo(startDir) {
let dir;
try {
dir = startDir || process.cwd();
} catch (e) {
// CWD deleted or inaccessible
return false;
}
const root = path.parse(dir).root;
while (dir !== root) {
if (fs.existsSync(path.join(dir, '.git'))) return true;
dir = path.dirname(dir);
}
return fs.existsSync(path.join(root, '.git'));
}
/**
* Get git remote URL
* @returns {string|null}
*/
function getGitRemoteUrl() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['config', '--get', 'remote.origin.url']);
}
/**
* Get current git branch
* @returns {string|null}
*/
function getGitBranch() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['branch', '--show-current']);
}
/**
* Get git repository root
* @returns {string|null}
*/
function getGitRoot() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['rev-parse', '--show-toplevel']);
}
// ═══════════════════════════════════════════════════════════════════════════
// PROJECT DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Detect project type based on workspace indicators
* @param {string} [configOverride] - Manual override from config
* @returns {'monorepo' | 'library' | 'single-repo'}
*/
function detectProjectType(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (fs.existsSync('pnpm-workspace.yaml')) return 'monorepo';
if (fs.existsSync('lerna.json')) return 'monorepo';
if (fs.existsSync('package.json')) {
try {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
if (pkg.workspaces) return 'monorepo';
if (pkg.main || pkg.exports) return 'library';
} catch (e) { /* ignore */ }
}
return 'single-repo';
}
/**
* Detect package manager from lock files
* @param {string} [configOverride] - Manual override from config
* @returns {'npm' | 'pnpm' | 'yarn' | 'bun' | null}
*/
function detectPackageManager(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (fs.existsSync('bun.lockb')) return 'bun';
if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
if (fs.existsSync('yarn.lock')) return 'yarn';
if (fs.existsSync('package-lock.json')) return 'npm';
return null;
}
/**
* Detect framework from package.json dependencies
* @param {string} [configOverride] - Manual override from config
* @returns {string|null}
*/
function detectFramework(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (!fs.existsSync('package.json')) return null;
try {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['next']) return 'next';
if (deps['nuxt']) return 'nuxt';
if (deps['astro']) return 'astro';
if (deps['@remix-run/node'] || deps['@remix-run/react']) return 'remix';
if (deps['svelte'] || deps['@sveltejs/kit']) return 'svelte';
if (deps['vue']) return 'vue';
if (deps['react']) return 'react';
if (deps['express']) return 'express';
if (deps['fastify']) return 'fastify';
if (deps['hono']) return 'hono';
if (deps['elysia']) return 'elysia';
return null;
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CODING LEVEL
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get coding level style name mapping
* @param {number} level - Coding level (0-5)
* @returns {string} Style name
*/
function getCodingLevelStyleName(level) {
const styleMap = {
0: 'coding-level-0-eli5',
1: 'coding-level-1-junior',
2: 'coding-level-2-mid',
3: 'coding-level-3-senior',
4: 'coding-level-4-lead',
5: 'coding-level-5-god'
};
return styleMap[level] || 'coding-level-5-god';
}
/**
* Get coding level guidelines by reading from output-styles .md files
* @param {number} level - Coding level (-1 to 5)
* @param {string} [configDir] - Config directory path
* @returns {string|null} Guidelines text or null if disabled
*/
function getCodingLevelGuidelines(level, configDir) {
if (level === -1 || level === null || level === undefined) return null;
const styleName = getCodingLevelStyleName(level);
const basePath = configDir || path.join(process.cwd(), '.claude');
const stylePath = path.join(basePath, 'output-styles', `${styleName}.md`);
try {
if (!fs.existsSync(stylePath)) return null;
const content = fs.readFileSync(stylePath, 'utf8');
const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n*/, '').trim();
return withoutFrontmatter;
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CONTEXT OUTPUT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Build context summary for output (compact, single line)
* @param {Object} config - Loaded config
* @param {Object} detections - Project detections
* @param {{ path: string|null, resolvedBy: string|null }} resolved - Plan resolution
* @param {string|null} gitRoot - Git repository root
* @returns {string}
*/
function buildContextOutput(config, detections, resolved, gitRoot) {
const lines = [`Project: ${detections.type || 'unknown'}`];
if (detections.pm) lines.push(`PM: ${detections.pm}`);
lines.push(`Plan naming: ${config.plan.namingFormat}`);
if (gitRoot && gitRoot !== process.cwd()) {
lines.push(`Root: ${gitRoot}`);
}
if (resolved.path) {
if (resolved.resolvedBy === 'session') {
lines.push(`Plan: ${resolved.path}`);
} else {
lines.push(`Suggested: ${resolved.path}`);
}
}
return lines.join(' | ');
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Detect all project information
*
* @param {Object} [options]
* @param {Object} [options.configOverrides] - Override auto-detection
* @returns {{
* type: 'monorepo' | 'library' | 'single-repo',
* packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | null,
* framework: string | null,
* pythonVersion: string | null,
* nodeVersion: string,
* gitBranch: string | null,
* gitRoot: string | null,
* gitUrl: string | null,
* osPlatform: string,
* user: string,
* locale: string,
* timezone: string
* }}
*/
function detectProject(options = {}) {
const { configOverrides = {} } = options;
return {
type: detectProjectType(configOverrides.type),
packageManager: detectPackageManager(configOverrides.packageManager),
framework: detectFramework(configOverrides.framework),
pythonVersion: getPythonVersion(),
nodeVersion: process.version,
gitBranch: getGitBranch(),
gitRoot: getGitRoot(),
gitUrl: getGitRemoteUrl(),
osPlatform: process.platform,
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
locale: process.env.LANG || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
}
/**
* Build static environment info object
* @param {string} [configDir] - Config directory path
* @returns {Object} Static environment info
*/
function buildStaticEnv(configDir) {
return {
nodeVersion: process.version,
pythonVersion: getPythonVersion(),
osPlatform: process.platform,
gitUrl: getGitRemoteUrl(),
gitBranch: getGitBranch(),
gitRoot: getGitRoot(),
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
locale: process.env.LANG || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
configDir: configDir || path.join(process.cwd(), '.claude')
};
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry points
detectProject,
buildStaticEnv,
// Detection functions
detectProjectType,
detectPackageManager,
detectFramework,
// Python detection
getPythonVersion,
findPythonBinary,
getPythonPaths,
isValidPythonPath,
// Git detection
isGitRepo,
getGitRemoteUrl,
getGitBranch,
getGitRoot,
// Coding level
getCodingLevelStyleName,
getCodingLevelGuidelines,
// Output
buildContextOutput,
// Helpers
execSafe,
execFileSafe
};

View File

@@ -0,0 +1,311 @@
#!/usr/bin/env node
/**
* scout-checker.cjs - Facade for scout-block modules
*
* Provides unified interface to scout-block/* modules for reuse in both
* Claude hooks and OpenCode plugins.
*
* @module scout-checker
*/
const fs = require('fs');
const path = require('path');
// Import scout-block modules
const { loadPatterns, createMatcher, matchPath } = require('../scout-block/pattern-matcher.cjs');
const { extractFromToolInput } = require('../scout-block/path-extractor.cjs');
const { detectBroadPatternIssue } = require('../scout-block/broad-pattern-detector.cjs');
// ═══════════════════════════════════════════════════════════════════════════
// COMMAND PATTERNS
// ═══════════════════════════════════════════════════════════════════════════
// Build command allowlist - these are allowed even if they contain blocked paths
// Handles flags and filters: npm build, pnpm --filter web run build, yarn workspace app build
const BUILD_COMMAND_PATTERN = /^(npm|pnpm|yarn|bun)\s+([^\s]+\s+)*(run\s+)?(build|test|lint|dev|start|install|ci|add|remove|update|publish|pack|init|create|exec)/;
// Tool commands - JS/TS, Go, Rust, Java, .NET, containers, IaC, Python, Ruby, PHP, Deno, Elixir
const TOOL_COMMAND_PATTERN = /^(\.\/)?(npx|pnpx|bunx|tsc|esbuild|vite|webpack|rollup|turbo|nx|jest|vitest|mocha|eslint|prettier|go|cargo|make|mvn|mvnw|gradle|gradlew|dotnet|docker|podman|kubectl|helm|terraform|ansible|bazel|cmake|sbt|flutter|swift|ant|ninja|meson|python3?|pip|uv|deno|bundle|rake|gem|php|composer|ruby|mix|elixir)/;
// Allow execution from .venv/bin/ or venv/bin/ (Unix) and .venv/Scripts/ or venv/Scripts/ (Windows)
const VENV_EXECUTABLE_PATTERN = /(^|[\/\\])\.?venv[\/\\](bin|Scripts)[\/\\]/;
// Allow Python venv creation commands (cross-platform):
// - python/python3 -m venv (Unix/macOS/Windows)
// - py -m venv (Windows py launcher, supports -3, -3.11, etc.)
// - uv venv (fast Rust-based Python package manager)
// - virtualenv (legacy but still widely used)
const VENV_CREATION_PATTERN = /^(python3?|py)\s+(-[\w.]+\s+)*-m\s+venv\s+|^uv\s+venv(\s|$)|^virtualenv\s+/;
// ═══════════════════════════════════════════════════════════════════════════
// HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Strip leading ENV variable assignments and command wrappers (sudo, env, etc.)
* e.g., "NODE_ENV=production npm run build" → "npm run build"
* @param {string} command - The command to strip
* @returns {string}
*/
function stripCommandPrefix(command) {
if (!command || typeof command !== 'string') return command;
let stripped = command.trim();
// Strip env var assignments (KEY=VALUE KEY2=VALUE2 ...)
stripped = stripped.replace(/^(\w+=\S+\s+)+/, '');
// Strip common command wrappers (one level)
stripped = stripped.replace(/^(sudo|env|nice|nohup|time|timeout)\s+/, '');
// Strip env vars again (sudo env VAR=x cmd)
stripped = stripped.replace(/^(\w+=\S+\s+)+/, '');
return stripped.trim();
}
/**
* Check if a command is a build/tooling command (should be allowed)
* @param {string} command - The command to check
* @returns {boolean}
*/
function isBuildCommand(command) {
if (!command || typeof command !== 'string') return false;
const trimmed = command.trim();
return BUILD_COMMAND_PATTERN.test(trimmed) || TOOL_COMMAND_PATTERN.test(trimmed);
}
/**
* Split a compound command into sub-commands on &&, ||, and ;.
* Does NOT split on newlines — newlines in command strings are typically
* heredoc bodies or multiline strings, not compound operators.
* Does not handle operators inside quoted strings (extremely rare in practice).
*
* @param {string} command - The compound command string
* @returns {string[]} Array of sub-commands (trimmed, non-empty)
*/
function splitCompoundCommand(command) {
if (!command || typeof command !== 'string') return [];
return command.split(/\s*(?:&&|\|\||;)\s*/).filter(cmd => cmd && cmd.trim().length > 0);
}
/**
* Unwrap shell executor wrappers (bash -c "...", sh -c '...', eval "...").
* Returns the inner command string for re-processing.
* @param {string} command - The command to unwrap
* @returns {string} Inner command, or original if not a shell executor
*/
function unwrapShellExecutor(command) {
if (!command || typeof command !== 'string') return command;
const match = command.trim().match(
/^(?:(?:bash|sh|zsh)\s+-c|eval)\s+["'](.+)["']\s*$/
);
return match ? match[1] : command;
}
/**
* Check if command executes from a .venv bin directory
* @param {string} command - The command to check
* @returns {boolean}
*/
function isVenvExecutable(command) {
if (!command || typeof command !== 'string') return false;
return VENV_EXECUTABLE_PATTERN.test(command);
}
/**
* Check if command creates a Python virtual environment
* @param {string} command - The command to check
* @returns {boolean}
*/
function isVenvCreationCommand(command) {
if (!command || typeof command !== 'string') return false;
return VENV_CREATION_PATTERN.test(command.trim());
}
/**
* Check if command should be allowed (build, venv executable, or venv creation)
* Strips ENV prefixes and command wrappers before checking.
* @param {string} command - The command to check
* @returns {boolean}
*/
function isAllowedCommand(command) {
const stripped = stripCommandPrefix(command);
return isBuildCommand(stripped) || isVenvExecutable(stripped) || isVenvCreationCommand(stripped);
}
function findGitRoot(startDir) {
if (!startDir || typeof startDir !== 'string') return null;
let dir = path.resolve(startDir);
const root = path.parse(dir).root;
while (true) {
if (fs.existsSync(path.join(dir, '.git')) || dir === root) {
return fs.existsSync(path.join(dir, '.git')) ? dir : null;
}
dir = path.dirname(dir);
}
}
/**
* Find an optional project-local .ckignore at the git root config directory.
* This keeps overrides stable regardless of the caller cwd inside the repo.
*
* @param {string} startDir - Directory to start searching from
* @param {string} [configDirName] - Config directory at git root (.claude, .opencode)
* @returns {string|null}
*/
function findProjectCkignore(startDir, configDirName) {
if (!configDirName || typeof configDirName !== 'string') return null;
const gitRoot = findGitRoot(startDir);
if (!gitRoot) return null;
const candidate = path.join(gitRoot, configDirName, '.ckignore');
return fs.existsSync(candidate) ? candidate : null;
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if a tool call accesses blocked directories or uses overly broad patterns
*
* @param {Object} params
* @param {string} params.toolName - Name of tool (Bash, Glob, Read, etc.)
* @param {Object} params.toolInput - Tool input with file_path, path, pattern, command
* @param {Object} [params.options]
* @param {string} [params.options.ckignorePath] - Path to .ckignore file
* @param {string} [params.options.projectCkignorePath] - Explicit project-local .ckignore path
* @param {string} [params.options.claudeDir] - Path to .claude or .opencode directory
* @param {string} [params.options.cwd] - Working directory used to discover a project .ckignore
* @param {string} [params.options.projectConfigDirName] - Git-root config dir for project-local overrides
* @param {boolean} [params.options.checkBroadPatterns] - Check for overly broad glob patterns (default: true)
* @returns {{
* blocked: boolean,
* path?: string,
* pattern?: string,
* reason?: string,
* configPath?: string,
* isBroadPattern?: boolean,
* suggestions?: string[],
* isAllowedCommand?: boolean
* }}
*/
function checkScoutBlock({ toolName, toolInput, options = {} }) {
const {
ckignorePath,
projectCkignorePath,
claudeDir = path.join(process.cwd(), '.claude'),
cwd = process.cwd(),
projectConfigDirName,
checkBroadPatterns = true
} = options;
// Unwrap shell executor wrappers (bash -c "...", eval "...")
// so the inner command gets properly analyzed
if (toolInput.command) {
const unwrapped = unwrapShellExecutor(toolInput.command);
if (unwrapped !== toolInput.command) {
toolInput = { ...toolInput, command: unwrapped };
}
}
// For Bash commands, split compound commands (&&, ||, ;) and check
// each sub-command independently. This prevents "echo msg && npm run build"
// from being blocked due to "build" token in the allowed build sub-command.
// Must split BEFORE isAllowedCommand because BUILD_COMMAND_PATTERN has no end
// anchor and would match the prefix of "npm run build && cat dist/file.js".
if (toolInput.command) {
const subCommands = splitCompoundCommand(toolInput.command);
const nonAllowed = subCommands.filter(cmd => !isAllowedCommand(cmd.trim()));
if (nonAllowed.length === 0) {
return { blocked: false, isAllowedCommand: true };
}
// Only extract paths from non-allowed sub-commands
if (nonAllowed.length < subCommands.length) {
toolInput = { ...toolInput, command: nonAllowed.join(' ; ') };
}
}
// Check for overly broad glob patterns (Glob tool)
if (checkBroadPatterns && (toolName === 'Glob' || toolInput.pattern)) {
const broadResult = detectBroadPatternIssue(toolInput);
if (broadResult.blocked) {
return {
blocked: true,
isBroadPattern: true,
pattern: toolInput.pattern,
reason: broadResult.reason || 'Pattern too broad - may fill context with too many files',
suggestions: broadResult.suggestions || []
};
}
}
// Resolve .ckignore path
const resolvedCkignorePath = ckignorePath || path.join(claudeDir, '.ckignore');
const discoveredProjectCkignorePath = projectCkignorePath || findProjectCkignore(cwd, projectConfigDirName);
const resolvedProjectCkignorePath = discoveredProjectCkignorePath
&& path.resolve(discoveredProjectCkignorePath) !== path.resolve(resolvedCkignorePath)
? discoveredProjectCkignorePath
: null;
const configPath = resolvedProjectCkignorePath || resolvedCkignorePath;
// Load patterns and create matcher
const patterns = loadPatterns(resolvedCkignorePath, resolvedProjectCkignorePath);
const matcher = createMatcher(patterns);
// Extract paths from tool input
const extractedPaths = extractFromToolInput(toolInput);
// If no paths extracted, allow operation
if (extractedPaths.length === 0) {
return { blocked: false };
}
// Check each path against patterns
for (const extractedPath of extractedPaths) {
const result = matchPath(matcher, extractedPath);
if (result.blocked) {
return {
blocked: true,
path: extractedPath,
pattern: result.pattern,
configPath,
reason: `Path matches blocked pattern: ${result.pattern}`
};
}
}
// All paths allowed
return { blocked: false };
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry point
checkScoutBlock,
// Command checkers
isBuildCommand,
isVenvExecutable,
isVenvCreationCommand,
isAllowedCommand,
splitCompoundCommand,
stripCommandPrefix,
unwrapShellExecutor,
findGitRoot,
findProjectCkignore,
// Re-export scout-block modules for direct access
loadPatterns,
createMatcher,
matchPath,
extractFromToolInput,
detectBroadPatternIssue,
// Patterns (for testing)
BUILD_COMMAND_PATTERN,
TOOL_COMMAND_PATTERN,
VENV_EXECUTABLE_PATTERN,
VENV_CREATION_PATTERN
};