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:
Maxim Brashenko
2026-04-11 00:56:19 +05:00
committed by GitHub
parent 46cc28251a
commit cc04baa524
3 changed files with 376 additions and 11 deletions

View File

@@ -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();