#!/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 };