* 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:
@@ -10,7 +10,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, saveSessionEnv, restoreSessionEnv, clearSessionEnv } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
validateWorkstreamName,
|
||||
@@ -29,30 +29,10 @@ const {
|
||||
} = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_ENV_KEYS = [
|
||||
'GSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SSE_PORT',
|
||||
'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID', 'WINDSURF_SESSION_ID',
|
||||
'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
|
||||
'TTY', 'SSH_TTY', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
|
||||
];
|
||||
|
||||
function clearSessionEnv() {
|
||||
for (const k of SESSION_ENV_KEYS) delete process.env[k];
|
||||
}
|
||||
|
||||
function saveSessionEnv() {
|
||||
const saved = {};
|
||||
for (const k of SESSION_ENV_KEYS) saved[k] = process.env[k];
|
||||
return saved;
|
||||
}
|
||||
|
||||
function restoreSessionEnv(saved) {
|
||||
for (const k of SESSION_ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
}
|
||||
//
|
||||
// saveSessionEnv/restoreSessionEnv/clearSessionEnv now live in tests/helpers.cjs
|
||||
// (single source of truth — #2850 code review finding: this file's local copy
|
||||
// had already silently diverged from tests/gsd-statusline.test.cjs's copy).
|
||||
|
||||
function makePlanningDir(base, ...workstreams) {
|
||||
const wsDir = path.join(base, '.planning', 'workstreams');
|
||||
|
||||
@@ -18,10 +18,11 @@ const path = require('node:path');
|
||||
const {
|
||||
parseStateMd,
|
||||
formatGsdState,
|
||||
formatGsdStateCompact,
|
||||
readGsdState,
|
||||
isInstalledAheadOfLatest,
|
||||
} = require('../hooks/gsd-statusline.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, saveSessionEnv, restoreSessionEnv, clearSessionEnv } = require('./helpers.cjs');
|
||||
|
||||
// ─── parseStateMd ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -346,6 +347,16 @@ describe('formatGsdState', () => {
|
||||
test('returns only available parts when everything else is missing', () => {
|
||||
assert.equal(formatGsdState({ status: 'planning' }), 'planning');
|
||||
});
|
||||
|
||||
test('renders observable "no active workstream" signal for the #2850 sentinel', () => {
|
||||
assert.equal(formatGsdState({ noActiveWorkstream: true }), 'no active workstream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGsdStateCompact — #2850 sentinel', () => {
|
||||
test('renders observable "no active workstream" signal', () => {
|
||||
assert.equal(formatGsdStateCompact({ noActiveWorkstream: true }), 'no active workstream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstalledAheadOfLatest', () => {
|
||||
@@ -410,6 +421,189 @@ describe('readGsdState', () => {
|
||||
// only returns null when no file is found.
|
||||
assert.deepEqual(s, {});
|
||||
});
|
||||
|
||||
// ─── Workstream mode (#2850) ──────────────────────────────────────────────
|
||||
//
|
||||
// readGsdState previously only ever walked up looking for a flat
|
||||
// .planning/STATE.md — it never resolved an active workstream, so the
|
||||
// GSD-state segment silently disappeared for any workstream-mode project
|
||||
// without a root STATE.md (issue #2850). These cases exercise the fix,
|
||||
// which reuses resolveActiveWorkstream (CLI>env>store precedence,
|
||||
// active-workstream-store.cjs) and listAvailableWorkstreams/planningPaths
|
||||
// (planning-workspace.cjs) rather than re-implementing that resolution.
|
||||
//
|
||||
// saveSessionEnv/restoreSessionEnv/clearSessionEnv come from tests/helpers.cjs
|
||||
// (shared with tests/active-workstream-store.unit.test.cjs — see that file's
|
||||
// comment; #2850 code review caught the two local copies had diverged).
|
||||
|
||||
test('resolves active workstream via GSD_WORKSTREAM env when no root STATE.md exists (#2850)', (t) => {
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
||||
'---\nstatus: executing\nmilestone: v1.0\n---\n'
|
||||
);
|
||||
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
t.after(() => restoreSessionEnv(saved));
|
||||
process.env.GSD_WORKSTREAM = 'bot';
|
||||
|
||||
const s = readGsdState(proj);
|
||||
assert.notEqual(s, null, 'must not silently return null when a workstream resolves');
|
||||
assert.equal(s.status, 'executing');
|
||||
assert.equal(s.milestone, 'v1.0');
|
||||
});
|
||||
|
||||
test('resolves via GSD_WORKSTREAM env with CRLF frontmatter (#2850)', (t) => {
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
||||
'---\r\nstatus: planning\r\nmilestone: v2.0\r\n---\r\n'
|
||||
);
|
||||
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
t.after(() => restoreSessionEnv(saved));
|
||||
process.env.GSD_WORKSTREAM = 'bot';
|
||||
|
||||
const s = readGsdState(proj);
|
||||
assert.equal(s.status, 'planning');
|
||||
assert.equal(s.milestone, 'v2.0');
|
||||
});
|
||||
|
||||
test('flat root STATE.md takes precedence over workstream mode when both exist (#2850)', (t) => {
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(proj, '.planning', 'STATE.md'),
|
||||
'---\nstatus: executing\nmilestone: v-flat\n---\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
||||
'---\nstatus: planning\nmilestone: v-ws\n---\n'
|
||||
);
|
||||
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
t.after(() => restoreSessionEnv(saved));
|
||||
process.env.GSD_WORKSTREAM = 'bot';
|
||||
|
||||
const s = readGsdState(proj);
|
||||
assert.equal(s.milestone, 'v-flat', 'flat STATE.md must win — flat-mode behavior stays byte-for-byte unchanged');
|
||||
});
|
||||
|
||||
test('returns an observable "no active workstream" signal when nothing resolves (#2850)', (t) => {
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'other'), { recursive: true });
|
||||
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
t.after(() => restoreSessionEnv(saved));
|
||||
|
||||
const s = readGsdState(proj);
|
||||
assert.deepEqual(s, { noActiveWorkstream: true });
|
||||
});
|
||||
|
||||
test('resolved workstream with no STATE.md yet degrades to null without crashing (#2850)', (t) => {
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
||||
// No STATE.md written under workstreams/bot/ — workstream exists, state doesn't yet.
|
||||
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
t.after(() => restoreSessionEnv(saved));
|
||||
process.env.GSD_WORKSTREAM = 'bot';
|
||||
|
||||
assert.equal(readGsdState(proj), null);
|
||||
});
|
||||
|
||||
test('resolves active workstream via the stored shared pointer file (#2850)', (t) => {
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(proj, '.planning', 'workstreams', 'bot', 'STATE.md'),
|
||||
'---\nstatus: verifying\nmilestone: v3.0\n---\n'
|
||||
);
|
||||
fs.writeFileSync(path.join(proj, '.planning', 'active-workstream'), 'bot\n');
|
||||
|
||||
const { _resetControllingTtyCacheForTests } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
_resetControllingTtyCacheForTests();
|
||||
t.after(() => {
|
||||
restoreSessionEnv(saved);
|
||||
_resetControllingTtyCacheForTests();
|
||||
});
|
||||
|
||||
const s = readGsdState(proj);
|
||||
assert.equal(s.status, 'verifying');
|
||||
assert.equal(s.milestone, 'v3.0');
|
||||
});
|
||||
|
||||
test('whitespace-only stored pointer file is treated as no active workstream (#2850)', (t) => {
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'bot'), { recursive: true });
|
||||
fs.writeFileSync(path.join(proj, '.planning', 'active-workstream'), ' \n');
|
||||
|
||||
const { _resetControllingTtyCacheForTests } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
_resetControllingTtyCacheForTests();
|
||||
t.after(() => {
|
||||
restoreSessionEnv(saved);
|
||||
_resetControllingTtyCacheForTests();
|
||||
});
|
||||
|
||||
assert.deepEqual(readGsdState(proj), { noActiveWorkstream: true });
|
||||
});
|
||||
|
||||
test('stored pointer naming an absent workstream dir degrades to the sentinel WITHOUT deleting the pointer file (#2850)', (t) => {
|
||||
// Regression for a review finding: readGsdState previously resolved via
|
||||
// resolveActiveWorkstream's DEFAULT store lookup (getActiveWorkstream),
|
||||
// which self-heals a stale pointer by deleting it (adapter.clear() in
|
||||
// active-workstream-store.cts). The statusline renders once per prompt,
|
||||
// so a merely-stale pointer (mid-rename, mid-cleanup, or any transient
|
||||
// absence of the workstream dir) would be silently unlinked by a hook
|
||||
// whose only job is to display text — violating issue #2850's AC4 ("the
|
||||
// fix is purely additive to what's displayed"). The fix routes through
|
||||
// peekActiveWorkstream, a read-only sibling that never calls clear().
|
||||
// This test asserts BOTH halves: the render still degrades usefully,
|
||||
// AND the pointer file survives the render untouched.
|
||||
const proj = fs.mkdtempSync(path.join(tmpRoot, 'proj-'));
|
||||
// workstream mode is detected via .planning/workstreams/ existing — but
|
||||
// note it does NOT contain a 'ghost' directory, so the pointer below
|
||||
// names a workstream that does not exist.
|
||||
fs.mkdirSync(path.join(proj, '.planning', 'workstreams', 'other'), { recursive: true });
|
||||
const pointerPath = path.join(proj, '.planning', 'active-workstream');
|
||||
fs.writeFileSync(pointerPath, 'ghost\n');
|
||||
|
||||
const { _resetControllingTtyCacheForTests } = require('../gsd-core/bin/lib/active-workstream-store.cjs');
|
||||
const saved = saveSessionEnv();
|
||||
clearSessionEnv();
|
||||
_resetControllingTtyCacheForTests();
|
||||
t.after(() => {
|
||||
restoreSessionEnv(saved);
|
||||
_resetControllingTtyCacheForTests();
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(pointerPath), true, 'precondition: pointer file must exist before the render');
|
||||
|
||||
const s = readGsdState(proj);
|
||||
|
||||
assert.deepEqual(s, { noActiveWorkstream: true }, 'a stale pointer must still degrade to the observable sentinel');
|
||||
assert.equal(
|
||||
fs.existsSync(pointerPath),
|
||||
true,
|
||||
'a read-only render must never delete the pointer file — that is a write, and the statusline must be purely additive to what is displayed (#2850 AC4)'
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(pointerPath, 'utf8'),
|
||||
'ghost\n',
|
||||
'the pointer file content must be byte-for-byte unchanged by the render, not just present'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CLAUDE_CODE_AUTO_COMPACT_WINDOW context meter (#2219) ──────────────────
|
||||
|
||||
@@ -450,4 +450,38 @@ function resetRuntimeWarningCaches() {
|
||||
modelResolver._resetModelOverrideWarningCacheForTests();
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, TOOLS_PATH };
|
||||
/**
|
||||
* Env vars that influence workstream-session identity (getWorkstreamSessionKey
|
||||
* in active-workstream-store.cjs) or workstream/project resolution (planningDir).
|
||||
* Single source of truth for tests that need a deterministic, session-key-free
|
||||
* and workstream/project-free process.env — save/clear before, restore after.
|
||||
* Union of the sets previously hand-duplicated in
|
||||
* tests/active-workstream-store.unit.test.cjs and tests/gsd-statusline.test.cjs
|
||||
* (#2850 code review finding: the two copies had already silently diverged).
|
||||
*/
|
||||
const SESSION_ENV_KEYS = [
|
||||
'GSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SSE_PORT',
|
||||
'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID', 'WINDSURF_SESSION_ID',
|
||||
'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
|
||||
'TTY', 'SSH_TTY', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
|
||||
'GSD_WORKSTREAM', 'GSD_PROJECT',
|
||||
];
|
||||
|
||||
function saveSessionEnv() {
|
||||
const saved = {};
|
||||
for (const k of SESSION_ENV_KEYS) saved[k] = process.env[k];
|
||||
return saved;
|
||||
}
|
||||
|
||||
function restoreSessionEnv(saved) {
|
||||
for (const k of SESSION_ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionEnv() {
|
||||
for (const k of SESSION_ENV_KEYS) delete process.env[k];
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH };
|
||||
|
||||
Reference in New Issue
Block a user