From abb2cb63f6282c1cc7132aa85298f12c737e2e54 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 11:38:13 -0400 Subject: [PATCH] refactor: extract planning-workspace seam from core.cjs (#2901) * refactor: extract planning workspace seam from core * docs: document planning-workspace module and inventory updates * fix: harden planning lock timeout and preserve workstream set contract --------- Co-authored-by: Tom Boucher --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 5 +- docs/CLI-TOOLS.md | 3 +- docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 5 +- get-shit-done/bin/gsd-tools.cjs | 3 +- get-shit-done/bin/lib/audit.cjs | 3 +- get-shit-done/bin/lib/commands.cjs | 3 +- get-shit-done/bin/lib/config.cjs | 3 +- get-shit-done/bin/lib/core.cjs | 338 +--------------- get-shit-done/bin/lib/gap-checker.cjs | 3 +- get-shit-done/bin/lib/init.cjs | 3 +- get-shit-done/bin/lib/milestone.cjs | 3 +- get-shit-done/bin/lib/phase.cjs | 3 +- get-shit-done/bin/lib/planning-workspace.cjs | 371 ++++++++++++++++++ get-shit-done/bin/lib/roadmap.cjs | 3 +- get-shit-done/bin/lib/state.cjs | 3 +- get-shit-done/bin/lib/template.cjs | 3 +- get-shit-done/bin/lib/uat.cjs | 3 +- get-shit-done/bin/lib/verify.cjs | 3 +- get-shit-done/bin/lib/workstream.cjs | 3 +- .../locking-bugs-1909-1916-1925-1927.test.cjs | 13 +- tests/planning-workspace.test.cjs | 167 ++++++++ 23 files changed, 595 insertions(+), 353 deletions(-) create mode 100644 get-shit-done/bin/lib/planning-workspace.cjs create mode 100644 tests/planning-workspace.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 678c19575..5f31a37af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). RC. (#2833) ### Changed — 1.40.0-rc.1 +- **Planning workspace seam extracted from `core.cjs` into `planning-workspace.cjs`** — path/workstream/lock behavior now lives in a dedicated module (`planningDir`, `planningPaths`, `planningRoot`, active-workstream routing, `withPlanningLock`). `core.cjs` keeps compatibility re-exports while call-sites migrate to direct imports, improving locality and reducing coupling. (#2900) - **Skill surface consolidated 86 → 59 `commands/gsd/*.md` entries** — four new grouped skills (`capture`, `phase`, `config`, `workspace`) replace clusters of micro-skills. Six existing parents absorb wrap-up and sub-operations as flags: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f69b189d9..f74baaa53 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -257,12 +257,13 @@ See [`docs/INVENTORY.md`](INVENTORY.md#hooks-11-shipped) for the authoritative 1 ### CLI Tools (`get-shit-done/bin/`) -Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `get-shit-done/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules-24-shipped) for the authoritative roster): +Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `get-shit-done/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules-33-shipped) for the authoritative roster): | Module | Responsibility | | ---------------------- | --------------------------------------------------------------------------------------------------- | -| `core.cjs` | Error handling, output formatting, shared utilities | +| `core.cjs` | Error handling, output formatting, shared utilities; compatibility re-exports for planning helpers | +| `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) | | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 5e5bbb064..679a75e36 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -452,9 +452,10 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs | Module | File | Exports | |--------|------|---------| -| Core | `lib/core.cjs` | `error()`, `output()`, `parseArgs()`, shared utilities | +| Core | `lib/core.cjs` | `error()`, `output()`, `parseArgs()`, shared utilities, compatibility re-exports | | State | `lib/state.cjs` | All `state` subcommands, `state-snapshot` | | Phase | `lib/phase.cjs` | Phase CRUD, `find-phase`, `phase-plan-index`, `phases list` | +| Planning Workspace | `lib/planning-workspace.cjs` | Planning seam: `planningDir`, `planningPaths`, active workstream routing, `.planning/.lock` | | Roadmap | `lib/roadmap.cjs` | Roadmap parsing, phase extraction, progress updates | | Config | `lib/config.cjs` | Config read/write, section initialization | | Verify | `lib/verify.cjs` | All verification and validation commands | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 18a3aa67d..0c6607354 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -265,6 +265,7 @@ "milestone.cjs", "model-profiles.cjs", "phase.cjs", + "planning-workspace.cjs", "profile-output.cjs", "profile-pipeline.cjs", "roadmap.cjs", @@ -291,4 +292,4 @@ "gsd-workflow-guard.js" ] } -} \ No newline at end of file +} diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 078f2d4ff..65dcb8d8d 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -348,7 +348,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (32 shipped) +## CLI Modules (33 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -360,7 +360,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `config-schema.cjs` | Single source of truth for `VALID_CONFIG_KEYS` and dynamic key patterns; imported by both the validator and the config-schema-docs parity test | | `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` | | `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) | -| `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks | +| `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks; compatibility re-exports for planning-workspace helpers | | `decisions.cjs` | Shared parser for CONTEXT.md `` blocks (D-NN entries); used by `gap-checker.cjs` and intended for #2492 plan/verify decision gates | | `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection | | `drift.cjs` | Post-execute codebase structural drift detector (#2003): classifies file changes into new-dir/barrel/migration/route categories and round-trips `last_mapped_commit` frontmatter | @@ -375,6 +375,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `milestone.cjs` | Milestone archival, requirements marking | | `model-profiles.cjs` | Model profile resolution table (authoritative profile data) | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | +| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 6c7e2ebea..cbb6cd92e 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -172,7 +172,8 @@ const fs = require('fs'); const path = require('path'); const core = require('./lib/core.cjs'); -const { error, findProjectRoot, getActiveWorkstream } = core; +const { error, findProjectRoot } = core; +const { getActiveWorkstream } = require('./lib/planning-workspace.cjs'); const state = require('./lib/state.cjs'); const phase = require('./lib/phase.cjs'); const roadmap = require('./lib/roadmap.cjs'); diff --git a/get-shit-done/bin/lib/audit.cjs b/get-shit-done/bin/lib/audit.cjs index 746028993..950875727 100644 --- a/get-shit-done/bin/lib/audit.cjs +++ b/get-shit-done/bin/lib/audit.cjs @@ -11,7 +11,8 @@ const fs = require('fs'); const path = require('path'); -const { planningDir, toPosixPath } = require('./core.cjs'); +const { toPosixPath } = require('./core.cjs'); +const { planningDir } = require('./planning-workspace.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { requireSafePath, sanitizeForDisplay } = require('./security.cjs'); diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index a7743664a..2127ef8b7 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, planningDir, planningPaths, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs'); +const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs'); +const { planningDir, planningPaths } = require('./planning-workspace.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { MODEL_PROFILES } = require('./model-profiles.cjs'); diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index fdb9d3ee4..b927c8ee5 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); -const { output, error, planningDir, withPlanningLock, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs'); +const { output, error, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs'); +const { planningDir, withPlanningLock } = require('./planning-workspace.cjs'); const { VALID_PROFILES, getAgentToModelMapForProfile, diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 808208e09..15c345050 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -5,37 +5,17 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const crypto = require('crypto'); const { execSync, execFileSync, spawnSync } = require('child_process'); const { MODEL_PROFILES } = require('./model-profiles.cjs'); - -const WORKSTREAM_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', -]; - -let cachedControllingTtyToken = null; -let didProbeControllingTtyToken = false; - -// Track all .planning/.lock files held by this process so they can be removed -// on exit. process.on('exit') fires even on process.exit(1), unlike try/finally -// which is skipped when error() calls process.exit(1) inside a locked region (#1916). -const _heldPlanningLocks = new Set(); -process.on('exit', () => { - for (const lockPath of _heldPlanningLocks) { - try { fs.unlinkSync(lockPath); } catch { /* already gone */ } - } -}); +// Compatibility shim: new imports should use planning-workspace.cjs directly. +const { + planningDir, + planningRoot, + planningPaths, + withPlanningLock, + getActiveWorkstream, + setActiveWorkstream, +} = require('./planning-workspace.cjs'); // ─── Path helpers ──────────────────────────────────────────────────────────── @@ -804,304 +784,7 @@ function pruneOrphanedWorktrees(repoRoot) { return pruned; } -/** - * Acquire a file-based lock for .planning/ writes. - * Prevents concurrent worktrees from corrupting shared planning files. - * Lock is auto-released after the callback completes. - */ -function withPlanningLock(cwd, fn) { - const lockPath = path.join(planningDir(cwd), '.lock'); - const lockTimeout = 10000; // 10 seconds - const retryDelay = 100; - const start = Date.now(); - - // Ensure .planning/ exists - try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ } - - while (Date.now() - start < lockTimeout) { - try { - // Atomic create — fails if file exists - fs.writeFileSync(lockPath, JSON.stringify({ - pid: process.pid, - cwd, - acquired: new Date().toISOString(), - }), { flag: 'wx' }); - - // Register for exit-time cleanup so process.exit(1) inside a locked region - // cannot leave a stale lock file (#1916). - _heldPlanningLocks.add(lockPath); - - // Lock acquired — run the function - try { - return fn(); - } finally { - _heldPlanningLocks.delete(lockPath); - try { fs.unlinkSync(lockPath); } catch { /* already released */ } - } - } catch (err) { - if (err.code === 'EEXIST') { - // Lock exists — check if stale (>30s old) - try { - const stat = fs.statSync(lockPath); - if (Date.now() - stat.mtimeMs > 30000) { - fs.unlinkSync(lockPath); - continue; // retry - } - } catch { continue; } - - // Wait and retry (cross-platform, no shell dependency) - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); - continue; - } - throw err; - } - } - // Timeout — force acquire (stale lock recovery) - try { fs.unlinkSync(lockPath); } catch { /* ok */ } - return fn(); -} - -/** - * Get the .planning directory path, project- and workstream-aware. - * - * Resolution order: - * 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to - * `.planning/{project}/` — supports multi-project workspaces where several - * independent projects share a single `.planning/` root directory (e.g., - * an Obsidian vault or monorepo knowledge base used as a command center). - * 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`. - * 3. Otherwise returns `.planning/`. - * - * GSD_PROJECT and GSD_WORKSTREAM can be combined: - * `.planning/{project}/workstreams/{ws}/` - * - * @param {string} cwd - project root - * @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var - * @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var - */ -function planningDir(cwd, ws, project) { - if (project === undefined) project = process.env.GSD_PROJECT || null; - if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; - - // Reject path separators and traversal components in project/workstream names - const BAD_SEGMENT = /[/\\]|\.\./; - if (project && BAD_SEGMENT.test(project)) { - throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`); - } - if (ws && BAD_SEGMENT.test(ws)) { - throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`); - } - - let base = path.join(cwd, '.planning'); - if (project) base = path.join(base, project); - if (ws) base = path.join(base, 'workstreams', ws); - return base; -} - -/** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */ -function planningRoot(cwd) { - return path.join(cwd, '.planning'); -} - -/** - * Get common .planning file paths, project-and-workstream-aware. - * - * All paths route through planningDir(cwd, ws), which honors the GSD_PROJECT - * env var and active workstream. This matches loadConfig() above (line 256), - * which has always read config.json via planningDir(cwd). Previously project - * and config were resolved against the unrouted .planning/ root, which broke - * `gsd-tools config-get` in multi-project layouts (the CRUD writers and the - * reader pointed at different files). - */ -function planningPaths(cwd, ws) { - const base = planningDir(cwd, ws); - return { - planning: base, - state: path.join(base, 'STATE.md'), - roadmap: path.join(base, 'ROADMAP.md'), - project: path.join(base, 'PROJECT.md'), - config: path.join(base, 'config.json'), - phases: path.join(base, 'phases'), - requirements: path.join(base, 'REQUIREMENTS.md'), - }; -} - -// ─── Active Workstream Detection ───────────────────────────────────────────── - -function sanitizeWorkstreamSessionToken(value) { - if (value === null || value === undefined) return null; - const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); - return token ? token.slice(0, 160) : null; -} - -function probeControllingTtyToken() { - if (didProbeControllingTtyToken) return cachedControllingTtyToken; - didProbeControllingTtyToken = true; - - // `tty` reads stdin. When stdin is already non-interactive, spawning it only - // adds avoidable failures on the routing hot path and cannot reveal a stable token. - if (!(process.stdin && process.stdin.isTTY)) { - return cachedControllingTtyToken; - } - - try { - const ttyPath = execFileSync('tty', [], { - encoding: 'utf-8', - stdio: ['inherit', 'pipe', 'ignore'], - }).trim(); - if (ttyPath && ttyPath !== 'not a tty') { - const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, '')); - if (token) cachedControllingTtyToken = `tty-${token}`; - } - } catch {} - - return cachedControllingTtyToken; -} - -function getControllingTtyToken() { - for (const envKey of ['TTY', 'SSH_TTY']) { - const token = sanitizeWorkstreamSessionToken(process.env[envKey]); - if (token) return `tty-${token.replace(/^dev_/, '')}`; - } - - return probeControllingTtyToken(); -} - -/** - * Resolve a deterministic session key for workstream-local routing. - * - * Order: - * 1. Explicit runtime/session env vars (`GSD_SESSION_KEY`, `CODEX_THREAD_ID`, etc.) - * 2. Terminal identity exposed via `TTY` or `SSH_TTY` - * 3. One best-effort `tty` probe when stdin is interactive - * 4. `null`, which tells callers to use the legacy shared pointer fallback - */ -function getWorkstreamSessionKey() { - 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(); -} - -function getSessionScopedWorkstreamFile(cwd) { - const sessionKey = getWorkstreamSessionKey(); - if (!sessionKey) return null; - - // Use realpathSync.native so the hash is derived from the canonical filesystem - // path. On Windows, path.resolve returns whatever case the caller supplied, - // while realpathSync.native returns the case the OS recorded — they differ on - // case-insensitive NTFS, producing different hashes and different tmpdir slots. - // Fall back to path.resolve when the directory does not yet exist. - let planningAbs; - 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), - }; -} - -function clearActiveWorkstreamPointer(filePath, cleanupDirPath) { - try { fs.unlinkSync(filePath); } catch {} - - // Session-scoped pointers for a repo share one tmp directory. Only remove it - // when it is empty so clearing or self-healing one session never deletes siblings. - // Explicitly check remaining entries rather than relying on rmdirSync throwing - // ENOTEMPTY — that error is not raised reliably on Windows. - if (cleanupDirPath) { - try { - const remaining = fs.readdirSync(cleanupDirPath); - if (remaining.length === 0) { - fs.rmdirSync(cleanupDirPath); - } - } catch {} - } -} - -/** - * Pointer files are self-healing: invalid names or deleted-workstream pointers - * are removed on read so the session falls back to `null` instead of carrying - * silent stale state forward. Session-scoped callers may also prune an empty - * per-project tmp directory; shared `.planning/active-workstream` callers do not. - */ -function readActiveWorkstreamPointer(filePath, cwd, cleanupDirPath = null) { - try { - const name = fs.readFileSync(filePath, 'utf-8').trim(); - if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) { - clearActiveWorkstreamPointer(filePath, cleanupDirPath); - return null; - } - const wsDir = path.join(planningRoot(cwd), 'workstreams', name); - if (!fs.existsSync(wsDir)) { - clearActiveWorkstreamPointer(filePath, cleanupDirPath); - return null; - } - return name; - } catch { - return null; - } -} - -/** - * Get the active workstream name. - * - * Resolution priority: - * 1. Session-scoped pointer (tmpdir) when the runtime exposes a stable session key - * 2. Legacy shared `.planning/active-workstream` file when no session key is available - * - * The shared file is intentionally ignored when a session key exists so multiple - * concurrent sessions do not overwrite each other's active workstream. - */ -function getActiveWorkstream(cwd) { - const sessionScoped = getSessionScopedWorkstreamFile(cwd); - if (sessionScoped) { - return readActiveWorkstreamPointer(sessionScoped.filePath, cwd, sessionScoped.dirPath); - } - - const sharedFilePath = path.join(planningRoot(cwd), 'active-workstream'); - return readActiveWorkstreamPointer(sharedFilePath, cwd); -} - -/** - * Set the active workstream. Pass null to clear. - * - * When a stable session key is available, this updates a tmpdir-backed - * session-scoped pointer. Otherwise it falls back to the legacy shared - * `.planning/active-workstream` file for backward compatibility. - */ -function setActiveWorkstream(cwd, name) { - const sessionScoped = getSessionScopedWorkstreamFile(cwd); - const filePath = sessionScoped - ? sessionScoped.filePath - : path.join(planningRoot(cwd), 'active-workstream'); - - if (!name) { - clearActiveWorkstreamPointer(filePath, sessionScoped ? sessionScoped.dirPath : null); - return; - } - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only'); - } - - if (sessionScoped) { - fs.mkdirSync(sessionScoped.dirPath, { recursive: true }); - } - fs.writeFileSync(filePath, name + '\n', 'utf-8'); -} +// ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ─── // ─── Phase utilities ────────────────────────────────────────────────────────── @@ -2155,6 +1838,7 @@ module.exports = { toPosixPath, extractOneLinerFromBody, resolveWorktreeRoot, + // Deprecated re-exports — prefer direct import from planning-workspace.cjs withPlanningLock, findProjectRoot, detectSubRepos, diff --git a/get-shit-done/bin/lib/gap-checker.cjs b/get-shit-done/bin/lib/gap-checker.cjs index 60f5a69a2..5e353e438 100644 --- a/get-shit-done/bin/lib/gap-checker.cjs +++ b/get-shit-done/bin/lib/gap-checker.cjs @@ -16,7 +16,8 @@ const fs = require('fs'); const path = require('path'); -const { planningPaths, planningDir, escapeRegex, output, error } = require('./core.cjs'); +const { escapeRegex, output, error } = require('./core.cjs'); +const { planningPaths, planningDir } = require('./planning-workspace.cjs'); const { parseDecisions } = require('./decisions.cjs'); /** diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 74ae4ec51..1e2654729 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -5,7 +5,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, planningPaths, planningDir, planningRoot, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches } = require('./core.cjs'); +const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches } = require('./core.cjs'); +const { planningPaths, planningDir, planningRoot } = require('./planning-workspace.cjs'); // Accept all bold/colon variants of the Requirements header (#2769): // **Requirements:** / **Requirements**: / **Requirements** : render the diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index 7a023bb11..23c8dd4ba 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizeMd, planningPaths, output, error, atomicWriteFileSync } = require('./core.cjs'); +const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizeMd, output, error, atomicWriteFileSync } = require('./core.cjs'); +const { planningPaths } = require('./planning-workspace.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd, stateReplaceFieldWithFallback } = require('./state.cjs'); diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 3d6fab9e5..9c078e0aa 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories, phaseTokenMatches, atomicWriteFileSync } = require('./core.cjs'); +const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error, readSubdirectories, phaseTokenMatches, atomicWriteFileSync } = require('./core.cjs'); +const { planningDir, withPlanningLock } = require('./planning-workspace.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd, readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs'); diff --git a/get-shit-done/bin/lib/planning-workspace.cjs b/get-shit-done/bin/lib/planning-workspace.cjs new file mode 100644 index 000000000..341128f40 --- /dev/null +++ b/get-shit-done/bin/lib/planning-workspace.cjs @@ -0,0 +1,371 @@ +/** + * Planning Workspace — .planning path resolution + active workstream routing. + * + * This module owns the planning workspace seam: + * - planningDir/planningRoot/planningPaths + * - active workstream pointer policy (session-scoped > shared) + * - pointer storage adapters (session/shared/memory) + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); + +const WORKSTREAM_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', +]; + +let cachedControllingTtyToken = null; +let didProbeControllingTtyToken = false; + +// Track .planning/.lock files held by this process so they can be removed on exit. +const _heldPlanningLocks = new Set(); +process.on('exit', () => { + for (const lockPath of _heldPlanningLocks) { + try { fs.unlinkSync(lockPath); } catch { /* already gone */ } + } +}); + +function planningDir(cwd, ws, project) { + if (project === undefined) project = process.env.GSD_PROJECT || null; + if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; + + // Reject path separators and traversal components in project/workstream names + const BAD_SEGMENT = /[/\\]|\.\./; + if (project && BAD_SEGMENT.test(project)) { + throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`); + } + if (ws && BAD_SEGMENT.test(ws)) { + throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`); + } + + let base = path.join(cwd, '.planning'); + if (project) base = path.join(base, project); + if (ws) base = path.join(base, 'workstreams', ws); + return base; +} + +function planningRoot(cwd) { + return path.join(cwd, '.planning'); +} + +function planningPaths(cwd, ws) { + const base = planningDir(cwd, ws); + return { + planning: base, + state: path.join(base, 'STATE.md'), + roadmap: path.join(base, 'ROADMAP.md'), + project: path.join(base, 'PROJECT.md'), + config: path.join(base, 'config.json'), + phases: path.join(base, 'phases'), + requirements: path.join(base, 'REQUIREMENTS.md'), + }; +} + +function sanitizeWorkstreamSessionToken(value) { + if (value === null || value === undefined) return null; + const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); + return token ? token.slice(0, 160) : null; +} + +function probeControllingTtyToken() { + if (didProbeControllingTtyToken) return cachedControllingTtyToken; + didProbeControllingTtyToken = true; + + // `tty` reads stdin. When stdin is already non-interactive, spawning it only + // adds avoidable failures on the routing hot path and cannot reveal a stable token. + if (!(process.stdin && process.stdin.isTTY)) { + return cachedControllingTtyToken; + } + + try { + const ttyPath = execFileSync('tty', [], { + encoding: 'utf-8', + stdio: ['inherit', 'pipe', 'ignore'], + }).trim(); + if (ttyPath && ttyPath !== 'not a tty') { + const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, '')); + if (token) cachedControllingTtyToken = `tty-${token}`; + } + } catch {} + + return cachedControllingTtyToken; +} + +function getControllingTtyToken() { + for (const envKey of ['TTY', 'SSH_TTY']) { + const token = sanitizeWorkstreamSessionToken(process.env[envKey]); + if (token) return `tty-${token.replace(/^dev_/, '')}`; + } + + return probeControllingTtyToken(); +} + +function getWorkstreamSessionKey() { + 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(); +} + +function getSessionScopedWorkstreamFile(cwd, fixedSessionKey) { + const sessionKey = fixedSessionKey || getWorkstreamSessionKey(); + if (!sessionKey) return null; + + // Use realpathSync.native so the hash is derived from the canonical filesystem + // path. On Windows, path.resolve returns whatever case the caller supplied, + // while realpathSync.native returns the case the OS recorded — they differ on + // case-insensitive NTFS, producing different hashes and different tmpdir slots. + // Fall back to path.resolve when the directory does not yet exist. + let planningAbs; + 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), + }; +} + +function createSharedPointerAdapter(cwd) { + const filePath = path.join(planningRoot(cwd), 'active-workstream'); + return { + read() { + try { + return fs.readFileSync(filePath, 'utf-8').trim() || null; + } catch { + return null; + } + }, + write(name) { + fs.writeFileSync(filePath, name + '\n', 'utf-8'); + }, + clear() { + try { fs.unlinkSync(filePath); } catch {} + }, + }; +} + +function createSessionScopedPointerAdapter(cwd, fixedSessionKey) { + const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey); + if (!scoped) return null; + + return { + read() { + try { + return fs.readFileSync(scoped.filePath, 'utf-8').trim() || null; + } catch { + return null; + } + }, + write(name) { + fs.mkdirSync(scoped.dirPath, { recursive: true }); + fs.writeFileSync(scoped.filePath, name + '\n', 'utf-8'); + }, + clear() { + 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 = null) { + let value = initialName; + return { + read() { + return value; + }, + write(name) { + value = name; + }, + clear() { + value = null; + }, + }; +} + +function pickActiveWorkstreamAdapter(cwd, opts = {}) { + 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); +} + +function validateWorkstreamName(name) { + return /^[a-zA-Z0-9_-]+$/.test(name); +} + +function withPlanningLock(cwd, fn) { + const lockPath = path.join(planningDir(cwd), '.lock'); + const lockTimeout = 10000; // 10 seconds + const start = Date.now(); + + // Ensure .planning/ exists + try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ } + + function runWithHeldLock() { + // Atomic create — fails if file exists + fs.writeFileSync(lockPath, JSON.stringify({ + pid: process.pid, + cwd, + acquired: new Date().toISOString(), + }), { flag: 'wx' }); + + _heldPlanningLocks.add(lockPath); + + // Lock acquired — run the function + try { + return fn(); + } finally { + _heldPlanningLocks.delete(lockPath); + try { fs.unlinkSync(lockPath); } catch { /* already released */ } + } + } + + while (Date.now() - start < lockTimeout) { + try { + return runWithHeldLock(); + } catch (err) { + if (err.code === 'EEXIST') { + // Lock exists — check if stale (>30s old) + try { + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs > 30000) { + fs.unlinkSync(lockPath); + continue; // retry + } + } catch { continue; } + + // Wait and retry (cross-platform, no shell dependency) + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + continue; + } + throw err; + } + } + + // Timeout — stale-lock recovery, then re-acquire atomically before entering critical section. + try { fs.unlinkSync(lockPath); } catch { /* ok */ } + return runWithHeldLock(); +} + +function createPlanningWorkspace(cwd, opts = {}) { + return { + paths: { + dir(ws, project) { + return planningDir(cwd, ws, project); + }, + root() { + return planningRoot(cwd); + }, + all(ws) { + return planningPaths(cwd, ws); + }, + }, + activeWorkstream: { + get() { + const adapter = pickActiveWorkstreamAdapter(cwd, opts); + if (!adapter) return null; + + const name = adapter.read(); + if (!name || !validateWorkstreamName(name)) { + adapter.clear(); + return null; + } + + const wsDir = path.join(planningRoot(cwd), 'workstreams', name); + if (!fs.existsSync(wsDir)) { + adapter.clear(); + return null; + } + + return name; + }, + set(name) { + 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, and underscores only'); + } + + const wsDir = path.join(planningRoot(cwd), 'workstreams', name); + fs.mkdirSync(wsDir, { recursive: true }); + adapter.write(name); + }, + clear() { + const adapter = pickActiveWorkstreamAdapter(cwd, opts); + if (!adapter) return; + adapter.clear(); + }, + }, + }; +} + +function getActiveWorkstream(cwd) { + return createPlanningWorkspace(cwd).activeWorkstream.get(); +} + +function setActiveWorkstream(cwd, name) { + createPlanningWorkspace(cwd).activeWorkstream.set(name); +} + +module.exports = { + createPlanningWorkspace, + createSharedPointerAdapter, + createSessionScopedPointerAdapter, + createMemoryPointerAdapter, + planningDir, + planningRoot, + planningPaths, + withPlanningLock, + getActiveWorkstream, + setActiveWorkstream, +}; diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 25a8b7f85..133f9b4d9 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches, atomicWriteFileSync } = require('./core.cjs'); +const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches, atomicWriteFileSync } = require('./core.cjs'); +const { planningPaths, withPlanningLock } = require('./planning-workspace.cjs'); /** * Coerce an arbitrary YAML scalar/object into a string for cross-cutting diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 4d8d926f0..08692df79 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningDir, planningPaths, output, error, atomicWriteFileSync } = require('./core.cjs'); +const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, output, error, atomicWriteFileSync } = require('./core.cjs'); +const { planningDir, planningPaths } = require('./planning-workspace.cjs'); const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs'); // Cache disk scan results from buildStateFrontmatter per cwd per process (#1967). diff --git a/get-shit-done/bin/lib/template.cjs b/get-shit-done/bin/lib/template.cjs index 95b692cb0..676396190 100644 --- a/get-shit-done/bin/lib/template.cjs +++ b/get-shit-done/bin/lib/template.cjs @@ -4,7 +4,8 @@ const fs = require('fs'); const path = require('path'); -const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, planningDir, output, error } = require('./core.cjs'); +const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, output, error } = require('./core.cjs'); +const { planningDir } = require('./planning-workspace.cjs'); const { reconstructFrontmatter } = require('./frontmatter.cjs'); function cmdTemplateSelect(cwd, planPath, raw) { diff --git a/get-shit-done/bin/lib/uat.cjs b/get-shit-done/bin/lib/uat.cjs index 22d68e0bc..5c989bf2c 100644 --- a/get-shit-done/bin/lib/uat.cjs +++ b/get-shit-done/bin/lib/uat.cjs @@ -7,7 +7,8 @@ const fs = require('fs'); const path = require('path'); -const { output, error, getMilestonePhaseFilter, planningDir, toPosixPath } = require('./core.cjs'); +const { output, error, getMilestonePhaseFilter, toPosixPath } = require('./core.cjs'); +const { planningDir } = require('./planning-workspace.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { requireSafePath, sanitizeForDisplay } = require('./security.cjs'); diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 699ce29e9..9de714c6a 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -5,7 +5,8 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, output, error, checkAgentsInstalled, CONFIG_DEFAULTS } = require('./core.cjs'); +const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS } = require('./core.cjs'); +const { planningDir } = require('./planning-workspace.cjs'); const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs'); const { writeStateMd } = require('./state.cjs'); diff --git a/get-shit-done/bin/lib/workstream.cjs b/get-shit-done/bin/lib/workstream.cjs index 55d69fc93..8b45617cd 100644 --- a/get-shit-done/bin/lib/workstream.cjs +++ b/get-shit-done/bin/lib/workstream.cjs @@ -10,7 +10,8 @@ const fs = require('fs'); const path = require('path'); -const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream, filterPlanFiles, filterSummaryFiles, readSubdirectories } = require('./core.cjs'); +const { output, error, toPosixPath, getMilestoneInfo, generateSlugInternal, filterPlanFiles, filterSummaryFiles, readSubdirectories } = require('./core.cjs'); +const { planningPaths, planningRoot, setActiveWorkstream, getActiveWorkstream } = require('./planning-workspace.cjs'); const { stateExtractField } = require('./state.cjs'); // ─── Migration ────────────────────────────────────────────────────────────── diff --git a/tests/locking-bugs-1909-1916-1925-1927.test.cjs b/tests/locking-bugs-1909-1916-1925-1927.test.cjs index b9d8d1ece..191c50774 100644 --- a/tests/locking-bugs-1909-1916-1925-1927.test.cjs +++ b/tests/locking-bugs-1909-1916-1925-1927.test.cjs @@ -140,16 +140,17 @@ describe('#1916 lock cleanup on process.exit()', () => { ); }); - test('core.cjs .planning/.lock is removed after a command exits with an error', () => { - // The withPlanningLock in core.cjs also needs exit cleanup. - const coreSrc = fs.readFileSync( - path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs'), + test('planning workspace lock owner registers exit cleanup', () => { + // withPlanningLock moved from core.cjs to planning-workspace.cjs. + // The lock owner must keep module-level process exit cleanup. + const workspaceSrc = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'planning-workspace.cjs'), 'utf-8' ); assert.ok( - coreSrc.includes("process.on('exit'"), - "core.cjs must register process.on('exit', ...) to clean up held planning lock files" + workspaceSrc.includes("process.on('exit'"), + "planning-workspace.cjs must register process.on('exit', ...) to clean up held planning lock files" ); }); }); diff --git a/tests/planning-workspace.test.cjs b/tests/planning-workspace.test.cjs new file mode 100644 index 000000000..9431c46ba --- /dev/null +++ b/tests/planning-workspace.test.cjs @@ -0,0 +1,167 @@ +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + createPlanningWorkspace, + createMemoryPointerAdapter, + planningDir, + planningPaths, + withPlanningLock, + getActiveWorkstream, + setActiveWorkstream, +} = require('../get-shit-done/bin/lib/planning-workspace.cjs'); + +const core = require('../get-shit-done/bin/lib/core.cjs'); + +describe('planning-workspace: planningDir/planningPaths parity', () => { + const cwd = '/fake/repo'; + let savedProject; + let savedWorkstream; + + beforeEach(() => { + savedProject = process.env.GSD_PROJECT; + savedWorkstream = process.env.GSD_WORKSTREAM; + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; + }); + + afterEach(() => { + if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject; + else delete process.env.GSD_PROJECT; + if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream; + else delete process.env.GSD_WORKSTREAM; + }); + + test('matches expected path resolution', () => { + assert.strictEqual(planningDir(cwd, null, null), path.join(cwd, '.planning')); + assert.strictEqual(planningDir(cwd, 'feature-x', null), path.join(cwd, '.planning', 'workstreams', 'feature-x')); + assert.strictEqual(planningDir(cwd, 'feature-x', 'my-app'), path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x')); + + const paths = planningPaths(cwd, 'feature-x'); + assert.strictEqual(paths.planning, path.join(cwd, '.planning', 'workstreams', 'feature-x')); + assert.strictEqual(paths.state, path.join(cwd, '.planning', 'workstreams', 'feature-x', 'STATE.md')); + assert.strictEqual(paths.config, path.join(cwd, '.planning', 'workstreams', 'feature-x', 'config.json')); + }); + + test('rejects traversal and path separators', () => { + assert.throws(() => planningDir(cwd, null, '../../etc'), /invalid path characters/); + assert.throws(() => planningDir(cwd, 'foo/bar', null), /invalid path characters/); + assert.throws(() => planningDir(cwd, 'foo\\bar', null), /invalid path characters/); + }); +}); + +describe('planning-workspace: session adapter precedence', () => { + let savedSession; + + beforeEach(() => { + savedSession = process.env.GSD_SESSION_KEY; + }); + + afterEach(() => { + if (savedSession !== undefined) process.env.GSD_SESSION_KEY = savedSession; + else delete process.env.GSD_SESSION_KEY; + }); + + test('uses session adapter over shared adapter when session key exists', () => { + process.env.GSD_SESSION_KEY = 'session-123'; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-planning-precedence-')); + try { + fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'session-ws'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'shared-ws'), { recursive: true }); + + const session = createMemoryPointerAdapter('session-ws'); + const shared = createMemoryPointerAdapter('shared-ws'); + const workspace = createPlanningWorkspace(tmpDir, { + activeWorkstreamAdapters: { session, shared }, + }); + + assert.strictEqual(workspace.activeWorkstream.get(), 'session-ws'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('planning-workspace: self-heal behavior', () => { + test('clears invalid pointer names and returns null', () => { + const adapter = createMemoryPointerAdapter('bad/name'); + const workspace = createPlanningWorkspace('/fake/repo', { + activeWorkstreamAdapter: adapter, + }); + + assert.strictEqual(workspace.activeWorkstream.get(), null); + assert.strictEqual(adapter.read(), null); + }); + + test('clears stale pointers when workstream directory is gone', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-planning-workspace-')); + try { + fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams'), { recursive: true }); + const adapter = createMemoryPointerAdapter('ghost'); + const workspace = createPlanningWorkspace(tmpDir, { + activeWorkstreamAdapter: adapter, + }); + + assert.strictEqual(workspace.activeWorkstream.get(), null); + assert.strictEqual(adapter.read(), null); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('planning-workspace: lock seam', () => { + test('exports withPlanningLock and acquires/release lock', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-planning-lock-')); + try { + const result = withPlanningLock(tmpDir, () => 'ok'); + assert.strictEqual(result, 'ok'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', '.lock'))); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('core compatibility adapter: planning workspace functions', () => { + let savedSession; + + beforeEach(() => { + savedSession = process.env.GSD_SESSION_KEY; + delete process.env.GSD_SESSION_KEY; + }); + + afterEach(() => { + if (savedSession !== undefined) process.env.GSD_SESSION_KEY = savedSession; + else delete process.env.GSD_SESSION_KEY; + }); + + test('core and planning-workspace expose matching behavior', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-compat-')); + try { + fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true }); + + core.setActiveWorkstream(tmpDir, 'alpha'); + assert.strictEqual(core.getActiveWorkstream(tmpDir), 'alpha'); + assert.strictEqual(getActiveWorkstream(tmpDir), 'alpha'); + + assert.strictEqual( + core.planningDir(tmpDir, 'feature-x', 'my-project'), + planningDir(tmpDir, 'feature-x', 'my-project') + ); + assert.deepStrictEqual( + core.planningPaths(tmpDir, 'feature-x'), + planningPaths(tmpDir, 'feature-x') + ); + + setActiveWorkstream(tmpDir, null); + assert.strictEqual(core.getActiveWorkstream(tmpDir), null); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +});