* test(3579): failing-first coverage for repo-marker inheritance A session that carries an identity but has never run 'workstream use' reads an absent session pointer, resolves null, and composes the flat .planning tree even when .planning/active-workstream names a live workstream. These tests fail on that and pin the invariants the fix must not break: a session with its own pointer is never repointed, and a session that merely lacked a pointer must never clear the shared marker on another session's behalf. * fix(3579): a pointer-less session inherits the repo active-workstream marker RED proven at 157cae26: the three inheritance tests failed while every isolation and negative control passed on base — the gap, and nothing else. pickActiveWorkstreamAdapter returned exactly ONE adapter: the session-scoped one whenever a session key existed, so the shared .planning/active-workstream marker was never consulted. getWorkstreamSessionKey resolves a key from ~13 env vars or the controlling TTY, so on any normal interactive terminal a key almost always exists — which is why a session that had never run 'workstream use' read an absent pointer, resolved null, and composed the FLAT planning tree even though the repo marker named a live workstream. Reads misreported; writes corrupted the superseded flat STATE. Silent, because the stale tree is well-formed. This was a genuine design fork, not an oversight: references/workstream-flag.md documented step 4 as a fallback 'when no session key exists', and the session isolation that buys is deliberate (#2850). The issue's Agent Brief left the choice open and said the reference doc should match whatever semantics ship. The maintainer ruled in chat for inheritance. Resolution now walks an ORDERED chain — session adapter first, shared second — and only a null from the session adapter falls through to the marker. Strictly additive: it can only turn a null into a name, never change a name that already resolves. The dangerous part is clear() ownership. resolveFromChain treats chain[0] as owned: only it is ever cleared, and only under selfHeal (getActiveWorkstream, never peek). An INHERITED marker is read-only — a stale value there resolves null and the file is left alone. Without that, one pointer-less session's read would delete the repo marker for every other session, which is a worse bug than the one being fixed. Covered by a test that asserts the marker still exists on disk after such a read. peekActiveWorkstream inherits but still mutates nothing (#2850 — the statusline draws on every render). references/workstream-flag.md's Resolution Priority is rewritten to match, keeping the session-isolation rationale and noting that inheritance does not weaken it: a session that owns a pointer is never repointed. Fixes #3579 * fix(3579): correct the guard diagnostics and lock the clear-semantics Three review passes; every finding fixed inline. MISSING ACCEPTANCE CRITERION (spec pass). The brief requires refusal diagnostics that distinguish 'marker present but the session lookup missed it' from 'no workstream set at all', and the two workstream-mode fail-safe guards were byte-for-byte untouched — still emitting a generic 'no active workstream is set' even when a marker exists and merely names a missing directory. Both guards (cmdPhaseComplete, cmdInitProgress) now branch on a new read-only diagnoseUnresolvedActiveWorkstream, which reuses the SAME resolvesToExistingWorkstream predicate resolveFromChain uses, so the diagnosis and the resolution cannot disagree. Two typed reasons added to ERROR_REASON; both arms still refuse — the fail-closed behavior is unchanged, only the message is now true. REAL TEST FAILURE, not a flake. The remote run failed 'clearing one session does not clear another session pointer'. That describe uses before() rather than beforeEach, so one tmpDir is shared and an earlier test writes active-workstream=beta into it; under inheritance the just-cleared session picks that marker up and resolves beta instead of null. The failure is a CORRECT consequence of Option A surfaced through an order-dependent fixture. The test now establishes its own marker state explicitly — its real intent (clearing A must not disturb B's pointer) is preserved and not weakened — and a new test pins the semantic deliberately: clearing a session pointer returns that session to INHERITING the marker, it does not force flat mode. Documented in references/workstream-flag.md, including how to actually get flat behavior. Also from review: partial activeWorkstreamAdapters injection no longer silently synthesizes a REAL filesystem adapter for the missing half (a latent test-isolation trap); the duplicated validate-then-existsSync logic is factored into one predicate; and the two try/finally test bodies are converted to t.after per CONTRIBUTING. New coverage: whitespace/empty shared marker; a session whose OWN pointer is stale while the marker names a different valid workstream (must self-heal to null, never inherit — the isolation guarantee at its sharpest); and both new diagnostic arms asserted on structured --json-errors output rather than prose. * fix(3579): read resolvability with the non-mutating peek, not the self-healing resolver Three of our own new tests failed on 7f5e706a. All three had ONE root cause, and none was fixed by relaxing an assertion. gsd-tools.cjs's bootstrap called the MUTATING getActiveWorkstream unconditionally on every invocation, purely to populate routing env. On an unresolvable pointer that self-healed — cleared it — BEFORE the dispatched command ran its own resolution. A second read in the same process then observed already-cleared state: - Isolation violation: a session whose own pointer was stale had it cleared by the bootstrap, so cmdWorkstreamGet's own resolution found a pointer-LESS session and inherited the shared marker ('beta' instead of null). Exactly the guarantee #2850 exists to protect, defeated across two calls rather than within one. - Guard diagnostics: the guards' own truthiness check also used the mutating resolver, so it cleared the invalid marker and the immediately-following read-only diagnosis found nothing and reported none_active instead of marker_unresolved. So a single invocation's answer depended on how many times it resolved. The bootstrap self-heal is PRE-EXISTING and was harmless while pointer-less meant flat — inheritance is what made it answer-changing, so this fix belongs here. Every call site that only CHECKS resolvability — the bootstrap, both fail-safe guards' truthiness check, and two informational init report fields — now uses the non-mutating peekActiveWorkstream. Self-heal is unchanged in active-workstream-store and still fires exactly once, at whichever site actually consumes the workstream. Verified by driving the real CLI against temp fixtures, since the suite cannot run locally: stale-own-pointer resolves null with the marker intact; both guard arms report marker_unresolved with missing_workstream_dir / invalid_name and the marker survives; no-marker still reports none_active; identity-less self-heal still deletes an invalid marker byte-identically to pre-#3579; and a session with a valid own pointer still wins. * chore(3579): backfill changeset PR number (#3616) * test(3579): kill the surviving mutants in the new resolution code CI's Stryker gate failed: active-workstream-store scored 79.45% against a break threshold of 80 — 259 killed, 67 survived, at 'Ran 1.00 tests per mutant on average'. The survivors cluster in the code this PR added (pickActiveWorkstreamAdapterChain, resolvesToExistingWorkstream, resolveFromChain, diagnoseUnresolvedActiveWorkstream): the CLI-level tests exercise those paths but do not DISCRIMINATE their branches, which is precisely what a surviving mutant means. Raised by strengthening assertions, never by touching the threshold. 21 unit tests added to the existing unit suite, each written to fail under a specific named mutant, using the module's injected adapter seams and createMemoryPointerAdapter so they stay hermetic under Stryker's per-mutant reruns: - chain shape with and without a session key, asserting length AND element identity (kills the if(false), the ': []' array mutant, and the block removal) - partial adapter injection, asserting the missing half is an inert memory adapter that never touches the filesystem (kills the three '??' -> '&&' mutants) - both arms of '!name || !validateWorkstreamName(name)' as SEPARATE tests — an absent name and a non-empty invalid one — which is what kills the '||' -> '&&' mutant - self-heal discrimination: getActiveWorkstream must clear an unresolvable owned pointer and peekActiveWorkstream must not, asserted on adapter state after each (kills if(selfHeal) -> if(true)) - fallback arm both ways: a fallback that resolves and one that does not - diagnoseUnresolvedActiveWorkstream asserted as a full object per case, with the reason strings compared exactly (kills present:true -> false and both StringLiteral mutants) One mutant is deliberately left: 'if (chain.length === 0)' -> 'if (false)'. The branch is structurally unreachable — the only chain source always returns a 1- or 2-element array literal — and resolveFromChain is not exported. Killing it would mean exporting an internal or deleting a defensive guard; neither is worth doing for a mutant, and the score clears 80 without it. Recorded here rather than left unexplained. Every new assertion was evaluated against the built module with real fixtures before committing, since the suite cannot run locally. --------- Co-authored-by: sim <sim@local>
506 lines
17 KiB
TypeScript
506 lines
17 KiB
TypeScript
/**
|
|
* Active Workstream Pointer Store Module
|
|
*
|
|
* Owns active workstream source precedence, session identity, and pointer IO:
|
|
* CLI --ws > GSD_WORKSTREAM env > stored active workstream pointer.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/active-workstream-store.cjs
|
|
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
|
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
|
import { isValidActiveWorkstreamName } from './workstream-name-policy.cjs';
|
|
|
|
const WORKSTREAM_SESSION_ENV_KEYS: ReadonlyArray<string> = [
|
|
'GSD_SESSION_KEY',
|
|
'CODEX_THREAD_ID',
|
|
'CLAUDE_SESSION_ID',
|
|
// #3557 — Claude Code (≥ 2.1.132) exports its session id to Bash-tool
|
|
// subprocesses as CLAUDE_CODE_SESSION_ID. Without it the probe returned
|
|
// null on Claude Code, so every concurrent session in a working tree
|
|
// shared the single .planning/active-workstream pointer and cross-
|
|
// workstream STATE.md writes landed silently in the wrong file. Inserted
|
|
// beside the other runtime keys without reordering any existing entry;
|
|
// ahead of CLAUDE_CODE_SSE_PORT so the canonical id wins when both are
|
|
// present (runtime identity outranks terminal identity in this list).
|
|
'CLAUDE_CODE_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',
|
|
];
|
|
|
|
let cachedControllingTtyToken: string | null = null;
|
|
let didProbeControllingTtyToken = false;
|
|
|
|
function planningRoot(cwd: string): string {
|
|
return path.join(cwd, '.planning');
|
|
}
|
|
|
|
function validateWorkstreamName(name: string | null | undefined): boolean {
|
|
return isValidActiveWorkstreamName(name);
|
|
}
|
|
|
|
function sanitizeWorkstreamSessionToken(value: unknown): string | null {
|
|
if (value === null || value === undefined) return null;
|
|
const raw = typeof value === 'string' ? value : `${value as number | boolean}`;
|
|
const token = raw.trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
return token ? token.slice(0, 160) : null;
|
|
}
|
|
|
|
/** Test-only seam: clear the memoized controlling-TTY probe cache (#1191). */
|
|
function _resetControllingTtyCacheForTests(): void {
|
|
cachedControllingTtyToken = null;
|
|
didProbeControllingTtyToken = false;
|
|
}
|
|
|
|
function probeControllingTtyToken(): string | null {
|
|
if (didProbeControllingTtyToken) return cachedControllingTtyToken;
|
|
didProbeControllingTtyToken = true;
|
|
|
|
if (!(process.stdin && process.stdin.isTTY)) {
|
|
return cachedControllingTtyToken;
|
|
}
|
|
|
|
const ttyPath = probeTty();
|
|
if (ttyPath) {
|
|
const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, ''));
|
|
if (token) cachedControllingTtyToken = `tty-${token}`;
|
|
}
|
|
|
|
return cachedControllingTtyToken;
|
|
}
|
|
|
|
function getControllingTtyToken(): string | null {
|
|
for (const envKey of ['TTY', 'SSH_TTY']) {
|
|
const token = sanitizeWorkstreamSessionToken(process.env[envKey]);
|
|
if (token) return `tty-${token.replace(/^dev_/, '')}`;
|
|
}
|
|
|
|
return probeControllingTtyToken();
|
|
}
|
|
|
|
function getWorkstreamSessionKey(): string | null {
|
|
for (const envKey of WORKSTREAM_SESSION_ENV_KEYS) {
|
|
const raw = process.env[envKey];
|
|
const token = sanitizeWorkstreamSessionToken(raw);
|
|
if (token) return `${envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token}`;
|
|
}
|
|
|
|
return getControllingTtyToken();
|
|
}
|
|
|
|
interface SessionScopedWorkstreamFile {
|
|
sessionKey: string;
|
|
dirPath: string;
|
|
filePath: string;
|
|
}
|
|
|
|
function getSessionScopedWorkstreamFile(cwd: string, fixedSessionKey?: string | null): SessionScopedWorkstreamFile | null {
|
|
const sessionKey = fixedSessionKey || getWorkstreamSessionKey();
|
|
if (!sessionKey) return null;
|
|
|
|
let planningAbs: string;
|
|
try {
|
|
planningAbs = fs.realpathSync.native(planningRoot(cwd));
|
|
} catch {
|
|
planningAbs = path.resolve(planningRoot(cwd));
|
|
}
|
|
const projectId = crypto
|
|
.createHash('sha1')
|
|
.update(planningAbs)
|
|
.digest('hex')
|
|
.slice(0, 16);
|
|
|
|
const dirPath = path.join(os.tmpdir(), 'gsd-workstream-sessions', projectId);
|
|
return {
|
|
sessionKey,
|
|
dirPath,
|
|
filePath: path.join(dirPath, sessionKey),
|
|
};
|
|
}
|
|
|
|
interface WorkstreamPointerAdapter {
|
|
read(): string | null;
|
|
write(name: string): void;
|
|
clear(): void;
|
|
}
|
|
|
|
function createSharedPointerAdapter(cwd: string): WorkstreamPointerAdapter {
|
|
const filePath = path.join(planningRoot(cwd), 'active-workstream');
|
|
return {
|
|
read(): string | null {
|
|
const raw = platformReadSync(filePath);
|
|
return raw ? raw.trim() || null : null;
|
|
},
|
|
write(name: string): void {
|
|
platformWriteSync(filePath, name + '\n');
|
|
},
|
|
clear(): void {
|
|
try { fs.unlinkSync(filePath); } catch {}
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSessionScopedPointerAdapter(cwd: string, fixedSessionKey?: string | null): WorkstreamPointerAdapter | null {
|
|
const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey);
|
|
if (!scoped) return null;
|
|
|
|
return {
|
|
read(): string | null {
|
|
const raw = platformReadSync(scoped.filePath);
|
|
return raw ? raw.trim() || null : null;
|
|
},
|
|
write(name: string): void {
|
|
platformEnsureDir(scoped.dirPath);
|
|
platformWriteSync(scoped.filePath, name + '\n');
|
|
},
|
|
clear(): void {
|
|
try { fs.unlinkSync(scoped.filePath); } catch {}
|
|
try {
|
|
const remaining = fs.readdirSync(scoped.dirPath);
|
|
if (remaining.length === 0) {
|
|
fs.rmdirSync(scoped.dirPath);
|
|
}
|
|
} catch {}
|
|
},
|
|
};
|
|
}
|
|
|
|
function createMemoryPointerAdapter(initialName: string | null = null): WorkstreamPointerAdapter {
|
|
let value: string | null = initialName;
|
|
return {
|
|
read(): string | null {
|
|
return value;
|
|
},
|
|
write(name: string): void {
|
|
value = name;
|
|
},
|
|
clear(): void {
|
|
value = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
interface ActiveWorkstreamAdapters {
|
|
session?: WorkstreamPointerAdapter;
|
|
shared?: WorkstreamPointerAdapter;
|
|
}
|
|
|
|
interface ActiveWorkstreamOpts {
|
|
activeWorkstreamAdapter?: WorkstreamPointerAdapter;
|
|
activeWorkstreamAdapters?: ActiveWorkstreamAdapters;
|
|
getStored?: (dir: string) => string | null;
|
|
}
|
|
|
|
function pickActiveWorkstreamAdapter(cwd: string, opts: ActiveWorkstreamOpts = {}): WorkstreamPointerAdapter | null {
|
|
if (opts.activeWorkstreamAdapter) {
|
|
return opts.activeWorkstreamAdapter;
|
|
}
|
|
|
|
const sessionKey = getWorkstreamSessionKey();
|
|
if (sessionKey) {
|
|
if (opts.activeWorkstreamAdapters && opts.activeWorkstreamAdapters.session) {
|
|
return opts.activeWorkstreamAdapters.session;
|
|
}
|
|
return createSessionScopedPointerAdapter(cwd, sessionKey);
|
|
}
|
|
|
|
if (opts.activeWorkstreamAdapters && opts.activeWorkstreamAdapters.shared) {
|
|
return opts.activeWorkstreamAdapters.shared;
|
|
}
|
|
return createSharedPointerAdapter(cwd);
|
|
}
|
|
|
|
/**
|
|
* Read-resolution chain for getActiveWorkstream/peekActiveWorkstream (#3579).
|
|
*
|
|
* pickActiveWorkstreamAdapter (above) picks exactly one adapter and remains
|
|
* the seam for WRITE paths (set/clear), where "which pointer do I mutate" has
|
|
* only one right answer: the session pointer when a session key exists,
|
|
* otherwise the shared marker. Reads are different — a session that has
|
|
* never called `workstream use` has no opinion of its own, so it should
|
|
* inherit the repo-wide `.planning/active-workstream` marker rather than
|
|
* resolve to nothing. This returns an ORDERED chain: [owned, ...fallbacks].
|
|
* `chain[0]` ("owned") is exactly what pickActiveWorkstreamAdapter would have
|
|
* returned — resolveFromChain() self-heals only chain[0], never a fallback,
|
|
* so one session's read can never delete another scope's marker. Fallbacks
|
|
* are consulted ONLY when chain[0].read() comes back absent/empty; a session
|
|
* with its own (even stale/invalid) pointer never falls through — that is
|
|
* the isolation guarantee and it must not be weakened by inheritance.
|
|
*/
|
|
function pickActiveWorkstreamAdapterChain(cwd: string, opts: ActiveWorkstreamOpts = {}): WorkstreamPointerAdapter[] {
|
|
if (opts.activeWorkstreamAdapter) {
|
|
return [opts.activeWorkstreamAdapter];
|
|
}
|
|
|
|
// #3579 item 3: when a caller supplies `opts.activeWorkstreamAdapters` at
|
|
// all, honor ONLY what it provides. The prior `|| createXPointerAdapter(...)`
|
|
// fallback synthesized a REAL filesystem adapter for whichever half a test
|
|
// double omitted — so a test injecting only `{ session }` silently touched
|
|
// the real shared marker file, and one injecting only `{ shared }` silently
|
|
// touched the real session-scoped tmp file. A missing half now gets a
|
|
// no-op in-memory adapter (always reads null) instead — this preserves the
|
|
// chain[0]-is-owned / rest-are-fallback shape resolveFromChain relies on
|
|
// without ever reaching disk. A caller that wants a real adapter for one
|
|
// half can still construct and pass it explicitly.
|
|
const injected = opts.activeWorkstreamAdapters;
|
|
const sessionKey = getWorkstreamSessionKey();
|
|
|
|
if (!sessionKey) {
|
|
const shared = injected
|
|
? (injected.shared ?? createMemoryPointerAdapter(null))
|
|
: createSharedPointerAdapter(cwd);
|
|
return [shared];
|
|
}
|
|
|
|
const session = injected
|
|
? (injected.session ?? createMemoryPointerAdapter(null))
|
|
: createSessionScopedPointerAdapter(cwd, sessionKey);
|
|
const shared = injected
|
|
? (injected.shared ?? createMemoryPointerAdapter(null))
|
|
: createSharedPointerAdapter(cwd);
|
|
|
|
return session ? [session, shared] : [shared];
|
|
}
|
|
|
|
/**
|
|
* Shared "does this stored name resolve" predicate — format-valid AND its
|
|
* workstream directory exists. Factored out so resolveFromChain's owned/
|
|
* fallback arms (and diagnoseUnresolvedActiveWorkstream, #3579 item 1) share
|
|
* one definition of "resolvable" instead of re-deriving the same two checks.
|
|
*/
|
|
function resolvesToExistingWorkstream(cwd: string, name: string | null): name is string {
|
|
if (!name || !validateWorkstreamName(name)) return false;
|
|
return fs.existsSync(path.join(planningRoot(cwd), 'workstreams', name));
|
|
}
|
|
|
|
/**
|
|
* Resolves a stored workstream name by walking an adapter chain.
|
|
*
|
|
* chain[0] is "owned" by this resolution: an absent/empty read falls through
|
|
* to the next adapter, but a present-and-bad read (invalid name, or a name
|
|
* whose workstream dir no longer exists) is resolved right there — self-
|
|
* healed via adapter.clear() when `selfHeal` is true, and never consulted
|
|
* further. Anything after chain[0] is a read-only fallback (the inherited
|
|
* marker): a bad value there resolves to null WITHOUT ever calling clear(),
|
|
* so a pointer-less session's read can never delete the shared marker that
|
|
* other sessions/scopes still depend on.
|
|
*/
|
|
function resolveFromChain(cwd: string, chain: WorkstreamPointerAdapter[], selfHeal: boolean): string | null {
|
|
if (chain.length === 0) return null;
|
|
const [owned, ...fallbacks] = chain;
|
|
|
|
const ownedName = owned.read();
|
|
if (ownedName) {
|
|
if (!resolvesToExistingWorkstream(cwd, ownedName)) {
|
|
if (selfHeal) owned.clear();
|
|
return null;
|
|
}
|
|
return ownedName;
|
|
}
|
|
|
|
for (const adapter of fallbacks) {
|
|
const name = adapter.read();
|
|
if (resolvesToExistingWorkstream(cwd, name)) return name;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Diagnostic sibling of resolveFromChain (#3579 item 1). getActiveWorkstream/
|
|
* peekActiveWorkstream collapse EVERY unresolvable case to `null`, which is
|
|
* exactly right for routing — but a fail-safe guard reporting "no active
|
|
* workstream is set" to an operator needs to distinguish two very different
|
|
* situations that both produce that same `null`:
|
|
*
|
|
* (a) no marker/pointer exists anywhere in the chain at all, vs.
|
|
* (b) a marker/pointer EXISTS (names a value) but that value didn't
|
|
* resolve — either the name fails validateWorkstreamName, or it's a
|
|
* well-formed name whose `workstreams/<name>` directory is missing.
|
|
*
|
|
* Walks the same chain resolveFromChain uses and, for the first adapter that
|
|
* held a non-empty raw value, reports why it didn't resolve. Read-only: never
|
|
* calls adapter.clear() (mirrors peekActiveWorkstream, not getActiveWorkstream
|
|
* — a diagnostic read must not have side effects). Reuses
|
|
* resolvesToExistingWorkstream so this can never disagree with the actual
|
|
* resolution predicate above.
|
|
*/
|
|
function diagnoseUnresolvedActiveWorkstream(
|
|
cwd: string,
|
|
opts: ActiveWorkstreamOpts = {},
|
|
): { present: boolean; value: string | null; reason: 'invalid_name' | 'missing_workstream_dir' | null } {
|
|
const chain = pickActiveWorkstreamAdapterChain(cwd, opts);
|
|
for (const adapter of chain) {
|
|
const raw = adapter.read();
|
|
if (!raw) continue;
|
|
if (resolvesToExistingWorkstream(cwd, raw)) continue;
|
|
return {
|
|
present: true,
|
|
value: raw,
|
|
reason: validateWorkstreamName(raw) ? 'missing_workstream_dir' : 'invalid_name',
|
|
};
|
|
}
|
|
return { present: false, value: null, reason: null };
|
|
}
|
|
|
|
function getActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): string | null {
|
|
const chain = pickActiveWorkstreamAdapterChain(cwd, opts);
|
|
return resolveFromChain(cwd, chain, true);
|
|
}
|
|
|
|
/**
|
|
* 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 chain = pickActiveWorkstreamAdapterChain(cwd, opts);
|
|
return resolveFromChain(cwd, chain, false);
|
|
}
|
|
|
|
function setActiveWorkstream(cwd: string, name: string | null | undefined, opts: ActiveWorkstreamOpts = {}): void {
|
|
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
|
if (!adapter) return;
|
|
|
|
if (!name) {
|
|
adapter.clear();
|
|
return;
|
|
}
|
|
if (!validateWorkstreamName(name)) {
|
|
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots');
|
|
}
|
|
|
|
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
|
platformEnsureDir(wsDir);
|
|
adapter.write(name);
|
|
}
|
|
|
|
function clearActiveWorkstream(cwd: string, opts: ActiveWorkstreamOpts = {}): void {
|
|
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
|
if (!adapter) return;
|
|
adapter.clear();
|
|
}
|
|
|
|
interface ParsedCliWorkstream {
|
|
value: string | null;
|
|
source: string | null;
|
|
args: string[];
|
|
}
|
|
|
|
function parseCliWorkstream(args: string[]): ParsedCliWorkstream {
|
|
const wsEqArg = args.find((arg) => arg.startsWith('--ws='));
|
|
const wsIdx = args.indexOf('--ws');
|
|
|
|
if (wsEqArg) {
|
|
const value = wsEqArg.slice('--ws='.length).trim();
|
|
if (!value) throw new Error('Missing value for --ws');
|
|
return {
|
|
value,
|
|
source: 'cli',
|
|
args: args.filter((arg) => arg !== wsEqArg),
|
|
};
|
|
}
|
|
|
|
if (wsIdx !== -1) {
|
|
const value = args[wsIdx + 1];
|
|
if (!value || value.startsWith('--')) throw new Error('Missing value for --ws');
|
|
return {
|
|
value,
|
|
source: 'cli',
|
|
args: args.filter((_: string, idx: number) => idx !== wsIdx && idx !== wsIdx + 1),
|
|
};
|
|
}
|
|
|
|
return {
|
|
value: null,
|
|
source: null,
|
|
args: args.slice(),
|
|
};
|
|
}
|
|
|
|
interface ResolvedWorkstream {
|
|
ws: string | null;
|
|
source: string;
|
|
args: string[];
|
|
}
|
|
|
|
function resolveActiveWorkstream(
|
|
cwd: string,
|
|
args: string[],
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
deps: ActiveWorkstreamOpts = {}
|
|
): ResolvedWorkstream {
|
|
const parsed = parseCliWorkstream(args);
|
|
const getStored = deps.getStored || ((dir: string) => getActiveWorkstream(dir, deps));
|
|
|
|
let ws: string | null = null;
|
|
let source = 'none';
|
|
|
|
if (parsed.value) {
|
|
ws = parsed.value;
|
|
source = parsed.source ?? 'cli';
|
|
} else if (env && typeof env['GSD_WORKSTREAM'] === 'string' && env['GSD_WORKSTREAM'].trim()) {
|
|
ws = env['GSD_WORKSTREAM'].trim();
|
|
source = 'env';
|
|
} else {
|
|
ws = getStored(cwd) || null;
|
|
source = ws ? 'store' : 'none';
|
|
}
|
|
|
|
if (ws && !validateWorkstreamName(ws)) {
|
|
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots');
|
|
}
|
|
|
|
return {
|
|
ws,
|
|
source,
|
|
args: parsed.args,
|
|
};
|
|
}
|
|
|
|
function applyResolvedWorkstreamEnv(
|
|
resolution: ResolvedWorkstream | null | undefined,
|
|
env: NodeJS.ProcessEnv = process.env
|
|
): void {
|
|
if (!resolution || !resolution.ws) return;
|
|
env['GSD_WORKSTREAM'] = resolution.ws;
|
|
}
|
|
|
|
export = {
|
|
validateWorkstreamName,
|
|
getWorkstreamSessionKey,
|
|
createSharedPointerAdapter,
|
|
createSessionScopedPointerAdapter,
|
|
createMemoryPointerAdapter,
|
|
pickActiveWorkstreamAdapter,
|
|
pickActiveWorkstreamAdapterChain,
|
|
getActiveWorkstream,
|
|
peekActiveWorkstream,
|
|
diagnoseUnresolvedActiveWorkstream,
|
|
setActiveWorkstream,
|
|
clearActiveWorkstream,
|
|
parseCliWorkstream,
|
|
resolveActiveWorkstream,
|
|
applyResolvedWorkstreamEnv,
|
|
_resetControllingTtyCacheForTests,
|
|
};
|