feat(#2833): phase-lifecycle status-line — read-side (parseStateMd + formatGsdState scenes + tests + docs) (#2884)

* 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 <stage>'
    - Scene 2: idle + next_action → 'next <action> <phases>' (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
This commit is contained in:
hoptop
2026-04-30 12:48:49 +08:00
committed by GitHub
parent 87917131f2
commit 8fc1fa263c
3 changed files with 579 additions and 16 deletions

177
docs/STATE-MD-LIFECYCLE.md Normal file
View File

@@ -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 `<status> · <phase>` 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 <stage>` 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)

View File

@@ -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 "<status> · <phase>" 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 "<status> · <phase>" 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(' · ');

View File

@@ -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 <stage>"
* - Scene 2: idle + next_action set → "next <action> <phases>"
* - Scene 3: percent 100 / all done → "milestone complete"
* - Scene 4: default fallback → unchanged "<status> · <phase>"
*
* 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 <stage>"', () => {
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 <action> <phases>"', () => {
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 "<milestone> · <status>"', () => {
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'));
});
});