diff --git a/.changeset/sturdy-mice-sprint.md b/.changeset/sturdy-mice-sprint.md new file mode 100644 index 000000000..3f770100f --- /dev/null +++ b/.changeset/sturdy-mice-sprint.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3012 +--- +**Statusline now shows GSD state in workstream mode** — the GSD-state segment used to silently disappear in workstream-mode projects with no root STATE.md, even with an active workstream selected; it now resolves the active workstream (env var or stored pointer) and shows its milestone/phase/progress, or an explicit "no active workstream" message when nothing resolves. (#2850) diff --git a/CONTEXT.md b/CONTEXT.md index c68ef22a9..bfd1617cc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -148,7 +148,7 @@ Module owning runtime identity normalization at runtime-selection seams. Canonic Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; declared and negotiated but not yet consumed by any scheduler — Phase 1 of #2584). `resolveOrchestratorExec(orchestratorExec, cwd) → { ok:true, command, args, cwd } | { ok:false, reason }` (#2584 Phase 2, pure, no I/O — resolves the `runtime.orchestratorExec` descriptor field, a sibling of `runtime.hostBehaviors` in `capability.json` carrying `{command, args?, cwdFlag?}`, into the concrete argv/cwd a process-spawn primitive would use for a `dispatch.isolation: orchestrator-worktree` host; appends `[cwdFlag, cwd]` to `args` when `cwdFlag` is a non-empty string, e.g. codex `exec --cd `, opencode `run --dir `, kimi `--work-dir `; when `cwdFlag` is `null`/absent — kimi-code's process-cwd case — no flag is appended and `cwd` alone is returned for the caller to bind via the subprocess's own working-directory option; fail-closed `missing_command`/`invalid_cwd`/`invalid_args`/`invalid_cwd_flag`; declared and testable but UNCONSUMED — no scheduler spawns anything with it yet, Phase 3 wires it). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016. ### Statusline -Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens` and `statusline.state_format`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope. +Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). `readGsdState()` is workstream-aware (#2850): when the walk-up finds no flat `.planning/STATE.md` but lands on a `.planning/workstreams/` directory, it resolves the active workstream via `resolveActiveWorkstream` (`active-workstream-store.cts`), called with an empty args array — only its env>store precedence applies for this caller, since the CLI leg is inert without argv — and `planningPaths`/`listAvailableWorkstreams` (`planning-workspace.cts`) for path/mode resolution, the same seams every other workstream-aware command uses, and reads that workstream's `STATE.md` instead. The store tier is `peekActiveWorkstream`, a read-only sibling of `getActiveWorkstream` that never deletes a stale/invalid pointer file — a renderer invoked on every prompt must never mutate persistent state as a side effect of drawing a screen (`getActiveWorkstream`'s self-heal is correct for a command, not a render). When workstream mode is detected but nothing resolves, it returns a `{noActiveWorkstream:true}` sentinel that `formatGsdState`/`formatGsdStateCompact` render as `"no active workstream"` — observable, never silent emptiness. Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens` and `statusline.state_format`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope. ### Install Engine Module Module owning the layout-driven runtime-artifact install pipeline — `installRuntimeArtifacts`, `uninstallRuntimeArtifacts`, `installOpencodeFamilySkills`, and their cluster helpers (`_copyStaged`, `_snapshotDir`/`_restoreDir`, legacy-migration + GSD-entry pruning, user-artifact preserve/restore). Extracted from the 12k-line `bin/install.js` (ADR-1239 Phase B, #1679) so adapters import the engine instead of reaching into the installer. Commit-attribution resolution stays in `bin/install.js` and is injected via a `resolveAttribution` parameter (the engine takes no config I/O). Source: `src/install-engine.cts` -> `gsd-core/bin/lib/install-engine.cjs`. diff --git a/docs/how-to/work-in-parallel-with-workstreams.md b/docs/how-to/work-in-parallel-with-workstreams.md index bfbda8872..a245a38cc 100644 --- a/docs/how-to/work-in-parallel-with-workstreams.md +++ b/docs/how-to/work-in-parallel-with-workstreams.md @@ -60,6 +60,8 @@ Shows all workstreams and which one is currently active in your session. From this point forward, all GSD workflow commands operate in the `backend-api` context. The switch is session-scoped: when multiple Claude Code terminals are open on the same repo, each session can hold a different active workstream without interfering with the others. +The statusline's GSD-state segment (milestone, phase, progress) reflects whichever workstream resolves as active — the same `GSD_WORKSTREAM` env var / stored pointer precedence every workstream-aware command uses. If no workstream can be resolved in a workstream-mode project, the segment shows `no active workstream` rather than disappearing silently. + Once switched, drive the normal phase workflow: ```bash diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 899f14c5a..bcd0f6a72 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -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); diff --git a/src/active-workstream-store.cts b/src/active-workstream-store.cts index 34bfc25b0..4422e694f 100644 --- a/src/active-workstream-store.cts +++ b/src/active-workstream-store.cts @@ -232,6 +232,31 @@ function getActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): stri return name; } +/** + * Read-only sibling of getActiveWorkstream (#2850): identical resolution — + * adapter -> stored name -> validate format -> workstream dir exists — but + * NEVER calls adapter.clear(). getActiveWorkstream's self-heal (deleting a + * stale/invalid pointer) is correct for a command that is actively acting on + * the active workstream; it is wrong for a read-only consumer invoked on + * every render (e.g. the statusline hook), which must never mutate + * persistent, possibly cross-session state as a side effect of drawing a + * screen. A stale or invalid pointer simply resolves to null here — the + * caller decides what "unresolvable" means for its own render, and the + * pointer file is left exactly as it was for whatever created it to fix. + */ +function peekActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): string | null { + const adapter = pickActiveWorkstreamAdapter(cwd, opts); + if (!adapter) return null; + + const name = adapter.read(); + if (!name || !validateWorkstreamName(name)) return null; + + const wsDir = path.join(planningRoot(cwd), 'workstreams', name); + if (!fs.existsSync(wsDir)) return null; + + return name; +} + function setActiveWorkstream(cwd: string, name: string | null | undefined, opts: ActiveWorkstreamOpts = {}): void { const adapter = pickActiveWorkstreamAdapter(cwd, opts); if (!adapter) return; @@ -348,6 +373,7 @@ export = { createMemoryPointerAdapter, pickActiveWorkstreamAdapter, getActiveWorkstream, + peekActiveWorkstream, setActiveWorkstream, clearActiveWorkstream, parseCliWorkstream, diff --git a/tests/active-workstream-store.unit.test.cjs b/tests/active-workstream-store.unit.test.cjs index 5d97a85c5..88e0269fe 100644 --- a/tests/active-workstream-store.unit.test.cjs +++ b/tests/active-workstream-store.unit.test.cjs @@ -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'); diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index ffd2eb7f1..00331b38d 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -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) ────────────────── diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 69b2e8644..b3f10ffc6 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -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 };