init
This commit is contained in:
842
.opencode/plugin/lib/context-builder.cjs
Normal file
842
.opencode/plugin/lib/context-builder.cjs
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user