fix(#2850): resolve the active workstream in the statusline GSD-state segment (#3012)

* test(#2850): add failing-first tests for workstream statusline state

readGsdState only ever reads the flat .planning/STATE.md via a directory
walk-up; it has no path for .planning/workstreams/<ws>/STATE.md and never
consults GSD_WORKSTREAM or the stored active-workstream pointer, so the
GSD-state segment silently disappears in workstream mode. These tests
prove the RED before the fix lands.

Uses shared saveSessionEnv/restoreSessionEnv/clearSessionEnv helpers now
added to tests/helpers.cjs (single source of truth for the session-env-var
save/clear/restore pattern also used by tests/active-workstream-store.unit.test.cjs,
which is updated here to consume the same shared helpers instead of its own
local, already-diverged copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#2850): resolve active workstream in the statusline

readGsdState only ever walked up looking for a flat .planning/STATE.md; it
had no branch for .planning/workstreams/<ws>/STATE.md and never consulted
GSD_WORKSTREAM or the stored active-workstream pointer, so the GSD-state
segment silently vanished in workstream-mode projects with no root
STATE.md (exit 0, no diagnostic).

Reuses the existing CLI>env>store resolution seam (resolveActiveWorkstream,
active-workstream-store.cts) and the existing mode-detection/path-building
seam (listAvailableWorkstreams/planningPaths, planning-workspace.cts)
rather than re-implementing either inline. When workstream mode is
detected but nothing resolves, readGsdState now returns a
{noActiveWorkstream:true} sentinel that formatGsdState/formatGsdStateCompact
render as "no active workstream" -- observable, never silent emptiness.
Flat-mode behavior and the case where a resolved workstream has no
STATE.md yet are both unchanged.

Adds active-workstream-store.cts's peekActiveWorkstream: a read-only
sibling of getActiveWorkstream. resolveActiveWorkstream's default store
lookup self-heals a stale/invalid pointer by deleting it
(adapter.clear()) -- correct for a command, but not for a renderer
invoked once per prompt, which must never mutate persistent, possibly
cross-session state as a side effect of drawing a screen. The statusline
now injects peekActiveWorkstream via resolveActiveWorkstream's own
getStored override, keeping the env>store precedence itself fully reused
while removing only the store tier's write side effect. This satisfies
the issue's AC4 ("the fix is purely additive to what's displayed").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#2850): backfill changeset PR number to 3012

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-02 21:44:04 -04:00
committed by GitHub
parent 07de60523c
commit fd07e1a357
8 changed files with 341 additions and 34 deletions

View File

@@ -12,6 +12,17 @@ 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');
// #2850: reuse the existing workstream resolution seams rather than
// re-implementing CLI>env>store precedence or path construction inline.
// peekActiveWorkstream is the read-only sibling of the store-tier lookup
// resolveActiveWorkstream defaults to (getActiveWorkstream) — that default
// self-heals a stale/invalid pointer by deleting it, which is correct for a
// command but not for a renderer invoked on every prompt. Injecting it via
// resolveActiveWorkstream's own `getStored` override keeps the CLI>env>store
// precedence itself fully reused (untouched); only the store tier's *write*
// side effect is removed.
const { resolveActiveWorkstream, peekActiveWorkstream } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
const { listAvailableWorkstreams, planningPaths } = require('../gsd-core/bin/lib/planning-workspace.cjs');
// --- Config + last-command readers ------------------------------------------
@@ -102,21 +113,65 @@ function readLastSlashCommand(transcriptPath) {
// --- GSD state reader -------------------------------------------------------
/**
* Walk up from dir looking for .planning/STATE.md.
* Returns parsed state object or null.
* Read and parse a STATE.md if it exists. Returns the parsed state object,
* `null` when the file is absent, or `null` on any read/parse failure (never
* throws) — the single shared shape for both the flat and workstream reads
* in readGsdState() below.
*/
function readStateFileOrNull(statePath) {
if (!fs.existsSync(statePath)) return null;
try {
return parseStateMd(fs.readFileSync(statePath, 'utf8'));
} catch (e) {
return null;
}
}
/**
* Walk up from dir looking for .planning/STATE.md (flat mode). If an ancestor
* has no flat STATE.md but IS in workstream mode (.planning/workstreams/
* present — the single-source-of-truth check `listAvailableWorkstreams`
* shares with the init.progress/phase.complete #1912/#2028 guards, so this
* can't drift from how every other GSD command detects the mode), resolve
* the active workstream and read that workstream's STATE.md instead (#2850).
*
* Resolution reuses `resolveActiveWorkstream` (active-workstream-store.cjs)
* called with an empty args array, so only its env>store precedence applies
* here — the CLI leg is inert for this renderer, which never receives argv.
* The store tier is `peekActiveWorkstream`, a READ-ONLY sibling of the
* default `getActiveWorkstream`: the default self-heals a stale/invalid
* pointer by deleting it, which is correct for a command but not for a
* renderer invoked on every prompt — a render must never write or delete.
*
* Returns:
* - the parsed state object when a flat or workstream STATE.md is found
* - { noActiveWorkstream: true } when workstream mode is active at an
* ancestor but no workstream can be resolved — an observable signal so
* this is distinguishable from "GSD isn't installed here" (#2850)
* - null when no .planning marker is found at all (GSD not present), or
* when a workstream DOES resolve but its STATE.md doesn't exist yet
* (negative space: mirrors flat-mode's own silent pre-STATE.md window)
*/
function readGsdState(dir) {
const home = os.homedir();
let current = dir;
for (let i = 0; i < 10; i++) {
const candidate = path.join(current, '.planning', 'STATE.md');
if (fs.existsSync(candidate)) {
const flatState = readStateFileOrNull(path.join(current, '.planning', 'STATE.md'));
if (flatState !== null) return flatState;
if (listAvailableWorkstreams(current).length > 0) {
let resolvedWs = null;
try {
return parseStateMd(fs.readFileSync(candidate, 'utf8'));
resolvedWs = resolveActiveWorkstream(current, [], process.env, { getStored: peekActiveWorkstream }).ws;
} catch (e) {
return null;
resolvedWs = null;
}
if (!resolvedWs) return { noActiveWorkstream: true };
return readStateFileOrNull(planningPaths(current, resolvedWs).state);
}
const parent = path.dirname(current);
if (parent === current || current === home) break;
current = parent;
@@ -217,6 +272,10 @@ function parseStateMd(content) {
return state;
}
// #2850: shared literal for formatGsdState/formatGsdStateCompact's "nothing
// resolvable" signal — one source of truth so the two renderers can't drift.
const NO_ACTIVE_WORKSTREAM_LABEL = 'no active workstream';
/**
* Render a 10-segment milestone progress bar (matches the context meter style).
*
@@ -249,6 +308,10 @@ function renderProgressBar(percent) {
* progress.percent is present in frontmatter; absent → empty string.
*/
function formatGsdState(s) {
// #2850: workstream mode with nothing resolvable — an observable signal,
// never silent emptiness (distinguishes from "GSD isn't installed here").
if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL;
const parts = [];
// Milestone segment: version + name + (opt-in) progress bar
@@ -364,6 +427,9 @@ function shortGsdStatus(status) {
* The default "full" format is untouched.
*/
function formatGsdStateCompact(s) {
// #2850: mirrors formatGsdState's observable "nothing resolvable" signal.
if (s.noActiveWorkstream) return NO_ACTIVE_WORKSTREAM_LABEL;
const parts = [];
if (s.milestone) parts.push(s.milestone);