Files
english/.opencode/plugin/lib/context-builder.cjs
2026-04-12 01:06:31 +07:00

843 lines
30 KiB
JavaScript

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