843 lines
30 KiB
JavaScript
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
|
|
};
|