* 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
303 lines
9.7 KiB
JavaScript
303 lines
9.7 KiB
JavaScript
/**
|
|
* 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'));
|
|
});
|
|
});
|