Spaces:
Runtime error
Runtime error
Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2 | // gsd-hook-version: 1.5.0 | |
| // 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'); | |
| const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'); | |
| const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); | |
| // --- Config + last-command readers ------------------------------------------ | |
| /** | |
| * Walk up from dir looking for .planning/config.json and return its parsed contents. | |
| * Returns {} if not found or unreadable. | |
| */ | |
| function readGsdConfig(dir) { | |
| const home = os.homedir(); | |
| let current = dir; | |
| for (let i = 0; i < 10; i++) { | |
| const candidate = path.join(current, '.planning', 'config.json'); | |
| if (fs.existsSync(candidate)) { | |
| try { | |
| return JSON.parse(fs.readFileSync(candidate, 'utf8')) || {}; | |
| } catch (e) { | |
| return {}; | |
| } | |
| } | |
| const parent = path.dirname(current); | |
| if (parent === current || current === home) break; | |
| current = parent; | |
| } | |
| return {}; | |
| } | |
| /** | |
| * Lookup a dotted key path (e.g. 'statusline.show_last_command') in a config | |
| * object that may use either nested or flat keys. | |
| */ | |
| function getConfigValue(cfg, keyPath) { | |
| if (!cfg || typeof cfg !== 'object') return undefined; | |
| if (keyPath in cfg) return cfg[keyPath]; | |
| const parts = keyPath.split('.'); | |
| let cur = cfg; | |
| for (const p of parts) { | |
| if (cur == null || typeof cur !== 'object' || !(p in cur)) return undefined; | |
| cur = cur[p]; | |
| } | |
| return cur; | |
| } | |
| /** | |
| * Extract the most recently invoked slash command from a Claude Code JSONL | |
| * transcript file. Returns the command name (no leading slash) or null. | |
| * | |
| * Claude Code embeds slash invocations in user messages as | |
| * <command-name>/foo</command-name> | |
| * We scan lines from the end of the file, stopping at the first match. | |
| */ | |
| function readLastSlashCommand(transcriptPath) { | |
| if (!transcriptPath || typeof transcriptPath !== 'string') return null; | |
| let content; | |
| try { | |
| if (!fs.existsSync(transcriptPath)) return null; | |
| // Read only the tail β typical transcripts grow large. 256 KiB comfortably | |
| // covers dozens of recent turns while staying cheap per render. | |
| const stat = fs.statSync(transcriptPath); | |
| const MAX = 256 * 1024; | |
| const start = Math.max(0, stat.size - MAX); | |
| const fd = fs.openSync(transcriptPath, 'r'); | |
| try { | |
| const buf = Buffer.alloc(stat.size - start); | |
| fs.readSync(fd, buf, 0, buf.length, start); | |
| content = buf.toString('utf8'); | |
| } finally { | |
| fs.closeSync(fd); | |
| } | |
| } catch (e) { | |
| return null; | |
| } | |
| // Find the LAST occurrence β scan right-to-left via lastIndexOf on the tag. | |
| const tagClose = '</command-name>'; | |
| const idx = content.lastIndexOf(tagClose); | |
| if (idx < 0) return null; | |
| const openTag = '<command-name>'; | |
| const openIdx = content.lastIndexOf(openTag, idx); | |
| if (openIdx < 0) return null; | |
| let name = content.slice(openIdx + openTag.length, idx).trim(); | |
| // Strip a leading slash if present, and any trailing arguments-on-same-line noise. | |
| if (name.startsWith('/')) name = name.slice(1); | |
| // Command names in Claude Code transcripts are plain identifiers like "gsd-plan-phase" | |
| // or namespaced like "plugin:skill". Reject anything with whitespace/newlines/control chars. | |
| if (!name || /[\s\\"<>]/.test(name) || name.length > 80) return null; | |
| return name; | |
| } | |
| // --- 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, | |
| * activePhase, nextAction, nextPhases, completedPhases, totalPhases, percent } | |
| * | |
| * Phase-lifecycle fields (issue #2833): | |
| * - activePhase : phase number ("4.5") when an orchestrator is mid-flight, null otherwise | |
| * - nextAction : recommended next command ("execute-phase") when idle, null otherwise | |
| * - nextPhases : array of phase numbers (["4.5"]) for nextAction, null otherwise | |
| * - completedPhases / totalPhases / percent : milestone progress dimension | |
| * | |
| * All new fields default to undefined when absent β formatGsdState() degrades | |
| * gracefully so existing STATE.md files (without these fields) keep working. | |
| */ | |
| function parseStateMd(content) { | |
| const state = {}; | |
| // YAML frontmatter between --- markers (anchored at file start) | |
| const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); | |
| if (fmMatch) { | |
| const fm = fmMatch[1]; | |
| // Top-level scalar key: value | |
| for (const line of fm.split('\n')) { | |
| const m = line.match(/^(\w+):\s*(.+)/); | |
| if (!m) continue; | |
| const [, key, val] = m; | |
| const v = val.trim().replace(/^["']|["']$/g, ''); | |
| // status / milestone-level fields (existing β preserved exactly) | |
| 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-lifecycle fields (new in issue #2833) | |
| // active_phase: phase number when an orchestrator is in-flight, null when idle | |
| if (key === 'active_phase') state.activePhase = (v === 'null' || v === '') ? null : v; | |
| // next_action: recommended command when idle (discuss-phase / plan-phase / execute-phase / verify-phase) | |
| if (key === 'next_action') state.nextAction = (v === 'null' || v === '') ? null : v; | |
| } | |
| // next_phases supports both flow array and block-list YAML forms. | |
| const npFlowMatch = fm.match(/^next_phases:\s*\[([^\]]*)\]/m); | |
| if (npFlowMatch) { | |
| const items = npFlowMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean); | |
| state.nextPhases = items.length > 0 ? items : null; | |
| } else { | |
| const npBlockMatch = fm.match(/^next_phases:\s*\n((?:[ \t]*-[ \t]*[^\n]+\n?)*)/m); | |
| if (npBlockMatch) { | |
| const items = npBlockMatch[1] | |
| .split('\n') | |
| .map(line => line.match(/^[ \t]*-[ \t]*(.+)$/)) | |
| .filter(Boolean) | |
| .map(m => m[1].trim().replace(/^["']|["']$/g, '')) | |
| .filter(Boolean); | |
| state.nextPhases = items.length > 0 ? items : null; | |
| } | |
| } | |
| // progress nested block: completed_phases / total_phases / percent (2-space indent) | |
| const progMatch = fm.match(/^progress:\s*\n((?:[ \t]+\w+:.+\n?)+)/m); | |
| if (progMatch) { | |
| const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m); | |
| const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m); | |
| const pc = progMatch[1].match(/^[ \t]+percent:\s*(\d+)/m); | |
| if (cp) state.completedPhases = cp[1]; | |
| if (tp) state.totalPhases = tp[1]; | |
| if (pc) state.percent = pc[1]; | |
| } | |
| } | |
| // 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; | |
| } | |
| /** | |
| * Render a 10-segment milestone progress bar (matches the context meter style). | |
| * | |
| * @param {number|string|null|undefined} percent β 0-100; missing/NaN returns '' | |
| * @returns {string} '[ββββββββββ] 50%' or '' (so callers can `[bar].filter(Boolean)`) | |
| */ | |
| function renderProgressBar(percent) { | |
| if (percent == null || isNaN(percent)) return ''; | |
| const pct = Math.max(0, Math.min(100, parseInt(percent, 10))); | |
| const filled = Math.floor(pct / 10); | |
| const bar = 'β'.repeat(filled) + 'β'.repeat(10 - filled); | |
| return `[${bar}] ${pct}%`; | |
| } | |
| /** | |
| * Format GSD state into display string. | |
| * | |
| * Backward-compatible default (no new fields populated): | |
| * "v1.9 Code Quality Β· executing Β· fix-graphiti-deployment (1/5)" | |
| * | |
| * Phase-lifecycle scenes (issue #2833 β activate when STATE.md frontmatter | |
| * carries the new fields; otherwise rendering falls through to the default): | |
| * | |
| * active_phase set β "v2.0 [βββ] X% Β· Phase 4.5 executing" | |
| * active_phase null + next_action set β "v2.0 [βββ] X% Β· next execute-phase 4.5" | |
| * percent=100 (milestone done) β "v2.0 [ββββββββββ] 100% Β· milestone complete" | |
| * none of the above β existing "<status> Β· <phase>" path | |
| * | |
| * Progress bar is opt-in: appended to the milestone segment only when | |
| * progress.percent is present in frontmatter; absent β empty string. | |
| */ | |
| function formatGsdState(s) { | |
| const parts = []; | |
| // Milestone segment: version + name + (opt-in) progress bar | |
| if (s.milestone || s.milestoneName) { | |
| const ver = s.milestone || ''; | |
| const name = (s.milestoneName && s.milestoneName !== 'milestone') ? s.milestoneName : ''; | |
| const bar = renderProgressBar(s.percent); | |
| const pieces = [ver, name, bar].filter(Boolean); | |
| if (pieces.length > 0) parts.push(pieces.join(' ')); | |
| } | |
| // Phase-lifecycle scenes (issue #2833) β first match wins; falls through to | |
| // the original "<status> Β· <phase>" path when none of the new fields apply. | |
| const phasesStr = (s.nextPhases && s.nextPhases.length > 0) ? s.nextPhases.join('/') : null; | |
| if (s.activePhase) { | |
| // Scene 1: an orchestrator is mid-flight on this phase. | |
| // stage = whichever lifecycle status was written by the orchestrator | |
| // (discussing / planning / executing / verifying) | |
| const stage = s.status || ''; | |
| parts.push(stage ? `Phase ${s.activePhase} ${stage}` : `Phase ${s.activePhase}`); | |
| } else if (s.nextAction && phasesStr) { | |
| // Scene 2: idle + a recommended next command is visible to the user. | |
| // Surfaces "what to run next" without the user opening STATE.md. | |
| parts.push(`next ${s.nextAction} ${phasesStr}`); | |
| } else if (Number(s.percent) === 100 || (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases)) { | |
| // Scene 3: milestone complete (every phase done). | |
| parts.push('milestone complete'); | |
| } else { | |
| // Backward-compatible default β preserved EXACTLY for STATE.md files that | |
| // don't carry the new lifecycle fields. Identical output to v1.38.x and | |
| // earlier so no existing project's status-line changes shape. | |
| if (s.status) parts.push(s.status); | |
| 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, Math.max(0, (1 - 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`); | |
| // used_pct written to the bridge must match CC's native /context reporting: | |
| // raw used = 100 - remaining_percentage (no buffer normalization applied). | |
| // The normalized `used` value is correct for the statusline progress bar but | |
| // inflates the context monitor warning messages by ~13 points (#2451). | |
| const rawUsedPct = Math.round(100 - remaining); | |
| const bridgeData = JSON.stringify({ | |
| session_id: session, | |
| remaining_percentage: remaining, | |
| used_pct: rawUsedPct, | |
| 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, '.opencode'); | |
| const todosDir = path.join(claudeDir, 'todos'); | |
| if (session && fs.existsSync(todosDir)) { | |
| try { | |
| // Single-pass max-by-mtime scan: only the newest matching todos file | |
| // is needed, so the O(n log n) sort and the intermediate array from the | |
| // prior `.filter().map(statSync).sort()` chain are unnecessary. Identical | |
| // I/O (one statSync per match) and identical result. (#305) | |
| let latest = null; | |
| for (const entry of fs.readdirSync(todosDir)) { | |
| if (!entry.startsWith(session) || !entry.includes('-agent-') || !entry.endsWith('.json')) continue; | |
| const mtime = fs.statSync(path.join(todosDir, entry)).mtime; | |
| if (!latest || mtime > latest.mtime) latest = { name: entry, mtime }; | |
| } | |
| if (latest) { | |
| try { | |
| const todos = JSON.parse(fs.readFileSync(path.join(todosDir, latest.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? | |
| // Read only the per-package shared cache file (#607). The legacy | |
| // runtime-specific fallback has been removed β the per-package filename | |
| // carries lineage and avoids multi-runtime resolution mismatches (#1421). | |
| let gsdUpdate = ''; | |
| const cacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName); | |
| if (fs.existsSync(cacheFile)) { | |
| try { | |
| const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); | |
| const { showUpdate, staleWarning } = evaluateUpdateCache(cache); | |
| if (showUpdate) { | |
| gsdUpdate = '\x1b[33mβ¬ /gsd:update\x1b[0m β '; | |
| } | |
| if (staleWarning === 'dev') { | |
| gsdUpdate += '\x1b[33mβ dev install β re-run installer to sync hooks\x1b[0m β '; | |
| } else if (staleWarning === 'stale') { | |
| gsdUpdate += '\x1b[31mβ stale hooks β run /gsd:update\x1b[0m β '; | |
| } | |
| } catch (e) {} | |
| } | |
| // Last-slash-command suffix and context_position config (#2538, #2937). | |
| // Reads the active session transcript for the most recent <command-name> tag. | |
| // Failure here must never break the statusline β wrap the entire lookup. | |
| let lastCmdSuffix = ''; | |
| let position = 'end'; | |
| try { | |
| const cfg = readGsdConfig(dir); | |
| if (getConfigValue(cfg, 'statusline.show_last_command') === true) { | |
| const transcriptPath = data.transcript_path; | |
| const lastCmd = readLastSlashCommand(transcriptPath); | |
| if (lastCmd) { | |
| lastCmdSuffix = ` β \x1b[2mlast: /${lastCmd}\x1b[0m`; | |
| } | |
| } | |
| const cfgPos = getConfigValue(cfg, 'statusline.context_position'); | |
| if (cfgPos != null) position = cfgPos; | |
| } catch (e) { | |
| // Never break the statusline on config/transcript errors | |
| } | |
| // Output | |
| const dirname = path.basename(dir); | |
| const middle = task | |
| ? `\x1b[1m${task}\x1b[0m` | |
| : gsdStateStr | |
| ? `\x1b[2m${gsdStateStr}\x1b[0m` | |
| : null; | |
| process.stdout.write(composeStatusline({ gsdUpdate, model, ctx, middle, dirname, lastCmdSuffix, position })); | |
| } catch (e) { | |
| // Silent fail - don't break statusline on parse errors | |
| } | |
| }); | |
| } | |
| // --- Layout composer -------------------------------------------------------- | |
| /** | |
| * Compose the statusline string from pre-built segments. | |
| * | |
| * @param {object} opts | |
| * @param {string} [opts.gsdUpdate=''] - leading update/stale-hooks warning (already formatted) | |
| * @param {string} opts.model - model display name (plain text; dim styling applied here) | |
| * @param {string} [opts.ctx=''] - context-window meter segment (empty string = absent) | |
| * @param {string|null} [opts.middle=null] - middle segment (todo task or GSD state), null = absent | |
| * @param {string} opts.dirname - project directory basename (dim styling applied here) | |
| * @param {string} [opts.lastCmdSuffix=''] - last-command suffix, e.g. ' β last: /foo' | |
| * @param {'end'|'front'} [opts.position='end'] | |
| * - 'end' (default): ctx appended after dirname β preserved byte-for-byte | |
| * - 'front': ctx immediately after model name so the meter stays visible in narrow terminals | |
| * | |
| * Invalid position values are silently coerced to 'end' β config-set schema rejects | |
| * invalid values upfront; runtime fallback defends against stale/corrupt configs | |
| * without breaking the statusline. | |
| */ | |
| function composeStatusline({ | |
| gsdUpdate = '', | |
| model, | |
| ctx = '', | |
| middle = null, | |
| dirname, | |
| lastCmdSuffix = '', | |
| position = 'end', | |
| } = {}) { | |
| const modelSeg = `\x1b[2m${model}\x1b[0m`; | |
| const dirSeg = `\x1b[2m${dirname}\x1b[0m`; | |
| // Coerce invalid values to 'end' (belt-and-suspenders; see JSDoc above) | |
| const pos = position === 'front' ? 'front' : 'end'; | |
| if (pos === 'front') { | |
| if (middle) return `${gsdUpdate}${modelSeg}${ctx} β ${middle} β ${dirSeg}${lastCmdSuffix}`; | |
| return `${gsdUpdate}${modelSeg}${ctx} β ${dirSeg}${lastCmdSuffix}`; | |
| } | |
| // 'end' β preserved byte-for-byte relative to original inline templates | |
| if (middle) return `${gsdUpdate}${modelSeg} β ${middle} β ${dirSeg}${ctx}${lastCmdSuffix}`; | |
| return `${gsdUpdate}${modelSeg} β ${dirSeg}${ctx}${lastCmdSuffix}`; | |
| } | |
| function isInstalledAheadOfLatest(installed, latest) { | |
| return isSemverNewer(installed, latest); | |
| } | |
| /** | |
| * Pure function: evaluate an update-check cache object and return display flags. | |
| * Applies lineage guard β if package_name is absent or foreign, treats cache as absent. | |
| * | |
| * @param {object|null} cache Parsed cache object, or null. | |
| * @returns {{ showUpdate: boolean, staleWarning: 'none'|'dev'|'stale' }} | |
| */ | |
| function evaluateUpdateCache(cache) { | |
| const none = { showUpdate: false, staleWarning: 'none' }; | |
| if (!cache) return none; | |
| // Lineage guard: package_name must be present and match this package. | |
| if (!cache.package_name || cache.package_name !== PACKAGE_NAME) return none; | |
| const showUpdate = Boolean(cache.update_available); | |
| let staleWarning = 'none'; | |
| if (cache.stale_hooks && cache.stale_hooks.length > 0) { | |
| const isDevInstall = ( | |
| cache.installed && | |
| cache.latest && | |
| cache.latest !== 'unknown' && | |
| isInstalledAheadOfLatest(cache.installed, cache.latest) | |
| ); | |
| staleWarning = isDevInstall ? 'dev' : 'stale'; | |
| } | |
| return { showUpdate, staleWarning }; | |
| } | |
| // Export helpers for unit tests. Harmless when run as a script. | |
| module.exports = { | |
| readGsdState, parseStateMd, formatGsdState, | |
| readGsdConfig, getConfigValue, readLastSlashCommand, | |
| composeStatusline, | |
| isInstalledAheadOfLatest, | |
| evaluateUpdateCache, | |
| }; | |
| /** | |
| * Render the statusline from an already-parsed hook input object. Exported for | |
| * testing without feeding stdin. Returns the rendered string. | |
| */ | |
| function renderStatusline(data) { | |
| const model = data.model?.display_name || 'Claude'; | |
| const dir = data.workspace?.current_dir || process.cwd(); | |
| const dirname = path.basename(dir); | |
| let lastCmdSuffix = ''; | |
| let position = 'end'; | |
| try { | |
| const cfg = readGsdConfig(dir); | |
| if (getConfigValue(cfg, 'statusline.show_last_command') === true) { | |
| const lastCmd = readLastSlashCommand(data.transcript_path); | |
| if (lastCmd) { | |
| lastCmdSuffix = ` β \x1b[2mlast: /${lastCmd}\x1b[0m`; | |
| } | |
| } | |
| const cfgPos = getConfigValue(cfg, 'statusline.context_position'); | |
| if (cfgPos != null) position = cfgPos; | |
| } catch (e) { /* swallow */ } | |
| const gsdStateStr = formatGsdState(readGsdState(dir) || {}); | |
| const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null; | |
| return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, position }); | |
| } | |
| module.exports.renderStatusline = renderStatusline; | |
| if (require.main === module) runStatusline(); | |