From 8fc1fa263c8aefe071c83493672a189c1d3376bc Mon Sep 17 00:00:00 2001 From: hoptop Date: Thu, 30 Apr 2026 12:48:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(#2833):=20phase-lifecycle=20status-line=20?= =?UTF-8?q?=E2=80=94=20read-side=20(parseStateMd=20+=20formatGsdState=20sc?= =?UTF-8?q?enes=20+=20tests=20+=20docs)=20(#2884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#2833): parseStateMd reads phase-lifecycle frontmatter fields Extend parseStateMd() to parse 4 new STATE.md frontmatter fields that drive the phase-lifecycle status-line proposed in #2833: - active_phase : phase number when orchestrator is in-flight, null when idle - next_action : recommended next command when idle - next_phases : YAML flow array of phase numbers for next_action - progress : nested block with completed_phases / total_phases / percent All fields default to undefined when absent — formatGsdState() (next commit) degrades gracefully so existing STATE.md files keep rendering as before. YAML scope intentionally narrow: - Only top-level scalar keys (status, milestone, active_phase, next_action) - Only single-line flow array for next_phases ([...]) - progress block requires 2-space indent for nested keys Block sequences (- item over multiple lines) and inline comments inside nested blocks are NOT parsed — keeping the regex-based parser predictable. Comments outside frontmatter or after the closing --- still work. Tests: all 27 existing tests still pass (no behavior change for STATE.md files that don't carry the new fields). Refs #2833 * feat(#2833): formatGsdState renders phase-lifecycle scenes + opt-in progress bar Extend formatGsdState() with three lifecycle scenes that activate when the new STATE.md frontmatter fields (added in the previous commit) are present. Also append an opt-in progress bar to the milestone segment when progress.percent is available. Scenes (first match wins; falls through to the existing path otherwise): 1. active_phase set → 'v2.0 [██░] X% · Phase 4.5 executing' (status field carries the lifecycle stage: discussing / planning / executing / verifying) 2. active_phase null + → 'v2.0 [██░] X% · next execute-phase 4.5' next_action set (idle state — surfaces what the user should run next without opening STATE.md) 3. percent=100 (or → 'v2.0 [██████████] 100% · milestone complete' completed=total) 4. (default fallback) → 'v1.9 Code Quality · executing · ph (1/5)' (existing rendering, byte-for-byte preserved when none of the new fields are populated) Backward compat is the design priority: - STATE.md files without the new fields render identically to v1.38.x - progress bar is opt-in (empty string when percent absent) - Each new scene only activates when its specific fields are populated A new helper renderProgressBar() generates the 10-segment bar that matches the existing context meter style (so the two bars on the status-line are visually consistent). Tests: 27/27 existing tests still pass. Refs #2833 * test(#2833): cover parseStateMd lifecycle fields + formatGsdState scenes 26 new tests organized in 5 describe blocks, modeled after the existing enh-2538-statusline-last-command.test.cjs convention: parseStateMd #2833 lifecycle fields (7 tests) - reads active_phase / next_action / next_phases / progress.percent - 'null' literal handled correctly - YAML flow array parsing (1 item, multiple items) - progress nested block (3 fields) - absent fields return undefined formatGsdState #2833 lifecycle scenes (6 tests) - Scene 1: active_phase set → 'Phase X.Y ' - Scene 2: idle + next_action → 'next ' (1+ phases) - Scene 3: percent=100 OR completed=total → 'milestone complete' formatGsdState #2833 backward compatibility (4 tests) — CRITICAL - Legacy STATE.md (no new fields) renders byte-for-byte unchanged - Empty state, partial state, progress-bar-opt-in all preserved progress bar rendering (6 tests) - 0% / 50% / 100% / clamping / opt-in absence formatGsdState #2833 scene priority (3 tests) - active_phase wins over next_action when both populated - next_action wins over fallback when active_phase null - percent=100 wins over fallback even with phase set Combined run: 53/53 tests pass (existing 27 + new 26). Refs #2833 * docs(#2833): describe phase-lifecycle frontmatter fields and rendering scenes Add docs/STATE-MD-LIFECYCLE.md as the canonical reference for the four new STATE.md frontmatter fields and the four status-line rendering scenes introduced by this proposal: - Frontmatter field reference (active_phase / next_action / next_phases / progress.percent) with type and population semantics - Why progress.percent is intentionally the phase dimension and not the plans dimension (plans dimension trends optimistic when future phases are unplanned) - The four rendering scenes including their priority order - Stage-label convention for Scene 1 (discussing / planning / executing / verifying matching the four phase orchestrators) - Frontmatter parsing constraints — frontmatter must start at file head, no comments inside nested blocks, next_phases is single-line flow only - Backward-compatibility guarantee (locked in by the test suite) - Cross-links to the foundation issue #1989 and the read-side issues this proposal helps close The document deliberately scopes itself to the read-side (what the hook parses, what it renders). Write-side SDK and workflow changes that auto-maintain the fields are out of scope for this PR so each piece can be reviewed independently — see the issue thread for the full proposal. Refs #2833 * test(#2833): simplify '0% renders 10 empty segments' assertion Address CodeRabbit nitpick — drop the convoluted assert.equal that built the expected value via .replace() and rely on the existing assert.ok includes-check. The behavior under test is unchanged; the assertion is just easier to read. Refs #2884 review comment --- docs/STATE-MD-LIFECYCLE.md | 177 ++++++++++ hooks/gsd-statusline.js | 116 ++++++- ...h-2833-phase-lifecycle-statusline.test.cjs | 302 ++++++++++++++++++ 3 files changed, 579 insertions(+), 16 deletions(-) create mode 100644 docs/STATE-MD-LIFECYCLE.md create mode 100644 tests/enh-2833-phase-lifecycle-statusline.test.cjs diff --git a/docs/STATE-MD-LIFECYCLE.md b/docs/STATE-MD-LIFECYCLE.md new file mode 100644 index 000000000..6ec35e38b --- /dev/null +++ b/docs/STATE-MD-LIFECYCLE.md @@ -0,0 +1,177 @@ +# STATE.md Phase Lifecycle Frontmatter + +> **Status:** Reference for the phase-lifecycle status-line proposed in +> [issue #2833](https://github.com/gsd-build/get-shit-done/issues/2833). +> The status-line hook (`hooks/gsd-statusline.js`) reads the fields below; +> SDK write-side support to maintain them is tracked separately. + +GSD's `STATE.md` carries YAML frontmatter that the status-line hook reads on +every render. This document describes the **phase-lifecycle fields** and the +rendering scenes they trigger. + +All four lifecycle fields are **optional and additive**. Existing `STATE.md` +files (without these fields) keep rendering exactly as they did before — no +visual change, no migration required. + +--- + +## Frontmatter fields + +```yaml +--- +gsd_state_version: 1.0 +milestone: v2.0 # existing +milestone_name: Code Quality # existing +status: in_progress # existing — see "status semantics" below + +# Phase-lifecycle additions (issue #2833) — all optional +active_phase: null # phase number when an orchestrator is in flight +next_action: execute-phase # next recommended command when idle +next_phases: ["4.5"] # phases that next_action applies to (1-2 ids) + +progress: # nested block (existing key, percent now opt-in for the bar) + total_phases: 17 + completed_phases: 10 + percent: 59 +--- +``` + +### Field reference + +| Field | Type | When populated | When null/absent | +|---|---|---|---| +| `active_phase` | string (e.g. `"4.5"`) | An orchestrator command is in flight on this phase | Idle between phases | +| `next_action` | string | Idle, with a recommended command (`discuss-phase` / `plan-phase` / `execute-phase` / `verify-phase`) | An orchestrator is in flight, OR no recommendation available | +| `next_phases` | YAML flow array (e.g. `["4.5"]`) | Goes with `next_action` — phases the action applies to | Same as above | +| `progress.percent` | integer 0-100 | Milestone progress in **phase dimension** (`completed_phases / total_phases`) | Bar rendering is opt-in — absent → no bar | + +### `next_phases` parser scope + +Only **single-line YAML flow** is parsed: `next_phases: ["4.5", "4.6"]`. + +Block sequences over multiple lines (`- 4.5\n - 4.6`) are intentionally +**not parsed** — the status-line only needs the primary recommendation, and a +single-line array keeps the regex-based parser predictable. If a project needs +to track many candidate next phases for documentation purposes, store the +extra ones in the `STATE.md` body. + +### `progress.percent` dimension + +The bar rendered next to the milestone version reflects **phase completion** +(`completed_phases / total_phases`), not plan completion. + +Plan dimension (`completed_plans / total_plans`) trends optimistic for any +project where future phases haven't been planned yet — `total_plans` only +counts plans inside *already-planned* phases, so the denominator is +structurally smaller than reality. Reporting that number to stakeholders +overstates progress. + +If a project wants to show plan-level progress somewhere, store it elsewhere +in frontmatter or the body — the status-line bar is reserved for the +phase-dimension number that matches `ROADMAP.md` progress tables and +`MILESTONES.md`. + +--- + +## Status-line rendering scenes + +`formatGsdState()` checks the lifecycle fields in the order below and emits +the **first matching scene**. If none match, the renderer falls through to +the original ` · ` format (byte-for-byte unchanged from +v1.38.x). + +| Scene | Trigger | Display | +|---|---|---| +| **1. Phase active** | `active_phase` populated | `v2.0 [██░░░] X% · Phase 4.5 executing` | +| **2. Idle, next recommended** | `active_phase` null AND `next_action` + `next_phases` populated | `v2.0 [██░░░] X% · next execute-phase 4.5` | +| **3. Milestone complete** | `percent: 100` OR `completed_phases == total_phases` | `v2.0 [██████████] 100% · milestone complete` | +| **4. Default fallback** | None of the above | `v1.9 Code Quality · executing · ph (1/5)` (existing format) | + +### Scene priority example + +When both `active_phase` and `next_action` are populated, **Scene 1 wins** — +an orchestrator is in flight, so any "next recommendation" would be misleading. +This is enforced by check order in `formatGsdState()` and by tests in +`tests/enh-2833-phase-lifecycle-statusline.test.cjs` (suite *"scene priority"*). + +### Stage labels in Scene 1 + +In Scene 1, the second part of `Phase 4.5 ` is whichever value is in +the `status` field at that moment. The convention proposed in issue #2833 +is to use the lifecycle stage: + +| Command | `status` value while in flight | +|---|---| +| `/gsd-discuss-phase` | `discussing` | +| `/gsd-plan-phase` | `planning` | +| `/gsd-execute-phase` | `executing` | +| `/gsd-verify-phase` | `verifying` | + +If `status` is left at `in_progress` (the milestone-level value), Scene 1 +renders just `Phase 4.5` without the stage suffix. + +--- + +## Frontmatter parsing constraints + +The status-line hook uses regex-based parsing (no full YAML library), so a +few constraints apply: + +1. **Frontmatter must start at the very first character of the file.** + Anything (including comments) above the opening `---` invalidates the + match. The opening `---` line must be exactly that — no trailing spaces. + +2. **Comments inside nested blocks are not supported.** + The parser for `progress:` requires the next line to be `[ \t]+\w+:` — + inserting `# comment` between `progress:` and the first key breaks the + match and the bar disappears. Put any documentation in the body of + `STATE.md`, not inside frontmatter blocks. + +3. **`next_phases` accepts only single-line flow format.** + See the parser scope note above. + +These constraints are tested in +`tests/enh-2833-phase-lifecycle-statusline.test.cjs`. If a future change +swaps the regex parser for a real YAML library, the constraints can be +relaxed and the tests updated accordingly. + +--- + +## Backward compatibility + +This document describes additive fields. The promise is: + +- A `STATE.md` file with **none** of the lifecycle fields populated renders + **byte-for-byte identically** to v1.38.x and earlier. +- Adding any lifecycle field is **opt-in per project** — the renderer falls + through to the existing format when fields are absent. +- The progress bar is opt-in even when `progress` block exists — only + `progress.percent` triggers the bar; `total_phases` / `completed_phases` + alone don't. + +The `formatGsdState #2833 backward compatibility` test suite locks this +guarantee in: any change that breaks legacy `STATE.md` rendering will fail +the suite. + +--- + +## Related issues / PRs + +- **#1989** — *enhancement: surface GSD state in statusline.* The foundation + this proposal extends. Established that `STATE.md` frontmatter drives the + status-line. +- **#2833** — *enhancement: phase-lifecycle status-line — auto-rotate + STATE.md frontmatter as phase orchestrators progress.* This document + describes the read-side spec from that issue. Write-side SDK / workflow + changes to auto-maintain the fields are tracked separately so each piece + can be reviewed independently. + +Companion read-side issues this proposal also helps close (each fixed a +specific symptom of the same gap): + +- #1102 — STATE.md frontmatter plan counts only update on plan completion +- #1103 — STATE.md status / last_activity not updated when a phase starts +- #1446 / #1572 — phase complete doesn't update Plans column +- #612 — ROADMAP.md not updating +- #956 — planning document drift across core workflows +- #2018 — verify-work doesn't auto-transition (fixed for verify only) diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 2840052ac..910a9ec1a 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -120,22 +120,60 @@ function readGsdState(dir) { /** * Parse STATE.md frontmatter + Phase line from body. - * Returns { status, milestone, milestoneName, phaseNum, phaseTotal, phaseName } + * + * Returns: + * { status, milestone, milestoneName, phaseNum, phaseTotal, phaseName, + * activePhase, nextAction, nextPhases, completedPhases, totalPhases, percent } + * + * Phase-lifecycle fields (issue #2833): + * - activePhase : phase number ("4.5") when an orchestrator is mid-flight, null otherwise + * - nextAction : recommended next command ("execute-phase") when idle, null otherwise + * - nextPhases : array of phase numbers (["4.5"]) for nextAction, null otherwise + * - completedPhases / totalPhases / percent : milestone progress dimension + * + * All new fields default to undefined when absent — formatGsdState() degrades + * gracefully so existing STATE.md files (without these fields) keep working. */ function parseStateMd(content) { const state = {}; - // YAML frontmatter between --- markers + // YAML frontmatter between --- markers (anchored at file start) const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (fmMatch) { - for (const line of fmMatch[1].split('\n')) { + const fm = fmMatch[1]; + // Top-level scalar key: value + for (const line of fm.split('\n')) { const m = line.match(/^(\w+):\s*(.+)/); if (!m) continue; const [, key, val] = m; const v = val.trim().replace(/^["']|["']$/g, ''); + // status / milestone-level fields (existing — preserved exactly) 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-lifecycle fields (new in issue #2833) + // active_phase: phase number when an orchestrator is in-flight, null when idle + if (key === 'active_phase') state.activePhase = (v === 'null' || v === '') ? null : v; + // next_action: recommended command when idle (discuss-phase / plan-phase / execute-phase / verify-phase) + if (key === 'next_action') state.nextAction = (v === 'null' || v === '') ? null : v; + } + // next_phases YAML flow array: ["4.5", "4.6"] — single-line flow only + // Block sequences (- 4.5 / - 4.6 over multiple lines) are intentionally + // not parsed here; statusline only needs the primary recommendation. + const npMatch = fm.match(/^next_phases:\s*\[([^\]]*)\]/m); + if (npMatch) { + const items = npMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean); + state.nextPhases = items.length > 0 ? items : null; + } + // progress nested block: completed_phases / total_phases / percent (2-space indent) + const progMatch = fm.match(/^progress:\s*\n((?:[ \t]+\w+:.+\n?)+)/m); + if (progMatch) { + const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m); + const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m); + const pc = progMatch[1].match(/^[ \t]+percent:\s*(\d+)/m); + if (cp) state.completedPhases = cp[1]; + if (tp) state.totalPhases = tp[1]; + if (pc) state.percent = pc[1]; } } @@ -161,31 +199,77 @@ function parseStateMd(content) { return state; } +/** + * Render a 10-segment milestone progress bar (matches the context meter style). + * + * @param {number|string|null|undefined} percent — 0-100; missing/NaN returns '' + * @returns {string} '[█████░░░░░] 50%' or '' (so callers can `[bar].filter(Boolean)`) + */ +function renderProgressBar(percent) { + if (percent == null || isNaN(percent)) return ''; + const pct = Math.max(0, Math.min(100, parseInt(percent, 10))); + const filled = Math.floor(pct / 10); + const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); + return `[${bar}] ${pct}%`; +} + /** * Format GSD state into display string. - * Format: "v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)" - * Gracefully degrades when parts are missing. + * + * Backward-compatible default (no new fields populated): + * "v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)" + * + * Phase-lifecycle scenes (issue #2833 — activate when STATE.md frontmatter + * carries the new fields; otherwise rendering falls through to the default): + * + * active_phase set → "v2.0 [██░] X% · Phase 4.5 executing" + * active_phase null + next_action set → "v2.0 [██░] X% · next execute-phase 4.5" + * percent=100 (milestone done) → "v2.0 [██████████] 100% · milestone complete" + * none of the above → existing " · " path + * + * Progress bar is opt-in: appended to the milestone segment only when + * progress.percent is present in frontmatter; absent → empty string. */ function formatGsdState(s) { const parts = []; - // Milestone: version + name (skip placeholder "milestone") + // Milestone segment: version + name + (opt-in) progress bar 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); + const bar = renderProgressBar(s.percent); + const pieces = [ver, name, bar].filter(Boolean); + if (pieces.length > 0) parts.push(pieces.join(' ')); } - // Status - if (s.status) parts.push(s.status); + // Phase-lifecycle scenes (issue #2833) — first match wins; falls through to + // the original " · " path when none of the new fields apply. + const phasesStr = (s.nextPhases && s.nextPhases.length > 0) ? s.nextPhases.join('/') : null; - // Phase - if (s.phaseNum && s.phaseTotal) { - const phase = s.phaseName - ? `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` - : `ph ${s.phaseNum}/${s.phaseTotal}`; - parts.push(phase); + if (s.activePhase) { + // Scene 1: an orchestrator is mid-flight on this phase. + // stage = whichever lifecycle status was written by the orchestrator + // (discussing / planning / executing / verifying) + const stage = s.status || ''; + parts.push(stage ? `Phase ${s.activePhase} ${stage}` : `Phase ${s.activePhase}`); + } else if (s.nextAction && phasesStr) { + // Scene 2: idle + a recommended next command is visible to the user. + // Surfaces "what to run next" without the user opening STATE.md. + parts.push(`next ${s.nextAction} ${phasesStr}`); + } else if (s.percent === '100' || (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases)) { + // Scene 3: milestone complete (every phase done). + parts.push('milestone complete'); + } else { + // Backward-compatible default — preserved EXACTLY for STATE.md files that + // don't carry the new lifecycle fields. Identical output to v1.38.x and + // earlier so no existing project's status-line changes shape. + if (s.status) parts.push(s.status); + 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(' · '); diff --git a/tests/enh-2833-phase-lifecycle-statusline.test.cjs b/tests/enh-2833-phase-lifecycle-statusline.test.cjs new file mode 100644 index 000000000..4b96c6a5f --- /dev/null +++ b/tests/enh-2833-phase-lifecycle-statusline.test.cjs @@ -0,0 +1,302 @@ +/** + * Tests for issue #2833 — phase-lifecycle status-line. + * + * Covers the additions made by the two preceding feat commits: + * + * 1. parseStateMd reads four new STATE.md frontmatter fields + * - active_phase + * - next_action + * - next_phases (YAML flow array) + * - progress (nested block: completed_phases / total_phases / percent) + * + * 2. formatGsdState renders three new scenes when those fields are populated + * - Scene 1: active_phase set → "Phase X.Y " + * - Scene 2: idle + next_action set → "next " + * - Scene 3: percent 100 / all done → "milestone complete" + * - Scene 4: default fallback → unchanged " · " + * + * 3. renderProgressBar() helper for the opt-in milestone bar. + * + * 4. Backward compatibility — existing STATE.md files (without any of the + * new fields) render byte-for-byte identically to v1.38.x. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseStateMd, + formatGsdState, +} = require('../hooks/gsd-statusline.js'); + +// ─── parseStateMd: new lifecycle fields ───────────────────────────────────── + +describe('parseStateMd #2833 lifecycle fields', () => { + test('reads active_phase from frontmatter', () => { + const content = [ + '---', + 'milestone: v2.0', + 'status: executing', + 'active_phase: "4.5"', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.activePhase, '4.5'); + }); + + test('reads next_action from frontmatter', () => { + const content = [ + '---', + 'milestone: v2.0', + 'next_action: execute-phase', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.nextAction, 'execute-phase'); + }); + + test('treats "null" literal as null for active_phase and next_action', () => { + const content = [ + '---', + 'active_phase: null', + 'next_action: null', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.activePhase, null); + assert.equal(s.nextAction, null); + }); + + test('parses next_phases YAML flow array (single item)', () => { + const content = [ + '---', + 'next_phases: ["4.5"]', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.deepEqual(s.nextPhases, ['4.5']); + }); + + test('parses next_phases YAML flow array (multiple items)', () => { + const content = [ + '---', + 'next_phases: ["4.5", "4.6", "5"]', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.deepEqual(s.nextPhases, ['4.5', '4.6', '5']); + }); + + test('parses progress nested block — all three fields', () => { + const content = [ + '---', + 'progress:', + ' total_phases: 17', + ' completed_phases: 10', + ' percent: 59', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.totalPhases, '17'); + assert.equal(s.completedPhases, '10'); + assert.equal(s.percent, '59'); + }); + + test('returns undefined for absent lifecycle fields', () => { + const content = [ + '---', + 'milestone: v1.9', + 'status: executing', + '---', + ].join('\n'); + const s = parseStateMd(content); + assert.equal(s.activePhase, undefined); + assert.equal(s.nextAction, undefined); + assert.equal(s.nextPhases, undefined); + assert.equal(s.percent, undefined); + }); +}); + +// ─── formatGsdState: new scenes ───────────────────────────────────────────── + +describe('formatGsdState #2833 lifecycle scenes', () => { + test('Scene 1 — active_phase set renders "Phase X.Y "', () => { + const out = formatGsdState({ + milestone: 'v2.0', + status: 'executing', + activePhase: '4.5', + percent: '59', + }); + assert.equal(out, 'v2.0 [█████░░░░░] 59% · Phase 4.5 executing'); + }); + + test('Scene 1 — active_phase without status renders "Phase X.Y"', () => { + const out = formatGsdState({ + milestone: 'v2.0', + activePhase: '4.5', + }); + assert.equal(out, 'v2.0 · Phase 4.5'); + }); + + test('Scene 2 — idle + next_action renders "next "', () => { + const out = formatGsdState({ + milestone: 'v2.0', + activePhase: null, + nextAction: 'execute-phase', + nextPhases: ['4.5'], + percent: '59', + }); + assert.equal(out, 'v2.0 [█████░░░░░] 59% · next execute-phase 4.5'); + }); + + test('Scene 2 — multiple next_phases joined with /', () => { + const out = formatGsdState({ + milestone: 'v2.0', + nextAction: 'discuss-phase', + nextPhases: ['4.7', '6.5'], + }); + assert.equal(out, 'v2.0 · next discuss-phase 4.7/6.5'); + }); + + test('Scene 3 — percent=100 renders "milestone complete"', () => { + const out = formatGsdState({ + milestone: 'v2.0', + percent: '100', + }); + assert.equal(out, 'v2.0 [██████████] 100% · milestone complete'); + }); + + test('Scene 3 — completed_phases equals total_phases also triggers complete', () => { + const out = formatGsdState({ + milestone: 'v2.0', + completedPhases: '17', + totalPhases: '17', + }); + assert.equal(out, 'v2.0 · milestone complete'); + }); +}); + +// ─── Backward compatibility — CRITICAL: existing STATE.md unchanged ───────── + +describe('formatGsdState #2833 backward compatibility', () => { + test('legacy STATE.md (only status + milestone + phase) renders unchanged', () => { + // Identical to the format documented in #1989 (the foundation issue). + // No new lifecycle fields populated → must render exactly as v1.38.x did. + const out = formatGsdState({ + status: 'executing', + milestone: 'v1.9', + milestoneName: 'Code Quality', + phaseNum: '1', + phaseTotal: '5', + phaseName: 'fix-graphiti-deployment', + }); + assert.equal(out, 'v1.9 Code Quality · executing · fix-graphiti-deployment (1/5)'); + }); + + test('only status set (no phase, no lifecycle fields) renders just " · "', () => { + const out = formatGsdState({ + milestone: 'v1.9', + status: 'executing', + }); + assert.equal(out, 'v1.9 · executing'); + }); + + test('empty state renders empty string', () => { + const out = formatGsdState({}); + assert.equal(out, ''); + }); + + test('progress.percent is opt-in — absent percent leaves milestone segment unchanged', () => { + const out = formatGsdState({ + milestone: 'v1.9', + milestoneName: 'Code Quality', + status: 'executing', + }); + // No bar rendered when percent is absent. + assert.equal(out, 'v1.9 Code Quality · executing'); + }); +}); + +// ─── renderProgressBar (exported indirectly via formatGsdState behavior) ──── + +describe('progress bar rendering', () => { + test('0% renders 10 empty segments', () => { + // percent=0 doesn't trigger Scene 3 (only percent='100' does), so + // Scene 4 fallback fires with no extra parts — just milestone + bar. + const out = formatGsdState({ milestone: 'v2.0', percent: '0' }); + assert.ok(out.includes('[░░░░░░░░░░] 0%')); + }); + + test('50% renders 5 filled + 5 empty', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '50' }); + assert.ok(out.includes('[█████░░░░░] 50%')); + }); + + test('100% renders 10 filled (and triggers Scene 3)', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '100' }); + assert.equal(out, 'v2.0 [██████████] 100% · milestone complete'); + }); + + test('percent absent → no bar rendered (opt-in)', () => { + const out = formatGsdState({ milestone: 'v2.0', status: 'executing' }); + assert.ok(!out.includes('[')); + assert.ok(!out.includes('░')); + assert.ok(!out.includes('█')); + }); + + test('percent over 100 clamps to 100', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '150' }); + assert.ok(out.includes('[██████████] 100%')); + }); + + test('percent below 0 clamps to 0', () => { + const out = formatGsdState({ milestone: 'v2.0', percent: '-10' }); + assert.ok(out.includes('[░░░░░░░░░░] 0%')); + }); +}); + +// ─── Scene priority — first-match-wins guarantee ──────────────────────────── + +describe('formatGsdState #2833 scene priority', () => { + test('active_phase wins over next_action when both populated', () => { + // active_phase populated should win — orchestrator is in flight, + // any "next" recommendation would be misleading. + const out = formatGsdState({ + milestone: 'v2.0', + status: 'executing', + activePhase: '4.5', + nextAction: 'execute-phase', + nextPhases: ['4.5'], + }); + assert.ok(out.includes('Phase 4.5 executing')); + assert.ok(!out.includes('next execute-phase')); + }); + + test('next_action wins over Scene 4 fallback when active_phase null', () => { + const out = formatGsdState({ + milestone: 'v2.0', + status: 'in_progress', // would be Scene 4 fallback alone + activePhase: null, + nextAction: 'execute-phase', + nextPhases: ['4.5'], + phaseNum: '1', + phaseTotal: '5', + }); + assert.ok(out.includes('next execute-phase 4.5')); + assert.ok(!out.includes('in_progress')); + assert.ok(!out.includes('1/5')); + }); + + test('percent=100 wins over Scene 4 even with phase set', () => { + const out = formatGsdState({ + milestone: 'v2.0', + percent: '100', + phaseNum: '1', + phaseTotal: '5', + }); + assert.ok(out.includes('milestone complete')); + assert.ok(!out.includes('1/5')); + }); +});