* refactor(shell-projection): migrate roadmap.cjs writes to platformWriteSync (#3467) 2 atomicWriteFileSync calls → platformWriteSync. The seam owns markdown normalization, so the explicit utf-8 encoding arg is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate config.cjs writes to platformWriteSync (#3467) - 3 atomicWriteFileSync calls → platformWriteSync - 1 raw fs.writeFileSync (depth→granularity migration) → platformWriteSync - 2 fs.mkdirSync(planningBase, { recursive: true }) → platformEnsureDir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate docs.cjs reads to platformReadSync (#3467) 6 try { fs.readFileSync } catch {} patterns → platformReadSync(path) with explicit null guards. detectProjectType now reads package.json once and shares it across has_cli_bin/is_monorepo/has_tests checks. JSON.parse is still wrapped in a try (parsing is a separate failure mode from missing file). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate audit.cjs reads to platformReadSync (#3467) 8 try { fs.readFileSync(safeFilePath, 'utf-8') } catch { continue } patterns → const content = platformReadSync(safeFilePath); if (content === null) continue; The single safeSum case (where catch set status='unreadable' rather than continue) maps to an if/else that preserves the same semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate planning-workspace.cjs to platform* seam (#3467) - 2 try { fs.readFileSync } catch {} → platformReadSync (null on missing) - 2 fs.writeFileSync (workstream pointer writes) → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir The .lock file write at withPlanningLock is intentionally NOT migrated. That call uses { flag: 'wx' } for atomic exclusive-create, which is the correct lock-acquisition primitive. platformWriteSync's atomic-rename pattern would silently overwrite an existing lock file and break the locking guarantee. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate milestone.cjs writes to platform* seam (#3467) - 5 atomicWriteFileSync calls → platformWriteSync (4 dropped normalizeMd wrapper; seam handles .md normalization automatically) - 2 raw fs.writeFileSync (archive ROADMAP.md / REQUIREMENTS.md) → platformWriteSync - 2 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Dropped normalizeMd import (only used as write pre-call here) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate intel.cjs to platform* seam (#3467) - 7 fs.readFileSync (existsSync+readFileSync patterns and try/catch) → platformReadSync - 2 fs.writeFileSync → platformWriteSync - 1 fs.mkdirSync(intelPath, { recursive: true }) → platformEnsureDir - Consolidated dual-check (existsSync + readFileSync) into single platformReadSync call returning null on missing file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate workstream.cjs to platform* seam (#3467) - 5 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - 1 fs.writeFileSync (STATE.md initial scaffold) → platformWriteSync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate init.cjs reads/writes to platform* seam (#3467) - 11 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 1 fs.writeFileSync (skill-manifest.json) → platformWriteSync Three bare fs.readFileSync calls remain (ROADMAP/STATE reads in code paths where the file is required to exist) — these are not "Done when" violations (no try/catch wrapping, no inline existsSync guard). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate commands.cjs reads/writes to platform* seam (#3467) - 6 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 2 fs.writeFileSync → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Removed unused safeReadFile import (zero call sites in this file) Three bare fs.readFileSync calls remain (sourcePath at line 752, fullPath at 443, roadmapPath in cmdAuditOpen) — preceded by existsSync guards or in code paths where file presence is required; not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate profile-output.cjs to platform* seam (#3467) - 6 safeReadFile (from core.cjs) calls preserved by aliasing platformReadSync as safeReadFile in the import — same semantics, zero call-site changes - 3 try/JSON.parse(readFileSync) patterns → platformReadSync + try/JSON.parse - 1 existsSync+readFileSync pattern (claude.md update) → platformReadSync - 5 fs.writeFileSync → platformWriteSync - 4 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir Two bare fs.readFileSync calls remain (template reads where file must exist or fail loudly) — not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate state.cjs to platform* seam (#3467) - 4 atomicWriteFileSync calls → platformWriteSync (3 dropped normalizeMd wrapper; seam handles .md normalization) - 4 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 1 fs.writeFileSync (WAITING.json) → platformWriteSync - 1 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Dropped normalizeMd and atomicWriteFileSync imports (only used as write pre-calls here) Bare fs.readFileSync calls remain in code paths where STATE.md is required to exist (statePath reads in cmd handlers, dry-run prune) — not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate core.cjs to platform* seam (#3467) - 7 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 3 fs.writeFileSync (config writes + large-payload temp file) → platformWriteSync - 1 fs.mkdirSync (GSD_TEMP_DIR) → platformEnsureDir Three fs calls remain — they are the internal implementations of the safeReadFile and atomicWriteFileSync wrappers that core.cjs exports for backward compatibility. The wrappers are scheduled for removal in Phase 4 (#3468) and will not be migrated here. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate phase.cjs writes to platform* seam (#3467) - 6 atomicWriteFileSync calls → platformWriteSync - 3 fs.writeFileSync(path.join(dirPath, '.gitkeep'), '') → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir Bare fs.readFileSync calls remain for roadmapPath/planPath reads where the file is required to exist; these are not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate verify.cjs to platform* seam (#3467) - 8 safeReadFile (from core.cjs) calls preserved by aliasing platformReadSync as safeReadFile in the import — same semantics, zero call-site changes - 1 existsSync+readFileSync inline ternary → safeReadFile (returns null) - 5 fs.writeFileSync (config writes + milestones writes) → platformWriteSync Bare fs.readFileSync calls remain for code paths where the file is required to exist (roadmap/state/config full reads); these are not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate frontmatter.cjs + update atomic-write test (#3467) - frontmatter.cjs: 2 atomicWriteFileSync calls → platformWriteSync. The legacy normalizeMd wrapper is dropped because the seam handles markdown normalization. safeReadFile preserved by aliasing platformReadSync. - atomic-write-coverage.test.cjs: update the #1972 structural invariant to assert on platformWriteSync. platformWriteSync uses the same tmp-file + atomic-rename primitive that atomicWriteFileSync did — the no-partial-write guarantee is preserved across the migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(changeset): add entry for shell-projection Phase 3 migration (#3467) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(coderabbit): disable ESLint tool (repo uses custom lint scripts) CodeRabbit's review surface emits a "skipped: no ESLint configuration" warning because the repo doesn't ship ESLint config. The repo intentionally does not use ESLint — it ships its own targeted lint scripts (scripts/lint-no-source-grep.cjs, npm run lint:tests) that enforce repo-specific test-quality invariants. Adding ESLint config purely to satisfy CR would add an external dependency (CONTRIBUTING.md: "No external dependencies in core") and overlap with the existing custom lint surface. Disable the ESLint tool in CR's tools config so the skip warning stops appearing on every PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
362 lines
10 KiB
JavaScript
362 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, platformWriteSync, platformReadSync, platformEnsureDir } = 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() {
|
|
const raw = platformReadSync(filePath);
|
|
return raw ? raw.trim() || null : null;
|
|
},
|
|
write(name) {
|
|
platformWriteSync(filePath, name + '\n');
|
|
},
|
|
clear() {
|
|
try { fs.unlinkSync(filePath); } catch {}
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSessionScopedPointerAdapter(cwd, fixedSessionKey) {
|
|
const scoped = getSessionScopedWorkstreamFile(cwd, fixedSessionKey);
|
|
if (!scoped) return null;
|
|
|
|
return {
|
|
read() {
|
|
const raw = platformReadSync(scoped.filePath);
|
|
return raw ? raw.trim() || null : null;
|
|
},
|
|
write(name) {
|
|
platformEnsureDir(scoped.dirPath);
|
|
platformWriteSync(scoped.filePath, name + '\n');
|
|
},
|
|
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 { platformEnsureDir(planningDir(cwd)); } 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);
|
|
platformEnsureDir(wsDir);
|
|
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,
|
|
};
|