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 <thomas.boucher@sas.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<decisions>` 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 |
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
371
get-shit-done/bin/lib/planning-workspace.cjs
Normal file
371
get-shit-done/bin/lib/planning-workspace.cjs
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
167
tests/planning-workspace.test.cjs
Normal file
167
tests/planning-workspace.test.cjs
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user