* fix(workstream): normalize migrate-name to valid slug * docs(context): record workstream migrate-name slug invariant * fix(catalog-cjs): balanced fallback for unknown profile (CR finding A) profiles[profile] could return undefined for any profile key absent from the catalog entry, causing downstream callers like formatAgentToModelMapAsTable to crash on .length. Add ?? profiles.balanced fallback to match the SDK adapter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(sdk): anchor path resolution on import.meta.url not cwd (CR finding B) resolve(process.cwd(), '..') breaks when Vitest is invoked from the repo root because cwd is already the repo root and '..' goes one level above. Replace with a file-relative path using fileURLToPath(new URL('../../../', import.meta.url)) anchored at the test file's location (sdk/src/query/). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: derive Group B runtime list from catalog (CR finding C) Hardcoded ['kilo', 'cline', ...] throws TypeError if a runtime name is removed from the catalog. Derive group B dynamically via Object.keys(catalog.runtimeTierDefaults).filter(r => !r.opus) so the test never goes stale and auto-covers future Group B additions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(workflow): add hermes to Step B runtime options (CR finding D) hermes appears in the Group A built-in defaults table but was missing from the AskUserQuestion options in Step B, forcing users to manually type it via 'Other (Group B or custom)'. Add explicit hermes entry for UI consistency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(config): refresh dynamic_routing tier table; fix stale L671 (findings E+F) Finding E: tier table was missing 6 heavy-tier agents and 15 standard/light agents added by this PR. Updated all three rows to match catalog routingTier assignments (33 agents total). Finding F: removed stale '18 of 31' claim and agent enumeration; replaced with accurate note that all 33 agents have explicit catalog entries. Updated authoritative source pointers to model-catalog.cjs / model-catalog.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(core): add profile-fallback unit tests for quality and budget (CR nitpick G) The PR introduced quality→opus and budget→haiku unknown-agent fallbacks but only balanced→sonnet and inherit→inherit were tested. Add two tests covering the remaining two branches to complete coverage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * adr: define planning workspace and worktree seam * refactor(worktree): extract worktree safety policy module * refactor(workstream): extract active workstream pointer store seam * test(worktree): cover policy branch paths and persist seam guardrails * refactor(worktree): centralize health inventory seam for W017 * fix(workspace): align SDK project path policy with CJS planningDir * refactor(query): unify SDK planning path projection seam * refactor(init): route workspace projection through planningPaths seam * docs(adr): add SDK architecture and planning path ADRs * refactor(worktree): deepen name, pointer, inventory, and config seams * docs(config): harmonize claude-opus-4-6 to 4-7 in resolve_model_ids example (CR finding 2) * fix(sdk): return undefined for model_profile='inherit' sentinel (CR finding 3) * docs(adr): renumber conflicting 0003-sdk-package-seam-module to 0007, update seam-map reference (CR finding 4) * fix(workstream): align CJS and SDK name validation to accept dots, guard path traversal via includes('..') (CR finding 5) * fix(sdk): guard writeActiveWorkstream against non-existent workstream directory, k014/k031 parity (CR finding 6) * chore(changeset): add #3269 changeset (CR finding 1 — proper changeset for this PR) * docs(inventory): register 3 new CLI modules in INVENTORY.md/MANIFEST (active-workstream-store, workstream-name-policy, worktree-safety) * fix(sdk): use relPlanningPath(workstream) in planningPaths, fix setActiveWorkstream/getActiveWorkstream name errors in workstream.ts * fix(sdk): validate GSD_WORKSTREAM in planningPaths before use (#3269 regression) planningPaths() called resolveWorkspaceContext() which returned GSD_WORKSTREAM raw (no validation). An invalid value like '../evil' was used as effectiveWorkstream, constructing a bad path; roadmapAnalyze() caught the ENOENT and returned a no-phase_count error object instead of the root ROADMAP result. Fix: validate envCtx.workstream with validateWorkstreamName() in planningPaths() before accepting it as effectiveWorkstream. Invalid env → null → root .planning/ fallback, preserving the bug-2791 contract: invalid GSD_WORKSTREAM is silently ignored and falls back to the root context (phase_count: 0 for empty root ROADMAP). The bug-2791 regression test now passes. No other call sites read GSD_WORKSTREAM without validation: query-runtime-context.ts already validates; cli.ts already validates; context-engine.ts takes a caller-validated workstream parameter. Closes #3268 (regression introduced by #3269 workstream-name-policy work). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
373 lines
10 KiB
JavaScript
373 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 { execFileSync } = require('child_process');
|
|
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;
|
|
}
|
|
|
|
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 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,
|
|
};
|