#!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // Claude Code Statusline - GSD Edition // Shows: model | current task (or GSD state) | directory | context usage const fs = require('fs'); const path = require('path'); const os = require('os'); // --- GSD state reader ------------------------------------------------------- /** * Walk up from dir looking for .planning/STATE.md. * Returns parsed state object or null. */ function readGsdState(dir) { const home = os.homedir(); let current = dir; for (let i = 0; i < 10; i++) { const candidate = path.join(current, '.planning', 'STATE.md'); if (fs.existsSync(candidate)) { try { return parseStateMd(fs.readFileSync(candidate, 'utf8')); } catch (e) { return null; } } const parent = path.dirname(current); if (parent === current || current === home) break; current = parent; } return null; } /** * Parse STATE.md frontmatter + Phase line from body. * Returns { status, milestone, milestoneName, phaseNum, phaseTotal, phaseName } */ function parseStateMd(content) { const state = {}; // YAML frontmatter between --- markers const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (fmMatch) { for (const line of fmMatch[1].split('\n')) { const m = line.match(/^(\w+):\s*(.+)/); if (!m) continue; const [, key, val] = m; const v = val.trim().replace(/^["']|["']$/g, ''); if (key === 'status') state.status = v === 'null' ? null : v; if (key === 'milestone') state.milestone = v === 'null' ? null : v; if (key === 'milestone_name') state.milestoneName = v === 'null' ? null : v; } } // Phase: N of M (name) or Phase: none active (...) const phaseMatch = content.match(/^Phase:\s*(\d+)\s+of\s+(\d+)(?:\s+\(([^)]+)\))?/m); if (phaseMatch) { state.phaseNum = phaseMatch[1]; state.phaseTotal = phaseMatch[2]; state.phaseName = phaseMatch[3] || null; } // Fallback: parse Status: from body when frontmatter is absent if (!state.status) { const bodyStatus = content.match(/^Status:\s*(.+)/m); if (bodyStatus) { const raw = bodyStatus[1].trim().toLowerCase(); if (raw.includes('ready to plan') || raw.includes('planning')) state.status = 'planning'; else if (raw.includes('execut')) state.status = 'executing'; else if (raw.includes('complet') || raw.includes('archived')) state.status = 'complete'; } } return state; } /** * Format GSD state into display string. * Format: "v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)" * Gracefully degrades when parts are missing. */ function formatGsdState(s) { const parts = []; // Milestone: version + name (skip placeholder "milestone") if (s.milestone || s.milestoneName) { const ver = s.milestone || ''; const name = (s.milestoneName && s.milestoneName !== 'milestone') ? s.milestoneName : ''; const ms = [ver, name].filter(Boolean).join(' '); if (ms) parts.push(ms); } // Status if (s.status) parts.push(s.status); // Phase if (s.phaseNum && s.phaseTotal) { const phase = s.phaseName ? `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` : `ph ${s.phaseNum}/${s.phaseTotal}`; parts.push(phase); } return parts.join(' · '); } // --- stdin ------------------------------------------------------------------ function runStatusline() { let input = ''; // Timeout guard: if stdin doesn't close within 3s (e.g. pipe issues on // Windows/Git Bash), exit silently instead of hanging. See #775. const stdinTimeout = setTimeout(() => process.exit(0), 3000); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { clearTimeout(stdinTimeout); try { const data = JSON.parse(input); const model = data.model?.display_name || 'Claude'; const dir = data.workspace?.current_dir || process.cwd(); const session = data.session_id || ''; const remaining = data.context_window?.remaining_percentage; // Context window display (shows USED percentage scaled to usable context) // Claude Code reserves a buffer for autocompact. By default this is ~16.5% // of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW // (a token count). When the env var is set, compute the buffer % dynamically so // the meter correctly reflects early-compaction configurations (#2219). const totalCtx = data.context_window?.total_tokens || 1_000_000; const acw = parseInt(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '0', 10); const AUTO_COMPACT_BUFFER_PCT = acw > 0 ? Math.min(100, (acw / totalCtx) * 100) : 16.5; let ctx = ''; if (remaining != null) { // Normalize: subtract buffer from remaining, scale to usable range const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100); const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining))); // Write context metrics to bridge file for the context-monitor PostToolUse hook. // The monitor reads this file to inject agent-facing warnings when context is low. // Reject session IDs with path separators or traversal sequences to prevent // a malicious session_id from writing files outside the temp directory. const sessionSafe = session && !/[/\\]|\.\./.test(session); if (sessionSafe) { try { const bridgePath = path.join(os.tmpdir(), `claude-ctx-${session}.json`); const bridgeData = JSON.stringify({ session_id: session, remaining_percentage: remaining, used_pct: used, timestamp: Math.floor(Date.now() / 1000) }); fs.writeFileSync(bridgePath, bridgeData); } catch (e) { // Silent fail -- bridge is best-effort, don't break statusline } } // Build progress bar (10 segments) const filled = Math.floor(used / 10); const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); // Color based on usable context thresholds if (used < 50) { ctx = ` \x1b[32m${bar} ${used}%\x1b[0m`; } else if (used < 65) { ctx = ` \x1b[33m${bar} ${used}%\x1b[0m`; } else if (used < 80) { ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`; } else { ctx = ` \x1b[5;31m💀 ${bar} ${used}%\x1b[0m`; } } // Current task from todos let task = ''; const homeDir = os.homedir(); // Respect CLAUDE_CONFIG_DIR for custom config directory setups (#870) const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude'); const todosDir = path.join(claudeDir, 'todos'); if (session && fs.existsSync(todosDir)) { try { const files = fs.readdirSync(todosDir) .filter(f => f.startsWith(session) && f.includes('-agent-') && f.endsWith('.json')) .map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime })) .sort((a, b) => b.mtime - a.mtime); if (files.length > 0) { try { const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8')); const inProgress = todos.find(t => t.status === 'in_progress'); if (inProgress) task = inProgress.activeForm || ''; } catch (e) {} } } catch (e) { // Silently fail on file system errors - don't break statusline } } // GSD state (milestone · status · phase) — shown when no todo task const gsdStateStr = task ? '' : formatGsdState(readGsdState(dir) || {}); // GSD update available? // Check shared cache first (#1421), fall back to runtime-specific cache for // backward compatibility with older gsd-check-update.js versions. let gsdUpdate = ''; const sharedCacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json'); const legacyCacheFile = path.join(claudeDir, 'cache', 'gsd-update-check.json'); const cacheFile = fs.existsSync(sharedCacheFile) ? sharedCacheFile : legacyCacheFile; if (fs.existsSync(cacheFile)) { try { const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); if (cache.update_available) { gsdUpdate = '\x1b[33m⬆ /gsd-update\x1b[0m │ '; } if (cache.stale_hooks && cache.stale_hooks.length > 0) { // If installed version is ahead of npm latest, this is a dev install. // Running /gsd-update would downgrade — show a contextual warning instead. const isDevInstall = (() => { if (!cache.installed || !cache.latest || cache.latest === 'unknown') return false; const parseV = v => v.replace(/^v/, '').split('.').map(Number); const [ai, bi, ci] = parseV(cache.installed); const [an, bn, cn] = parseV(cache.latest); return ai > an || (ai === an && bi > bn) || (ai === an && bi === bn && ci > cn); })(); if (isDevInstall) { gsdUpdate += '\x1b[33m⚠ dev install — re-run installer to sync hooks\x1b[0m │ '; } else { gsdUpdate += '\x1b[31m⚠ stale hooks — run /gsd-update\x1b[0m │ '; } } } catch (e) {} } // Output const dirname = path.basename(dir); const middle = task ? `\x1b[1m${task}\x1b[0m` : gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null; if (middle) { process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ ${middle} │ \x1b[2m${dirname}\x1b[0m${ctx}`); } else { process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}`); } } catch (e) { // Silent fail - don't break statusline on parse errors } }); } // Export helpers for unit tests. Harmless when run as a script. module.exports = { readGsdState, parseStateMd, formatGsdState }; if (require.main === module) runStatusline();