feat(statusline): surface GSD milestone/phase/status when no active todo (#1990)
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
This commit is contained in:
@@ -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.<cli>` 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 `<milestone> · <status> · <phase> (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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
249
tests/gsd-statusline.test.cjs
Normal file
249
tests/gsd-statusline.test.cjs
Normal file
@@ -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, {});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user