When no in_progress todo is active, fill the middle slot of gsd-statusline.js with GSD state read from .planning/STATE.md. Format: <milestone> · <status> · <phase name> (N/total) - Add readGsdState() — walks up from workspace dir looking for .planning/STATE.md (bounded at 10 levels / home dir) - Add parseStateMd() — reads YAML frontmatter (status, milestone, milestone_name) and Phase line from body; falls back to body Status: parsing for older STATE.md files without frontmatter - Add formatGsdState() — joins available parts with ' · ', degrades gracefully when fields are missing - Wrap stdin handler in runStatusline() and export helpers so unit tests can require the file without triggering the script behavior Strictly additive: active todo wins the slot (unchanged); missing STATE.md leaves the slot empty (unchanged). Only the "no active todo AND STATE.md present" path is new. Uses the YAML frontmatter added for #628, completing the statusline display that issue originally proposed. Closes #1989
242 lines
8.7 KiB
JavaScript
Executable File
242 lines
8.7 KiB
JavaScript
Executable File
#!/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 ~16.5% for autocompact buffer, so usable context
|
|
// is 83.5% of the total window. We normalize to show 100% at that point.
|
|
const AUTO_COMPACT_BUFFER_PCT = 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) {
|
|
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();
|