diff --git a/.changeset/proud-zebras-bark.md b/.changeset/proud-zebras-bark.md new file mode 100644 index 000000000..15e16ad21 --- /dev/null +++ b/.changeset/proud-zebras-bark.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2175 +--- +**Opt-in compact GSD-state statusline format** — new `statusline.state_format` config, enum `full`|`compact` (default `full`, the existing rendering). `compact` renders " · P/ · " (e.g. "v1.12 · P7/12 · executing"), dropping the milestone name and progress bar and collapsing narrative statuses to the canonical vocabulary from `normalizeStateStatus()` — the canonical stuck state `paused` renders uppercase as `PAUSED`. Solves the unbounded-width problem where free-text status sentences push the context meter off the line. (#2162) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 440404c19..a5d640bdc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -453,6 +453,7 @@ If `.planning/` is in `.gitignore`, `commit_docs` is automatically `false` regar | `statusline.show_last_command` | boolean | `false` | Append `last: /` suffix to the statusline showing the most recently invoked slash command. Opt-in; reads the active session transcript to extract the latest `` tag (closes #2538) | | `statusline.context_position` | string | `"end"` | Position of the context-window meter. `"end"` (default) renders at line tail; `"front"` renders immediately after the model name so the meter stays visible in narrow terminals. Closes #2937 | | `statusline.show_context_tokens` | boolean | `false` | Append the absolute token count (e.g. `(156k)`) after the context meter's percentage. Sums input, cache-creation, cache-read, and output tokens from the hook payload — a broader basis than the meter's percentage (which excludes output tokens), so the two figures can diverge slightly. Opt-in; the meter is unchanged when the flag is absent | +| `statusline.state_format` | string | `"full"` | Format of the GSD-state segment. `"full"` (default) is the existing rendering with milestone name and progress bar. `"compact"` renders ` · P/ · ` (e.g. `v1.12 · P7/12 · executing`) — drops the milestone name and bar, and collapses narrative statuses to the canonical keyword set from `normalizeStateStatus()` (`paused` — the canonical stuck state — renders uppercase as `PAUSED`) | | `statusline.show_git` | boolean | `false` | Append a git segment after the directory: current branch plus compact work-state markers (`+staged` `~unstaged` `?untracked` `↑ahead` `↓behind`, or `✓` when clean and in sync). One `git status --porcelain=v2` call per render; the segment is absent outside a git repo or when git is unavailable | The prompt injection guard hook (`gsd-prompt-guard.js`) is always active and cannot be disabled — it's a security feature, not a workflow toggle. diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 67c0f6fde..f0d43d5b9 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -72,6 +72,7 @@ "statusline.show_last_command", "statusline.context_position", "statusline.show_context_tokens", + "statusline.state_format", "statusline.show_git", "workflow.max_discuss_passes", "features.thinking_partner", diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 7b6a3b007..9256f4bd8 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -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 }); } diff --git a/src/config.cts b/src/config.cts index 8779dcb3a..eced9cf77 100644 --- a/src/config.cts +++ b/src/config.cts @@ -770,6 +770,10 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | } } + // Statusline GSD-state format enum validation + const VALID_STATE_FORMATS = ['full', 'compact']; + if (kp === 'statusline.state_format') assertEnumValue(parsedValue, val, VALID_STATE_FORMATS, 'statusline.state_format'); + // statusline.show_git — boolean only if (kp === 'statusline.show_git') { if (typeof parsedValue !== 'boolean') { diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index ae7ad82b8..220ae9466 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -5952,6 +5952,7 @@ const ENUM_KEYS = [ { key: 'workflow.human_verify_mode', member: 'mid-flight' }, { key: 'workflow.context_guard_mode', member: 'off' }, { key: 'statusline.context_position', member: 'front' }, + { key: 'statusline.state_format', member: 'compact' }, { key: 'code_quality.fallow.scope', member: 'phase' }, { key: 'code_quality.fallow.profile', member: 'standard' }, { key: 'plan_review.source_grounding_authority', member: 'grep' }, diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index fa4959393..0f8f25708 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "8bc541aabc2e143c", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "eefea61f9b0e464c", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "e284419f4ce60383", + "hooks/gsd-statusline.js": "ba8422027f710711", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index b17bce74d..4dbbee033 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "c8800819f7443a15", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "05144fab95be6e45", + "hooks/gsd-statusline.js": "85141ec6a067fce2", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 298fe2aa6..eee99afdf 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -111,7 +111,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -398,7 +398,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "5d2c06224db20d23", + "hooks/gsd-statusline.js": "bc03b97ef19328c0", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 06633d0f2..e7f84dd41 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -40,7 +40,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -327,7 +327,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "00d2449afefd2e5f", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "5d2c06224db20d23", + "hooks/gsd-statusline.js": "bc03b97ef19328c0", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index cae8723d4..fb5aa35c5 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -44,7 +44,7 @@ "gsd-core/bin/gsd-tools.cjs": "49dfaa890fdd5627", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 9c9ae39a5..2aa98ad7c 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "7f7a7615b303369a", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "49fe17737e4565f9", + "hooks/gsd-statusline.js": "7cdf1ae0e5b17969", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 7c39208b0..eafa2de64 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -147,7 +147,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index a55bcc385..ac4b1090d 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -42,7 +42,7 @@ "gsd-core/bin/gsd-tools.cjs": "efc88e7691c6c3e6", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "10226e9512dd44bf", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 9ad875e85..6b7cefc3a 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "50658d517405cd63", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index c3820f81b..634aa3e89 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "12ee14a48b678d2b", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "1f58b020a91f032b", "hooks/gsd-read-injection-scanner.js": "f358eca3fa1eab24", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "815e107016e994a1", + "hooks/gsd-statusline.js": "3f59c6becf124608", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index 15a9fab0b..93f262a91 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 4b638fcb9..d591d5a45 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -18,7 +18,7 @@ ".kimi/hooks/gsd-read-guard.js": "9e423cd03e2d1b16", ".kimi/hooks/gsd-read-injection-scanner.js": "c519598b9257aafa", ".kimi/hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - ".kimi/hooks/gsd-statusline.js": "e0a50e21a1e9aeb6", + ".kimi/hooks/gsd-statusline.js": "fb90ca297b60bddf", ".kimi/hooks/gsd-update-banner.js": "55143a25f978f301", ".kimi/hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", ".kimi/hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", @@ -105,7 +105,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 256b45f9a..0cfa02f66 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -399,7 +399,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f72060dfe035f706", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "f93d64704cba14fd", + "hooks/gsd-statusline.js": "982cbb17444935fa", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index 5045a1765..1fe291ea0 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -8,7 +8,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -295,7 +295,7 @@ "hooks/gsd-read-guard.js": "9e423cd03e2d1b16", "hooks/gsd-read-injection-scanner.js": "f454242c010804cf", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "239c1b15d1ff4ef9", + "hooks/gsd-statusline.js": "b59f79b77f53b2a0", "hooks/gsd-update-banner.js": "55143a25f978f301", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 36ddf63e9..dab19ed29 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "205830afac36f33a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", @@ -328,7 +328,7 @@ "hooks/gsd-read-guard.js": "2c8d417d12b51040", "hooks/gsd-read-injection-scanner.js": "396574bd25e99ff9", "hooks/gsd-session-state.sh": "e54379ba86bf1b6d", - "hooks/gsd-statusline.js": "e9cfc9ddfffabe4d", + "hooks/gsd-statusline.js": "390b3601312345ae", "hooks/gsd-update-banner.js": "b457746cb76c1957", "hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38", "hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 23a691ab9..2d534e876 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "c8283c0c8888e357", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index b06e0b1bf..8e63e790f 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -41,7 +41,7 @@ "gsd-core/bin/gsd-tools.cjs": "f21bb9ba5e55f642", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index cfe6a1852..b71668d20 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -112,7 +112,7 @@ "gsd-core/bin/gsd-tools.cjs": "06c046925b7156b7", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", - "gsd-core/bin/shared/config-schema.manifest.json": "ef818b1afd7ee8e4", + "gsd-core/bin/shared/config-schema.manifest.json": "0109a5a9866a24e0", "gsd-core/bin/shared/model-catalog.json": "b55176ca044728d3", "gsd-core/bin/shared/runtime-aliases.manifest.json": "2df2c5ac1957911a", "gsd-core/bin/verify-reapply-patches.cjs": "caec5dbce11e3904", diff --git a/tests/gsd-statusline-state.property.test.cjs b/tests/gsd-statusline-state.property.test.cjs new file mode 100644 index 000000000..e5289070f --- /dev/null +++ b/tests/gsd-statusline-state.property.test.cjs @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Property tests for the compact GSD-state status normalizer (#2162). + * + * shortGsdStatus() collapses free-text STATE.md statuses to a canonical + * keyword (or a capped first-word fallback). As a parsing/normalization + * contract it gets property coverage per the repo testing rules, alongside + * the example-based cases in gsd-statusline.test.cjs. + */ + +const { test, describe } = require('node:test'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { shortGsdStatus } = require('../hooks/gsd-statusline.js'); + +const CANONICAL = ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused']; + +describe('shortGsdStatus properties (#2162)', () => { + test('totality: any string input yields null or a short non-empty string', () => { + fc.assert( + fc.property(fc.string(), (s) => { + const out = shortGsdStatus(s); + if (out === null) return true; + return typeof out === 'string' && out.length > 0 && out.length <= 16; + }), + ); + }); + + test('canonical statuses are fixed points (paused shouts as PAUSED)', () => { + fc.assert( + fc.property(fc.constantFrom(...CANONICAL), (canonical) => { + const out = shortGsdStatus(canonical); + return canonical === 'paused' ? out === 'PAUSED' : out === canonical; + }), + ); + }); + + test('output never contains whitespace or separator characters', () => { + // The compact line joins segments with ' · ' — a status containing + // whitespace or the separator would corrupt the segment structure. + fc.assert( + fc.property(fc.string(), (s) => { + const out = shortGsdStatus(s); + return out === null || !/[\s·—–]/.test(out); + }), + ); + }); + + test('unrecognized free text falls back to its first word, capped at 16', () => { + // Alphabetic words that are not canonical and don't contain canonical + // trigger substrings exercise the fallback path deterministically. + const word = fc.stringMatching(/^[A-Za-z]{1,32}$/).filter((w) => { + const lower = w.toLowerCase(); + return !CANONICAL.some((c) => lower.includes(c.slice(0, 4))); + }); + fc.assert( + fc.property(word, word, (first, second) => { + const out = shortGsdStatus(`${first} ${second}`); + // The normalizer may still map some phrasings to a canonical keyword + // (e.g. synonym tables); otherwise the first word survives, capped. + return out === null || CANONICAL.concat('PAUSED').includes(out) || out === first.slice(0, 16); + }), + ); + }); +}); diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index 04ef43773..082fce1ad 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -1365,6 +1365,244 @@ test('config-set statusline.show_context_tokens yes → rejected', () => { } +// ──────────────────────────────────────────────────────────────────────── +// Compact GSD-state format (statusline.state_format) +// ──────────────────────────────────────────────────────────────────────── +{ + 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 { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + const statusline = require('../hooks/gsd-statusline.js'); + const { shortGsdStatus, formatGsdStateCompact, formatGsdState } = statusline; + const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); + + describe('config schema: statusline.state_format', () => { + test('registers statusline.state_format', () => { + assert.ok( + VALID_CONFIG_KEYS.has('statusline.state_format'), + 'statusline.state_format must be in VALID_CONFIG_KEYS', + ); + }); + // Direct config-set write-path coverage, mirroring the sibling + // context_position tests (the ENUM_KEYS matrix covers only the JSON + // coercion-bypass shapes, not the plain-string paths). + test('config-set accepts "compact" and rejects an invalid plain string', () => { + const tmpDir = createTempProject(); + try { + const ok = runGsdTools(['config-set', 'statusline.state_format', 'compact'], tmpDir); + assert.ok(ok.success, ok.error); + const bad = runGsdTools(['config-set', 'statusline.state_format', 'tiny'], tmpDir); + assert.equal(bad.success, false, 'invalid enum value must be rejected'); + assert.ok( + /statusline\.state_format|Invalid/i.test(bad.error), + `stderr must reference key or "Invalid"; got: ${bad.error}`, + ); + } finally { + cleanup(tmpDir); + } + }); + }); + + describe('shortGsdStatus', () => { + test('returns null for empty input', () => { + assert.equal(shortGsdStatus(null), null); + assert.equal(shortGsdStatus(''), null); + assert.equal(shortGsdStatus(undefined), null); + }); + test('paused — the canonical stuck state — wins and renders uppercase (#2162 condition)', () => { + assert.equal(shortGsdStatus('paused — waiting on credentials'), 'PAUSED'); + assert.equal(shortGsdStatus('stopped by user'), 'PAUSED'); + }); + test('collapses lifecycle narratives to canonical keywords via normalizeStateStatus', () => { + assert.equal(shortGsdStatus('Executing phase 7 of the parser milestone'), 'executing'); + assert.equal(shortGsdStatus('Ready to plan next phase'), 'planning'); + assert.equal(shortGsdStatus('Discussing scope with user'), 'discussing'); + assert.equal(shortGsdStatus('Verifying UAT criteria'), 'verifying'); + assert.equal(shortGsdStatus('Work complete'), 'completed'); + }); + test('matches the canonical vocabulary exactly — no drift from normalizeStateStatus', () => { + const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs'); + for (const canonical of ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused']) { + const rendered = shortGsdStatus(canonical); + const expected = canonical === 'paused' ? 'PAUSED' : canonical; + assert.equal(rendered, expected); + assert.equal(normalizeStateStatus(canonical, null), canonical, + `canonical vocabulary changed upstream: ${canonical}`); + } + }); + test('unknown shapes fall back to the first word, capped at 16 chars', () => { + assert.equal(shortGsdStatus('reticulating splines'), 'reticulating'); + assert.equal(shortGsdStatus('supercalifragilisticexpialidocious state'), 'supercalifragili'); + }); + test('16-char cap boundary: limit-1 / limit / limit+1', () => { + assert.equal(shortGsdStatus('x'.repeat(15)), 'x'.repeat(15)); + assert.equal(shortGsdStatus('x'.repeat(16)), 'x'.repeat(16)); + assert.equal(shortGsdStatus('x'.repeat(17)), 'x'.repeat(16)); + }); + }); + + describe('formatGsdStateCompact', () => { + test('renders version · phase/total · status', () => { + const out = formatGsdStateCompact({ + milestone: 'v1.12', phaseNum: '7', phaseTotal: '12', + status: 'Executing phase 7 — building the parser', + }); + assert.equal(out, 'v1.12 · P7/12 · executing'); + }); + test('prefers lifecycle active_phase over body phase number', () => { + const out = formatGsdStateCompact({ + milestone: 'v2.0', activePhase: '4.5', phaseNum: '4', status: 'executing', + }); + assert.equal(out, 'v2.0 · P4.5 · executing'); + }); + test('paused state renders uppercase in the compact line', () => { + const out = formatGsdStateCompact({ + milestone: 'v2.0', activePhase: '4.5', status: 'paused — waiting on review', + }); + assert.equal(out, 'v2.0 · P4.5 · PAUSED'); + }); + test('milestone completion renders "complete"', () => { + assert.equal(formatGsdStateCompact({ milestone: 'v2.0', percent: '100' }), 'v2.0 · complete'); + assert.equal( + formatGsdStateCompact({ milestone: 'v2.0', completedPhases: '5', totalPhases: '5' }), + 'v2.0 · complete'); + }); + test('scene exclusivity: an in-flight phase wins over milestone-complete', () => { + // Non-atomic STATE.md edits can leave active_phase populated alongside + // percent=100 — the compact format must mirror formatGsdState's + // if/else-chain precedence (Scene 1 beats Scene 3), never render both. + const state = { + milestone: 'v2.0', activePhase: '4.5', percent: '100', status: 'executing', + }; + assert.equal(formatGsdStateCompact(state), 'v2.0 · P4.5 · executing'); + const full = formatGsdState(state); + assert.ok(!/(complete)/.test(full) || !/4\.5/.test(full), + `full format must not co-render phase and complete either; got: ${full}`); + // The legacy body-phase shape (phaseNum, no activePhase) does NOT hold + // completion back — formatGsdState reaches Scene 3 on percent=100 + // regardless of phaseNum, and compact must agree (#2175 re-review Major). + const legacyDone = { milestone: 'v2.0', phaseNum: '5', phaseTotal: '5', percent: '100', status: 'verifying' }; + assert.equal(formatGsdStateCompact(legacyDone), 'v2.0 · P5/5 · complete'); + assert.ok(formatGsdState(legacyDone).includes('milestone complete'), + 'parity: full format must render Scene 3 for the same input'); + }); + test('parity: both renderers agree on completion for the same input', () => { + // Feed identical state objects to both renderers and require they agree + // on whether the milestone reads as complete — the drift guard for the + // parallel rendering surfaces. + const cases = [ + { milestone: 'v2.0', percent: '100' }, + { milestone: 'v2.0', phaseNum: '5', phaseTotal: '5', percent: '100', status: 'verifying' }, + { milestone: 'v2.0', completedPhases: '5', totalPhases: '5' }, + { milestone: 'v2.0', activePhase: '4.5', percent: '100', status: 'executing' }, + { milestone: 'v1.9', percent: '40', status: 'executing', phaseNum: '2', phaseTotal: '5' }, + ]; + for (const s of cases) { + const fullDone = formatGsdState(s).includes('milestone complete'); + const compactDone = / complete$|^complete$/.test(formatGsdStateCompact(s)); + assert.equal(compactDone, fullDone, + `completion parity diverged for ${JSON.stringify(s)}`); + } + }); + test('idle with queued next action renders "next "', () => { + const out = formatGsdStateCompact({ + milestone: 'v2.0', nextAction: 'execute-phase', nextPhases: ['4.5', '4.6'], + }); + assert.equal(out, 'v2.0 · next execute-phase 4.5/4.6'); + }); + test('empty state renders empty string', () => { + assert.equal(formatGsdStateCompact({}), ''); + }); + test('drops the milestone name and progress bar the full format shows', () => { + const state = { + milestone: 'v1.9', milestoneName: 'Code Quality', percent: '40', + status: 'executing', phaseNum: '2', phaseTotal: '5', + }; + const full = formatGsdState(state); + const compact = formatGsdStateCompact(state); + assert.ok(full.includes('Code Quality'), `full keeps name; got: ${full}`); + assert.ok(!compact.includes('Code Quality'), `compact drops name; got: ${compact}`); + assert.ok(!compact.includes('█'), `compact drops bar; got: ${compact}`); + }); + }); + + describe('state_format via renderStatusline', () => { + function makeProject(stateFormat) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'state-fmt-')); + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + if (stateFormat !== undefined) { + fs.writeFileSync( + path.join(dir, '.planning', 'config.json'), + JSON.stringify({ statusline: { state_format: stateFormat } }), + ); + } + fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), [ + '---', + 'milestone: v1.9', + 'milestone_name: Code Quality', + 'status: executing', + '---', + '', + 'Phase: 2 of 5 (parser-rewrite)', + '', + ].join('\n')); + return dir; + } + + test('compact format drops the milestone name', () => { + const dir = makeProject('compact'); + try { + const out = statusline.renderStatusline({ + model: { display_name: 'Claude' }, + workspace: { current_dir: dir }, + }); + assert.ok(out.includes('v1.9 · P2/5 · executing'), `expected compact state; got: ${out}`); + assert.ok(!out.includes('Code Quality'), `expected no milestone name; got: ${out}`); + } finally { + cleanup(dir); + } + }); + + test('default (key absent) keeps the full format unchanged', () => { + const dir = makeProject(undefined); + try { + const out = statusline.renderStatusline({ + model: { display_name: 'Claude' }, + workspace: { current_dir: dir }, + }); + assert.ok(out.includes('Code Quality'), `expected full format; got: ${out}`); + } finally { + cleanup(dir); + } + }); + + test('explicit "full" matches the default rendering', () => { + const dirDefault = makeProject(undefined); + const dirFull = makeProject('full'); + try { + const input = (dir) => ({ + model: { display_name: 'Claude' }, + workspace: { current_dir: dir }, + }); + const a = statusline.renderStatusline(input(dirDefault)); + const b = statusline.renderStatusline(input(dirFull)); + // Same STATE.md content → same rendered middle segment (the trailing + // directory basename differs per temp dir, so compare with it removed) + assert.equal( + a.replace(path.basename(dirDefault), ''), + b.replace(path.basename(dirFull), '')); + } finally { + cleanup(dirDefault); + cleanup(dirFull); + } + }); + }); +} + + // ──────────────────────────────────────────────────────────────────────── // Compact 1M model badge // ────────────────────────────────────────────────────────────────────────