Files
msd-core/get-shit-done/bin/lib/planning-workspace.cjs
Tom Boucher 639e4d603a refactor(shell-projection): migrate all subprocess call sites to exec*/probeTty seam (Phase 2, #3466) (#3476)
* refactor(shell-projection): migrate planning-workspace.cjs tty probe to probeTty seam (#3466)

Replaces direct execFileSync('tty') with probeTty() from the shell-projection
seam. Removes try/catch — probeTty() returns null on error/non-tty/win32.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): migrate commands.cjs to execGit seam (#3466)

- Replaces execSync('git diff --cached --name-only') with execGit array call
- Migrates 14 existing execGit(cwd, args) callers from core.cjs's local
  wrapper to the seam's execGit(args, { cwd }) signature
- Drops execGit from the core.cjs destructure to resolve naming collision

Drops try/catch around git diff — execGit returns exitCode without throwing,
so the no-staged-files / not-a-git-repo case is detected by exitCode !== 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): migrate check-latest-version.cjs to execNpm seam (#3466)

Routes the default-spawn path through execNpm — execNpm owns the win32
shell-flag policy. The injection point remains spawnSync-shaped for test
compatibility; an internal adapter translates { exitCode } → { status }.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): migrate init.cjs git calls to execGit seam (#3466)

Replaces 3 execSync calls with execGit array-args:
- detectChildRepos: git status --porcelain
- cmdInitNewWorkspace: git --version (worktree availability probe)
- cmdRemoveWorkspace: git status --porcelain

Drops 3 try/catch blocks — execGit returns exitCode without throwing, so
best-effort handling becomes a clean exitCode === 0 check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): migrate core.cjs to execGit seam delegation (#3466)

- Removes direct require('child_process') from core.cjs
- Replaces execFileSync('git check-ignore') with seam's execGit
- Local execGit wrapper now a thin adapter delegating to seam — keeps the
  legacy (cwd, args) positional signature and derived timedOut field for
  the verify.cjs and worktree-safety.cjs consumers that are out of Phase 2
  scope (the wrapper proper would only be removed once those consumers
  migrate, tracked separately)

Extends the seam's _spawnResult to expose signal and error fields so callers
can compute timedOut without bypassing the seam. The Phase 1 test suite
asserts on required field presence only, so the extension is
backward-compatible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): migrate graphify.cjs to execTool seam (#3466)

- execGraphify: spawnSync('graphify', ...) → execTool with env passthrough,
  preserving the ENOENT/TIMEOUT/EXIT_NONZERO typed reason mapping using the
  seam's signal/error fields
- checkGraphifyInstalled: spawnSync('graphify', ['--help']) → execTool
- checkGraphifyVersion strategy 1: graphify --version via execTool
- checkGraphifyVersion strategy 2: python3 importlib.metadata via execTool

Adds env option to execTool — graphify needs PYTHONUNBUFFERED=1 to drain
buffered stdout on long-running operations.

Changes seam internals to access spawnSync/execFileSync via the non-destructured
childProcess module reference. Destructured imports capture references at load
time and are un-mockable by mock.method(childProcess, 'spawnSync', ...) — which
breaks all the graphify subprocess tests. Non-destructured access restores
mockability without changing public behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(shell-projection): execGit defaults to non-interactive git env (#3466)

Bakes GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never into execGit's default
env. Without these, a credential prompt or terminal-input probe blocks the
git subprocess indefinitely until our 10s timeout kills it — surfacing as
a generic timeout instead of the actual auth-prompt cause.

These were previously set ad-hoc in worktree-safety.cjs's local execGitDefault
wrapper. Moving them to the seam makes them the consistent default for every
git call across the codebase. Callers can override via opts.env.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): remove core.cjs local execGit wrapper; migrate verify + worktree-safety (#3466)

Completes the Phase 2 "remove local execGit wrapper" criterion. All callers
now use the shell-projection seam's execGit(args, opts) signature directly.

- core.cjs: delete the local execGit wrapper and the execGit export. The
  isGitIgnored seam check now calls execGit from the seam. Worktree-safety
  function calls drop their execGit DI passthrough — worktree-safety's
  internal execGitDefault now delegates to the seam and adds timedOut.
- verify.cjs: 6 callers migrate from execGit(cwd, args) to
  execGit(args, { cwd }). Imports execGit from the seam directly. The
  inspectWorktreeHealth DI passes the seam's execGit (worktree-safety now
  matches that shape).
- worktree-safety.cjs: local execGitDefault becomes a thin adapter over
  the seam — no more direct spawnSync. 11 internal callers migrate to the
  new shape. DI contract for tests changes from (cwd, args) → (args, opts).
- graphify.cjs: 2 remaining execGit callers migrate from core.cjs (now
  removed) to the seam directly.
- test mocks updated in 3 worktree-safety test files to match the new
  (args, opts) DI shape — most mocks were shape-agnostic and required no
  changes; only those that destructured cwd/args needed updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(changeset): add entry for shell-projection Phase 2 migration (#3466)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pr3476): address CodeRabbit review findings

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:33:32 -04:00

368 lines
10 KiB
JavaScript

/**
* 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 { probeTty } = require('./shell-command-projection.cjs');
const { isValidActiveWorkstreamName } = require('./workstream-name-policy.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 .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;
}
const ttyPath = probeTty();
if (ttyPath) {
const token = sanitizeWorkstreamSessionToken(ttyPath.replace(/^\/dev\//, ''));
if (token) cachedControllingTtyToken = `tty-${token}`;
}
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 isValidActiveWorkstreamName(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, underscores, or dots');
}
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,
};