diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f0a9a5e..c2ab31d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **Review model configuration** — Per-CLI model selection for /gsd-review via `review.models.` config keys. Falls back to CLI defaults when not set. (#1849) +- **Statusline now surfaces GSD milestone/phase/status** — when no `in_progress` todo is active, `gsd-statusline.js` reads `.planning/STATE.md` (walking up from the workspace dir) and fills the middle slot with ` · · (N/total)`. Gracefully degrades when fields are missing; identical to previous behavior when there is no STATE.md or an active todo wins the slot. Uses the YAML frontmatter added for #628. ## [1.34.2] - 2026-04-06 diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index dfdfd9a39..0196cfd74 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -1,20 +1,120 @@ #!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} // Claude Code Statusline - GSD Edition -// Shows: model | current task | directory | context usage +// Shows: model | current task (or GSD state) | directory | context usage const fs = require('fs'); const path = require('path'); const os = require('os'); -// Read JSON from stdin -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', () => { +// --- 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); @@ -94,6 +194,9 @@ process.stdin.on('end', () => { } } + // 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. @@ -115,8 +218,14 @@ process.stdin.on('end', () => { // Output const dirname = path.basename(dir); - if (task) { - process.stdout.write(`${gsdUpdate}\x1b[2m${model}\x1b[0m │ \x1b[1m${task}\x1b[0m │ \x1b[2m${dirname}\x1b[0m${ctx}`); + 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}`); } @@ -124,3 +233,9 @@ process.stdin.on('end', () => { // 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(); diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs new file mode 100644 index 000000000..eecbe0ffc --- /dev/null +++ b/tests/gsd-statusline.test.cjs @@ -0,0 +1,249 @@ +/** + * Tests for gsd-statusline.js GSD state display helpers. + * + * Covers: + * - parseStateMd across YAML-frontmatter, body-fallback, and partial formats + * - formatGsdState graceful degradation when fields are missing + * - readGsdState walk-up search with proper bounds + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { parseStateMd, formatGsdState, readGsdState } = require('../hooks/gsd-statusline.js'); + +// ─── parseStateMd ─────────────────────────────────────────────────────────── + +describe('parseStateMd', () => { + test('parses full YAML frontmatter', () => { + const content = [ + '---', + 'status: executing', + 'milestone: v1.9', + 'milestone_name: Code Quality', + '---', + '', + '# State', + 'Phase: 1 of 5 (fix-graphiti-deployment)', + ].join('\n'); + + const s = parseStateMd(content); + assert.equal(s.status, 'executing'); + assert.equal(s.milestone, 'v1.9'); + assert.equal(s.milestoneName, 'Code Quality'); + assert.equal(s.phaseNum, '1'); + assert.equal(s.phaseTotal, '5'); + assert.equal(s.phaseName, 'fix-graphiti-deployment'); + }); + + test('treats literal "null" values as null', () => { + const content = [ + '---', + 'status: null', + 'milestone: null', + 'milestone_name: null', + '---', + ].join('\n'); + + const s = parseStateMd(content); + assert.equal(s.status, null); + assert.equal(s.milestone, null); + assert.equal(s.milestoneName, null); + }); + + test('strips surrounding quotes from frontmatter values', () => { + const content = [ + '---', + 'milestone_name: "Code Quality"', + "milestone: 'v1.9'", + '---', + ].join('\n'); + + const s = parseStateMd(content); + assert.equal(s.milestone, 'v1.9'); + assert.equal(s.milestoneName, 'Code Quality'); + }); + + test('parses phase without name', () => { + const content = [ + '---', + 'status: planning', + '---', + 'Phase: 3 of 10', + ].join('\n'); + + const s = parseStateMd(content); + assert.equal(s.phaseNum, '3'); + assert.equal(s.phaseTotal, '10'); + assert.equal(s.phaseName, null); + }); + + test('falls back to body Status when frontmatter is missing', () => { + const content = [ + '# State', + 'Status: Ready to plan', + ].join('\n'); + + const s = parseStateMd(content); + assert.equal(s.status, 'planning'); + }); + + test('body fallback recognizes executing state', () => { + const content = 'Status: Executing phase 2'; + assert.equal(parseStateMd(content).status, 'executing'); + }); + + test('body fallback recognizes complete state', () => { + const content = 'Status: Complete'; + assert.equal(parseStateMd(content).status, 'complete'); + }); + + test('body fallback recognizes archived as complete', () => { + const content = 'Status: Archived'; + assert.equal(parseStateMd(content).status, 'complete'); + }); + + test('returns empty object for empty content', () => { + const s = parseStateMd(''); + assert.deepEqual(s, {}); + }); + + test('returns partial state when only some fields present', () => { + const content = [ + '---', + 'milestone: v2.0', + '---', + ].join('\n'); + + const s = parseStateMd(content); + assert.equal(s.milestone, 'v2.0'); + assert.equal(s.status, undefined); + assert.equal(s.phaseNum, undefined); + }); +}); + +// ─── formatGsdState ───────────────────────────────────────────────────────── + +describe('formatGsdState', () => { + test('formats full state with milestone name, status, and phase name', () => { + const out = formatGsdState({ + milestone: 'v1.9', + milestoneName: 'Code Quality', + status: 'executing', + phaseNum: '1', + phaseTotal: '5', + phaseName: 'fix-graphiti-deployment', + }); + assert.equal(out, 'v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)'); + }); + + test('skips placeholder "milestone" value in milestoneName', () => { + const out = formatGsdState({ + milestone: 'v1.0', + milestoneName: 'milestone', + status: 'planning', + }); + assert.equal(out, 'v1.0 · planning'); + }); + + test('uses short phase form when phase name is missing', () => { + const out = formatGsdState({ + milestone: 'v2.0', + status: 'executing', + phaseNum: '3', + phaseTotal: '7', + }); + assert.equal(out, 'v2.0 · executing · ph 3/7'); + }); + + test('omits phase entirely when phaseNum/phaseTotal missing', () => { + const out = formatGsdState({ + milestone: 'v1.0', + status: 'planning', + }); + assert.equal(out, 'v1.0 · planning'); + }); + + test('handles milestone version only (no name)', () => { + const out = formatGsdState({ + milestone: 'v1.9', + status: 'executing', + }); + assert.equal(out, 'v1.9 · executing'); + }); + + test('handles milestone name only (no version)', () => { + const out = formatGsdState({ + milestoneName: 'Foundations', + status: 'planning', + }); + assert.equal(out, 'Foundations · planning'); + }); + + test('returns empty string for empty state', () => { + assert.equal(formatGsdState({}), ''); + }); + + test('returns only available parts when everything else is missing', () => { + assert.equal(formatGsdState({ status: 'planning' }), 'planning'); + }); +}); + +// ─── readGsdState ─────────────────────────────────────────────────────────── + +describe('readGsdState', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-statusline-test-')); + + test('finds STATE.md in the starting directory', () => { + const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-')); + fs.mkdirSync(path.join(proj, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(proj, '.planning', 'STATE.md'), + '---\nstatus: executing\nmilestone: v1.0\n---\n' + ); + + const s = readGsdState(proj); + assert.equal(s.status, 'executing'); + assert.equal(s.milestone, 'v1.0'); + }); + + test('walks up to find STATE.md in a parent directory', () => { + const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-')); + fs.mkdirSync(path.join(proj, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(proj, '.planning', 'STATE.md'), + '---\nstatus: planning\n---\n' + ); + + const nested = path.join(proj, 'src', 'components', 'deep'); + fs.mkdirSync(nested, { recursive: true }); + + const s = readGsdState(nested); + assert.equal(s.status, 'planning'); + }); + + test('returns null when no STATE.md exists in the walk-up chain', () => { + const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-')); + const nested = path.join(proj, 'src'); + fs.mkdirSync(nested, { recursive: true }); + + assert.equal(readGsdState(nested), null); + }); + + test('returns null on malformed STATE.md without crashing', () => { + const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-')); + fs.mkdirSync(path.join(proj, '.planning'), { recursive: true }); + // Valid file (no content to crash on) — parseStateMd returns {} + fs.writeFileSync(path.join(proj, '.planning', 'STATE.md'), ''); + + const s = readGsdState(proj); + // Empty file yields an empty state object, not null — the function + // only returns null when no file is found. + assert.deepEqual(s, {}); + }); +});