feat(#2162): opt-in compact GSD-state format for the statusline (#2175)

* feat(#2162): opt-in compact GSD-state format for the statusline

New statusline.state_format config, enum full|compact (default full —
existing rendering untouched). "compact" renders the state segment as
"<version> · P<phase>/<total> · <status>", e.g. "v1.12 · P7/12 ·
executing" — dropping the milestone name and progress bar (the two
biggest width costs) and collapsing narrative statuses to a single
keyword. Per the #2162 approval conditions, the keyword set is the
canonical vocabulary from normalizeStateStatus() in state-document.cjs
(discussing/planning/executing/verifying/completed/paused) — no
parallel hand-rolled list, so the vocabularies can't drift — and the
canonical stuck state "paused" renders uppercase as PAUSED (no new
"blocked" lifecycle state). Statuses the normalizer passes through
unrecognized fall back to their first word capped at 16 chars.
Lifecycle scenes preserved: active_phase wins over the body phase
number, milestone completion renders "complete", idle-with-next-action
renders "next <action> <phases>".

Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg

* docs(#2162): changeset fragment for PR #2175

* fix(#2162): review fixes — ENUM_KEYS coverage, cap boundary tests, changeset format

- register statusline.state_format in the fix-1628 coercion-bypass matrix
- 15/16/17-char boundary tests for the shortGsdStatus fallback cap
- changeset body ends with the (#2162) citation per house convention

Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg

* fix(#2162): round-2 review fixes — scene exclusivity, direct config-set coverage

- compact renderer gates the milestone-complete scene behind the absence of
  an in-flight phase id, mirroring formatGsdState's if/else precedence
  (Scene 1 beats Scene 3); regression test covers the non-atomic
  active_phase + percent=100 STATE.md shape
- direct config-set accept/reject test for statusline.state_format plain
  strings (ENUM_KEYS matrix covers only the JSON coercion shapes)

Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg

* test: regenerate golden-install-parity fixtures for the statusline hook change

Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg

* fix(#2162): complete-scene gate matches formatGsdState exactly (+property tests)

Re-review Major: gating done on !phaseId held completion back for the
legacy phaseNum shape — formatGsdState reaches Scene 3 on percent=100
regardless of phaseNum, so compact must too. Gate is now !s.activePhase.
The phaseNum-only test now expects 'complete' and cross-checks the full
renderer; a parity test feeds identical inputs to both renderers.
Re-review Minor: shortGsdStatus gets fast-check property coverage
(totality, canonical fixed points, separator safety, fallback shape).
Golden fixtures regenerated for the hook byte change.

Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg
This commit is contained in:
Cody Anderson
2026-07-14 19:09:24 -06:00
committed by GitHub
parent eec9efc351
commit 20ff405cb3
26 changed files with 432 additions and 31 deletions

View File

@@ -11,6 +11,7 @@ const os = require('os');
const childProcess = require('child_process');
const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
// --- Config + last-command readers ------------------------------------------
@@ -319,6 +320,78 @@ function contextTokenSuffix(currentUsage) {
return total > 0 ? ` (${formatTokens(total)})` : '';
}
// --- Compact state format (opt-in) ---------------------------------------------
/**
* Collapse GSD's free-text status (often a multi-sentence narrative) to a
* single keyword, built on the canonical normalizer (#2162 approval
* condition): normalizeStateStatus() in state-document.cjs owns the status
* vocabulary (discussing / planning / executing / verifying / completed /
* paused) so the two can't drift. "paused" — the canonical stuck state — is
* uppercased to PAUSED, the one state worth shouting about. Statuses the
* normalizer passes through unrecognized fall back to their first word,
* capped at 16 chars so a rogue STATE.md can't blow up the line.
* Returns null for empty input.
*/
const CANONICAL_STATUSES = ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused'];
function shortGsdStatus(status) {
if (!status) return null;
const norm = normalizeStateStatus(status, null);
if (CANONICAL_STATUSES.includes(norm)) {
return norm === 'paused' ? 'PAUSED' : norm;
}
// Unrecognized free text passes through normalizeStateStatus verbatim —
// fall back to the first word, capped.
const first = String(norm).trim().split(/[\s\u2014\u2013-]+/)[0] || '';
return first ? first.slice(0, 16) : null;
}
/**
* Compact alternative to formatGsdState, selected via
* `statusline.state_format: "compact"`:
*
* "v1.12 · P7/12 · executing" (phase active)
* "v2.0 · P4.5 · BLOCKED" (no total known)
* "v2.0 · complete" (milestone done)
* "v2.0 · next execute-phase 4.5" (idle with a queued action)
*
* Drops the milestone name and progress bar — the biggest width costs in the
* default format — and collapses narrative statuses via shortGsdStatus().
* The default "full" format is untouched.
*/
function formatGsdStateCompact(s) {
const parts = [];
if (s.milestone) parts.push(s.milestone);
const phaseId = s.activePhase || s.phaseNum;
if (phaseId) {
parts.push(s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`);
}
// Scene exclusivity mirrors formatGsdState's if/else chain: an in-flight
// phase (Scene 1, gated on activePhase ONLY — the legacy phaseNum shape
// still completes) wins over milestone-complete (Scene 3), even if a
// non-atomic STATE.md edit leaves percent=100 alongside a lifecycle phase.
const done = !s.activePhase && (Number(s.percent) === 100 ||
(s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases));
if (done) {
parts.push('complete');
} else {
const st = shortGsdStatus(s.status);
if (st) {
parts.push(st);
} else if (!phaseId && s.nextAction) {
const phasesStr = (s.nextPhases && s.nextPhases.length > 0) ? s.nextPhases.join('/') : '';
parts.push(`next ${s.nextAction}${phasesStr ? ' ' + phasesStr : ''}`);
}
}
return parts.join(' \u00b7 ');
}
// --- Model name --------------------------------------------------------------
/**
@@ -529,8 +602,9 @@ function runStatusline() {
}
}
// GSD state (milestone · status · phase) — shown when no todo task
const gsdStateStr = task ? '' : formatGsdState(readGsdState(dir) || {});
// GSD state (milestone · status · phase) — shown when no todo task.
// Format resolved below once config is read (statusline.state_format).
let gsdStateStr = '';
// GSD update available?
// Read only the per-package shared cache file (#607). The legacy
@@ -558,6 +632,7 @@ function runStatusline() {
// Failure here must never break the statusline — wrap the entire lookup.
let lastCmdSuffix = '';
let position = 'end';
let stateFormat = 'full';
let gitSuffix = '';
try {
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
@@ -569,6 +644,7 @@ function runStatusline() {
}
const cfgPos = getConfigValue(cfg, 'statusline.context_position');
if (cfgPos != null) position = cfgPos;
if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
if (getConfigValue(cfg, 'statusline.show_git') === true) {
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
}
@@ -576,6 +652,11 @@ function runStatusline() {
// Never break the statusline on config/transcript/git errors
}
if (!task) {
const state = readGsdState(dir) || {};
gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
}
// Output
const dirname = path.basename(dir);
const middle = task
@@ -675,6 +756,7 @@ module.exports = {
evaluateUpdateCache,
formatTokens,
contextTokenSuffix,
shortGsdStatus, formatGsdStateCompact,
compactModelName,
readGitStatus, parseGitStatus, buildGitSegment,
};
@@ -690,6 +772,7 @@ function renderStatusline(data) {
let lastCmdSuffix = '';
let position = 'end';
let stateFormat = 'full';
let gitSuffix = '';
try {
const cfg = readGsdConfig(dir);
@@ -701,12 +784,14 @@ function renderStatusline(data) {
}
const cfgPos = getConfigValue(cfg, 'statusline.context_position');
if (cfgPos != null) position = cfgPos;
if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
if (getConfigValue(cfg, 'statusline.show_git') === true) {
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
}
} catch (e) { /* swallow */ }
const gsdStateStr = formatGsdState(readGsdState(dir) || {});
const state = readGsdState(dir) || {};
const gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
}