fix(workstream): normalize migration workstream names (#3269)
* 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>
This commit is contained in:
@@ -174,6 +174,7 @@ const path = require('path');
|
||||
const core = require('./lib/core.cjs');
|
||||
const { error, findProjectRoot } = core;
|
||||
const { getActiveWorkstream } = require('./lib/planning-workspace.cjs');
|
||||
const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/active-workstream-store.cjs');
|
||||
const state = require('./lib/state.cjs');
|
||||
const phase = require('./lib/phase.cjs');
|
||||
const roadmap = require('./lib/roadmap.cjs');
|
||||
@@ -275,30 +276,18 @@ async function main() {
|
||||
}
|
||||
|
||||
// Optional workstream override for parallel milestone work.
|
||||
// Priority: --ws flag > GSD_WORKSTREAM env var > session-scoped pointer > shared legacy pointer > null
|
||||
const wsEqArg = args.find(arg => arg.startsWith('--ws='));
|
||||
const wsIdx = args.indexOf('--ws');
|
||||
// Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
|
||||
let ws = null;
|
||||
if (wsEqArg) {
|
||||
ws = wsEqArg.slice('--ws='.length).trim();
|
||||
if (!ws) error('Missing value for --ws');
|
||||
args.splice(args.indexOf(wsEqArg), 1);
|
||||
} else if (wsIdx !== -1) {
|
||||
ws = args[wsIdx + 1];
|
||||
if (!ws || ws.startsWith('--')) error('Missing value for --ws');
|
||||
args.splice(wsIdx, 2);
|
||||
} else if (process.env.GSD_WORKSTREAM) {
|
||||
ws = process.env.GSD_WORKSTREAM.trim();
|
||||
} else {
|
||||
ws = getActiveWorkstream(cwd);
|
||||
}
|
||||
// Validate workstream name to prevent path traversal attacks.
|
||||
if (ws && !/^[a-zA-Z0-9_-]+$/.test(ws)) {
|
||||
error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
|
||||
}
|
||||
// Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths
|
||||
if (ws) {
|
||||
process.env.GSD_WORKSTREAM = ws;
|
||||
try {
|
||||
const wsResolution = resolveActiveWorkstream(cwd, args, process.env, {
|
||||
getStored: getActiveWorkstream,
|
||||
});
|
||||
ws = wsResolution.ws;
|
||||
args = wsResolution.args;
|
||||
// Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths.
|
||||
applyResolvedWorkstreamEnv(wsResolution, process.env);
|
||||
} catch (err) {
|
||||
error(err.message || String(err));
|
||||
}
|
||||
|
||||
const rawIndex = args.indexOf('--raw');
|
||||
|
||||
85
get-shit-done/bin/lib/active-workstream-store.cjs
Normal file
85
get-shit-done/bin/lib/active-workstream-store.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Active Workstream Pointer Store Module
|
||||
*
|
||||
* Owns workstream source precedence and selection:
|
||||
* CLI --ws > GSD_WORKSTREAM env > stored active workstream pointer.
|
||||
*/
|
||||
|
||||
const { getActiveWorkstream } = require('./planning-workspace.cjs');
|
||||
const { isValidActiveWorkstreamName } = require('./workstream-name-policy.cjs');
|
||||
|
||||
function validateWorkstreamName(name) {
|
||||
return isValidActiveWorkstreamName(name);
|
||||
}
|
||||
|
||||
function parseCliWorkstream(args) {
|
||||
const wsEqArg = args.find(arg => arg.startsWith('--ws='));
|
||||
const wsIdx = args.indexOf('--ws');
|
||||
|
||||
if (wsEqArg) {
|
||||
const value = wsEqArg.slice('--ws='.length).trim();
|
||||
if (!value) throw new Error('Missing value for --ws');
|
||||
return {
|
||||
value,
|
||||
source: 'cli',
|
||||
args: args.filter(arg => arg !== wsEqArg),
|
||||
};
|
||||
}
|
||||
|
||||
if (wsIdx !== -1) {
|
||||
const value = args[wsIdx + 1];
|
||||
if (!value || value.startsWith('--')) throw new Error('Missing value for --ws');
|
||||
return {
|
||||
value,
|
||||
source: 'cli',
|
||||
args: args.filter((_, idx) => idx !== wsIdx && idx !== wsIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: null,
|
||||
source: null,
|
||||
args: args.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveActiveWorkstream(cwd, args, env = process.env, deps = {}) {
|
||||
const parsed = parseCliWorkstream(args);
|
||||
const getStored = deps.getStored || getActiveWorkstream;
|
||||
|
||||
let ws = null;
|
||||
let source = 'none';
|
||||
|
||||
if (parsed.value) {
|
||||
ws = parsed.value;
|
||||
source = parsed.source;
|
||||
} else if (env && typeof env.GSD_WORKSTREAM === 'string' && env.GSD_WORKSTREAM.trim()) {
|
||||
ws = env.GSD_WORKSTREAM.trim();
|
||||
source = 'env';
|
||||
} else {
|
||||
ws = getStored(cwd) || null;
|
||||
source = ws ? 'store' : 'none';
|
||||
}
|
||||
|
||||
if (ws && !validateWorkstreamName(ws)) {
|
||||
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots');
|
||||
}
|
||||
|
||||
return {
|
||||
ws,
|
||||
source,
|
||||
args: parsed.args,
|
||||
};
|
||||
}
|
||||
|
||||
function applyResolvedWorkstreamEnv(resolution, env = process.env) {
|
||||
if (!resolution || !resolution.ws) return;
|
||||
env.GSD_WORKSTREAM = resolution.ws;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateWorkstreamName,
|
||||
parseCliWorkstream,
|
||||
resolveActiveWorkstream,
|
||||
applyResolvedWorkstreamEnv,
|
||||
};
|
||||
@@ -8,6 +8,13 @@ const path = require('path');
|
||||
const { execSync, execFileSync, spawnSync } = require('child_process');
|
||||
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = require('./model-profiles.cjs');
|
||||
const { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT } = require('./model-catalog.cjs');
|
||||
const {
|
||||
resolveWorktreeContext,
|
||||
parseWorktreePorcelain: parseWorktreePorcelainPolicy,
|
||||
planWorktreePrune,
|
||||
executeWorktreePrunePlan,
|
||||
inspectWorktreeHealth,
|
||||
} = require('./worktree-safety.cjs');
|
||||
// Compatibility shim: new imports should use planning-workspace.cjs directly.
|
||||
const {
|
||||
planningDir,
|
||||
@@ -332,11 +339,14 @@ function _deepMergeConfig(base, overlay) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function loadConfig(cwd) {
|
||||
function loadConfig(cwd, options = {}) {
|
||||
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
|
||||
? options.workstream
|
||||
: (process.env.GSD_WORKSTREAM || null);
|
||||
// When GSD_WORKSTREAM is set, load root config first so workstream config
|
||||
// can inherit from it. This prevents users from duplicating model_overrides,
|
||||
// workflow.*, etc. across every workstream config (#2714).
|
||||
const ws = process.env.GSD_WORKSTREAM || null;
|
||||
const ws = activeWorkstream;
|
||||
let rootParsed = null;
|
||||
if (ws) {
|
||||
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
||||
@@ -348,7 +358,7 @@ function loadConfig(cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
const configPath = path.join(planningDir(cwd, ws), 'config.json');
|
||||
const defaults = CONFIG_DEFAULTS;
|
||||
|
||||
try {
|
||||
@@ -535,18 +545,11 @@ function loadConfig(cwd) {
|
||||
// If .planning/ exists, the project is initialized — just missing config.json.
|
||||
// When GSD_WORKSTREAM is set and root config was loaded, the workstream config
|
||||
// doesn't exist — treat root config as the effective config for this workstream.
|
||||
if (fs.existsSync(planningDir(cwd))) {
|
||||
if (fs.existsSync(planningDir(cwd, ws))) {
|
||||
if (rootParsed) {
|
||||
// Workstream has no config.json: re-parse using root config as the sole source.
|
||||
// Temporarily clear GSD_WORKSTREAM so planningDir() returns root .planning/,
|
||||
// then reload. This is safe: rootParsed is already the root config object.
|
||||
const savedWs = process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
try {
|
||||
return loadConfig(cwd);
|
||||
} finally {
|
||||
process.env.GSD_WORKSTREAM = savedWs;
|
||||
}
|
||||
// Keep env immutable by explicitly reloading with workstream context cleared.
|
||||
return loadConfig(cwd, { workstream: null });
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -740,30 +743,11 @@ function execGit(cwd, args) {
|
||||
* Returns the main worktree path, or cwd if not in a worktree.
|
||||
*/
|
||||
function resolveWorktreeRoot(cwd) {
|
||||
// If the current directory already has its own .planning/, respect it.
|
||||
// This handles linked worktrees with independent planning state (e.g., Conductor workspaces).
|
||||
if (fs.existsSync(path.join(cwd, '.planning'))) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
// Check if we're in a linked worktree
|
||||
const gitDir = execGit(cwd, ['rev-parse', '--git-dir']);
|
||||
const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']);
|
||||
|
||||
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd;
|
||||
|
||||
// In a linked worktree, .git is a file pointing to .git/worktrees/<name>
|
||||
// and git-common-dir points to the main repo's .git directory
|
||||
const gitDirResolved = path.resolve(cwd, gitDir.stdout);
|
||||
const commonDirResolved = path.resolve(cwd, commonDir.stdout);
|
||||
|
||||
if (gitDirResolved !== commonDirResolved) {
|
||||
// We're in a linked worktree — resolve main worktree root
|
||||
// The common dir is the main repo's .git, so its parent is the main worktree root
|
||||
return path.dirname(commonDirResolved);
|
||||
}
|
||||
|
||||
return cwd;
|
||||
const context = resolveWorktreeContext(cwd, {
|
||||
execGit,
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
return context.effectiveRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -775,21 +759,7 @@ function resolveWorktreeRoot(cwd) {
|
||||
* @returns {{ path: string, branch: string }[]}
|
||||
*/
|
||||
function parseWorktreePorcelain(porcelain) {
|
||||
const entries = [];
|
||||
let current = null;
|
||||
for (const line of porcelain.split('\n')) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
current = { path: line.slice('worktree '.length).trim(), branch: null };
|
||||
} else if (line.startsWith('branch refs/heads/') && current) {
|
||||
current.branch = line.slice('branch refs/heads/'.length).trim();
|
||||
} else if (line === '' && current) {
|
||||
if (current.branch) entries.push(current);
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
// flush last entry if file doesn't end with blank line
|
||||
if (current && current.branch) entries.push(current);
|
||||
return entries;
|
||||
return parseWorktreePorcelainPolicy(porcelain);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -802,31 +772,15 @@ function parseWorktreePorcelain(porcelain) {
|
||||
* @returns {string[]} list of worktree paths that were removed (always empty)
|
||||
*/
|
||||
function pruneOrphanedWorktrees(repoRoot) {
|
||||
const pruned = [];
|
||||
const cwd = process.cwd();
|
||||
|
||||
try {
|
||||
// 1. Get all worktrees in porcelain format
|
||||
const listResult = execGit(repoRoot, ['worktree', 'list', '--porcelain']);
|
||||
if (listResult.exitCode !== 0) return pruned;
|
||||
|
||||
const worktrees = parseWorktreePorcelain(listResult.stdout);
|
||||
if (worktrees.length === 0) {
|
||||
execGit(repoRoot, ['worktree', 'prune']);
|
||||
return pruned;
|
||||
}
|
||||
|
||||
// Destructive removal of linked worktrees is intentionally disabled.
|
||||
// Keep metadata cleanup only (git worktree prune), which clears stale refs
|
||||
// for manually-deleted directories without removing active sibling worktrees.
|
||||
void cwd;
|
||||
void worktrees;
|
||||
const plan = planWorktreePrune(
|
||||
repoRoot,
|
||||
{ allowDestructive: false },
|
||||
{ execGit, parseWorktreePorcelain }
|
||||
);
|
||||
executeWorktreePrunePlan(plan, { execGit });
|
||||
} catch { /* never crash the caller */ }
|
||||
|
||||
// Always run prune to clear stale references (e.g. manually-deleted dirs)
|
||||
execGit(repoRoot, ['worktree', 'prune']);
|
||||
|
||||
return pruned;
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ───
|
||||
@@ -2047,4 +2001,5 @@ module.exports = {
|
||||
atomicWriteFileSync,
|
||||
timeAgo,
|
||||
pruneOrphanedWorktrees,
|
||||
inspectWorktreeHealth,
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ function getAgentToModelMapForProfile(normalizedProfile) {
|
||||
const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced';
|
||||
const out = {};
|
||||
for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) {
|
||||
out[agent] = profile === 'inherit' ? 'inherit' : profiles[profile];
|
||||
out[agent] = profile === 'inherit' ? 'inherit' : (profiles[profile] ?? profiles.balanced);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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',
|
||||
@@ -235,7 +236,7 @@ function pickActiveWorkstreamAdapter(cwd, opts = {}) {
|
||||
}
|
||||
|
||||
function validateWorkstreamName(name) {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
return isValidActiveWorkstreamName(name);
|
||||
}
|
||||
|
||||
function withPlanningLock(cwd, fn) {
|
||||
@@ -333,7 +334,7 @@ function createPlanningWorkspace(cwd, opts = {}) {
|
||||
return;
|
||||
}
|
||||
if (!validateWorkstreamName(name)) {
|
||||
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
|
||||
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, underscores, or dots');
|
||||
}
|
||||
|
||||
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS } = require('./core.cjs');
|
||||
const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth } = require('./core.cjs');
|
||||
const { planningDir } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
||||
const { writeStateMd } = require('./state.cjs');
|
||||
@@ -908,33 +908,24 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
|
||||
// ─── Check 11: Stale / orphan git worktrees (#2167) ────────────────────────
|
||||
try {
|
||||
const worktreeResult = execGit(cwd, ['worktree', 'list', '--porcelain']);
|
||||
if (worktreeResult.exitCode === 0 && worktreeResult.stdout) {
|
||||
const blocks = worktreeResult.stdout.split('\n\n').filter(Boolean);
|
||||
// Skip the first block — it is always the main worktree
|
||||
for (let i = 1; i < blocks.length; i++) {
|
||||
const lines = blocks[i].split('\n');
|
||||
const wtLine = lines.find(l => l.startsWith('worktree '));
|
||||
if (!wtLine) continue;
|
||||
const wtPath = wtLine.slice('worktree '.length);
|
||||
|
||||
if (!fs.existsSync(wtPath)) {
|
||||
// Orphan: path no longer exists on disk
|
||||
const worktreeHealth = inspectWorktreeHealth(
|
||||
cwd,
|
||||
{ staleAfterMs: 60 * 60 * 1000 },
|
||||
{ execGit, existsSync: fs.existsSync, statSync: fs.statSync }
|
||||
);
|
||||
if (worktreeHealth.ok) {
|
||||
for (const finding of worktreeHealth.findings) {
|
||||
if (finding.kind === 'orphan') {
|
||||
addIssue('warning', 'W017',
|
||||
`Orphan git worktree: ${wtPath} (path no longer exists on disk)`,
|
||||
`Orphan git worktree: ${finding.path} (path no longer exists on disk)`,
|
||||
'Run: git worktree prune');
|
||||
} else {
|
||||
// Check if stale (older than 1 hour)
|
||||
try {
|
||||
const stat = fs.statSync(wtPath);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
const ONE_HOUR = 60 * 60 * 1000;
|
||||
if (ageMs > ONE_HOUR) {
|
||||
addIssue('warning', 'W017',
|
||||
`Stale git worktree: ${wtPath} (last modified ${Math.round(ageMs / 60000)} minutes ago)`,
|
||||
`Run: git worktree remove ${wtPath} --force`);
|
||||
}
|
||||
} catch { /* stat failed — skip */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (finding.kind === 'stale') {
|
||||
addIssue('warning', 'W017',
|
||||
`Stale git worktree: ${finding.path} (last modified ${finding.ageMinutes} minutes ago)`,
|
||||
`Run: git worktree remove ${finding.path} --force`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
get-shit-done/bin/lib/workstream-name-policy.cjs
Normal file
33
get-shit-done/bin/lib/workstream-name-policy.cjs
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Workstream Name Policy Module
|
||||
*
|
||||
* Owns canonical name validation and slug normalization used by workstream and
|
||||
* active-pointer callers.
|
||||
*/
|
||||
|
||||
const ACTIVE_WORKSTREAM_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||
|
||||
function toWorkstreamSlug(name) {
|
||||
return String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function hasInvalidPathSegment(name) {
|
||||
const value = String(name || '');
|
||||
return /[/\\]/.test(value) || value === '.' || value === '..' || value.includes('..');
|
||||
}
|
||||
|
||||
function isValidActiveWorkstreamName(name) {
|
||||
const value = String(name || '');
|
||||
if (value === '..' || value.startsWith('../') || value.includes('..')) return false;
|
||||
return ACTIVE_WORKSTREAM_RE.test(value);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toWorkstreamSlug,
|
||||
hasInvalidPathSegment,
|
||||
isValidActiveWorkstreamName,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const path = require('path');
|
||||
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');
|
||||
const { toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName } = require('./workstream-name-policy.cjs');
|
||||
|
||||
// ─── Migration ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +24,7 @@ const { stateExtractField } = require('./state.cjs');
|
||||
* milestones/, research/, codebase/, todos/) stay in place.
|
||||
*/
|
||||
function migrateToWorkstreams(cwd, workstreamName) {
|
||||
if (!workstreamName || /[/\\]/.test(workstreamName) || workstreamName === '.' || workstreamName === '..') {
|
||||
if (!workstreamName || hasInvalidPathSegment(workstreamName)) {
|
||||
throw new Error('Invalid workstream name for migration');
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ function cmdWorkstreamCreate(cwd, name, options, raw) {
|
||||
error('workstream name required. Usage: workstream create <name>');
|
||||
}
|
||||
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const slug = toWorkstreamSlug(name);
|
||||
if (!slug) {
|
||||
error('Invalid workstream name — must contain at least one alphanumeric character');
|
||||
}
|
||||
@@ -101,7 +102,15 @@ function cmdWorkstreamCreate(cwd, name, options, raw) {
|
||||
const migrateName = options.migrateName || null;
|
||||
let existingWsName;
|
||||
if (migrateName) {
|
||||
existingWsName = migrateName;
|
||||
existingWsName = toWorkstreamSlug(migrateName);
|
||||
if (!existingWsName) {
|
||||
output({
|
||||
created: false,
|
||||
error: 'migration_failed',
|
||||
message: 'Invalid migrate-name — must contain at least one alphanumeric character',
|
||||
}, raw);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
@@ -222,7 +231,7 @@ function cmdWorkstreamList(cwd, raw) {
|
||||
|
||||
function cmdWorkstreamStatus(cwd, name, raw) {
|
||||
if (!name) error('workstream name required. Usage: workstream status <name>');
|
||||
if (/[/\\]/.test(name) || name === '.' || name === '..') error('Invalid workstream name');
|
||||
if (hasInvalidPathSegment(name)) error('Invalid workstream name');
|
||||
|
||||
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
||||
if (!fs.existsSync(wsDir)) {
|
||||
@@ -279,7 +288,7 @@ function cmdWorkstreamStatus(cwd, name, raw) {
|
||||
|
||||
function cmdWorkstreamComplete(cwd, name, options, raw) {
|
||||
if (!name) error('workstream name required. Usage: workstream complete <name>');
|
||||
if (/[/\\]/.test(name) || name === '.' || name === '..') error('Invalid workstream name');
|
||||
if (hasInvalidPathSegment(name)) error('Invalid workstream name');
|
||||
|
||||
const root = planningRoot(cwd);
|
||||
const wsRoot = path.join(root, 'workstreams');
|
||||
@@ -350,8 +359,8 @@ function cmdWorkstreamSet(cwd, name, raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
output({ active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, and underscores only' }, raw);
|
||||
if (!isValidActiveWorkstreamName(name)) {
|
||||
output({ active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, underscores, or dots' }, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
287
get-shit-done/bin/lib/worktree-safety.cjs
Normal file
287
get-shit-done/bin/lib/worktree-safety.cjs
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Worktree Safety Policy Module
|
||||
*
|
||||
* Owns worktree-root resolution and non-destructive prune policy decisions.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function execGitDefault(cwd, args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: (result.stdout ?? '').toString().trim(),
|
||||
stderr: (result.stderr ?? '').toString().trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorktreePorcelain(porcelain) {
|
||||
return parseWorktreeEntries(porcelain).filter((entry) => entry.branch).map((entry) => ({
|
||||
path: entry.path,
|
||||
branch: entry.branch,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseWorktreeEntries(porcelain) {
|
||||
const entries = [];
|
||||
const blocks = String(porcelain || '').split('\n\n').filter(Boolean);
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n');
|
||||
const worktreeLine = lines.find((l) => l.startsWith('worktree '));
|
||||
if (!worktreeLine) continue;
|
||||
const worktreePath = worktreeLine.slice('worktree '.length).trim();
|
||||
if (!worktreePath) continue;
|
||||
const branchLine = lines.find((l) => l.startsWith('branch refs/heads/'));
|
||||
const branch = branchLine ? branchLine.slice('branch refs/heads/'.length).trim() : null;
|
||||
entries.push({ path: worktreePath, branch });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseWorktreeListPaths(porcelain) {
|
||||
return parseWorktreeEntries(porcelain).map((entry) => entry.path);
|
||||
}
|
||||
|
||||
function readWorktreeList(repoRoot, deps = {}) {
|
||||
const execGit = deps.execGit || execGitDefault;
|
||||
const listResult = execGit(repoRoot, ['worktree', 'list', '--porcelain']);
|
||||
if (listResult.exitCode !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'git_list_failed',
|
||||
porcelain: '',
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: 'ok',
|
||||
porcelain: listResult.stdout,
|
||||
entries: parseWorktreeEntries(listResult.stdout),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWorktreeContext(cwd, deps = {}) {
|
||||
const execGit = deps.execGit || execGitDefault;
|
||||
const existsSync = deps.existsSync || fs.existsSync;
|
||||
|
||||
// Local .planning takes precedence over linked-worktree remapping.
|
||||
if (existsSync(path.join(cwd, '.planning'))) {
|
||||
return {
|
||||
effectiveRoot: cwd,
|
||||
mode: 'current_directory',
|
||||
reason: 'has_local_planning',
|
||||
};
|
||||
}
|
||||
|
||||
const gitDir = execGit(cwd, ['rev-parse', '--git-dir']);
|
||||
const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']);
|
||||
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) {
|
||||
return {
|
||||
effectiveRoot: cwd,
|
||||
mode: 'current_directory',
|
||||
reason: 'not_git_repo',
|
||||
};
|
||||
}
|
||||
|
||||
const gitDirResolved = path.resolve(cwd, gitDir.stdout);
|
||||
const commonDirResolved = path.resolve(cwd, commonDir.stdout);
|
||||
if (gitDirResolved !== commonDirResolved) {
|
||||
return {
|
||||
effectiveRoot: path.dirname(commonDirResolved),
|
||||
mode: 'linked_worktree_root',
|
||||
reason: 'linked_worktree',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
effectiveRoot: cwd,
|
||||
mode: 'current_directory',
|
||||
reason: 'main_worktree',
|
||||
};
|
||||
}
|
||||
|
||||
function planWorktreePrune(repoRoot, options = {}, deps = {}) {
|
||||
const parsePorcelain = deps.parseWorktreePorcelain || parseWorktreePorcelain;
|
||||
const destructiveModeRequested = Boolean(options.allowDestructive);
|
||||
const listed = readWorktreeList(repoRoot, deps);
|
||||
if (!listed.ok) {
|
||||
return {
|
||||
repoRoot,
|
||||
action: 'skip',
|
||||
reason: listed.reason,
|
||||
destructiveModeRequested,
|
||||
};
|
||||
}
|
||||
|
||||
let worktrees = [];
|
||||
try {
|
||||
worktrees = parsePorcelain(listed.porcelain);
|
||||
} catch {
|
||||
// Keep historical behavior: still run metadata prune when parsing fails.
|
||||
worktrees = [];
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot,
|
||||
action: 'metadata_prune_only',
|
||||
reason: worktrees.length === 0 ? 'no_worktrees' : 'worktrees_present',
|
||||
destructiveModeRequested,
|
||||
};
|
||||
}
|
||||
|
||||
function executeWorktreePrunePlan(plan, deps = {}) {
|
||||
const execGit = deps.execGit || execGitDefault;
|
||||
if (!plan || plan.action === 'skip') {
|
||||
return {
|
||||
ok: false,
|
||||
action: plan ? plan.action : 'skip',
|
||||
reason: plan ? plan.reason : 'missing_plan',
|
||||
pruned: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (plan.action !== 'metadata_prune_only') {
|
||||
return {
|
||||
ok: false,
|
||||
action: plan.action,
|
||||
reason: 'unsupported_action',
|
||||
pruned: [],
|
||||
};
|
||||
}
|
||||
|
||||
const result = execGit(plan.repoRoot, ['worktree', 'prune']);
|
||||
return {
|
||||
ok: result.exitCode === 0,
|
||||
action: plan.action,
|
||||
reason: plan.reason,
|
||||
pruned: [],
|
||||
};
|
||||
}
|
||||
|
||||
function listLinkedWorktreePaths(repoRoot, deps = {}) {
|
||||
const listed = readWorktreeList(repoRoot, deps);
|
||||
if (!listed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: listed.reason,
|
||||
paths: [],
|
||||
};
|
||||
}
|
||||
|
||||
const allPaths = listed.entries.map((entry) => entry.path);
|
||||
// git worktree list always includes the current/main worktree first.
|
||||
return {
|
||||
ok: true,
|
||||
reason: 'ok',
|
||||
paths: allPaths.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectWorktreeHealth(repoRoot, options = {}, deps = {}) {
|
||||
const inventory = snapshotWorktreeInventory(repoRoot, options, deps);
|
||||
if (!inventory.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: inventory.reason,
|
||||
findings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const findings = [];
|
||||
for (const entry of inventory.entries) {
|
||||
if (!entry.exists) {
|
||||
findings.push({
|
||||
kind: 'orphan',
|
||||
path: entry.path,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.isStale) {
|
||||
findings.push({
|
||||
kind: 'stale',
|
||||
path: entry.path,
|
||||
ageMinutes: entry.ageMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: 'ok',
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
|
||||
const existsSync = deps.existsSync || fs.existsSync;
|
||||
const statSync = deps.statSync || fs.statSync;
|
||||
const staleAfterMs = options.staleAfterMs ?? (60 * 60 * 1000);
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const listed = listLinkedWorktreePaths(repoRoot, { execGit: deps.execGit || execGitDefault });
|
||||
if (!listed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: listed.reason,
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
for (const worktreePath of listed.paths) {
|
||||
let exists = false;
|
||||
let isStale = false;
|
||||
let ageMinutes = null;
|
||||
|
||||
if (!existsSync(worktreePath)) {
|
||||
entries.push({
|
||||
path: worktreePath,
|
||||
exists,
|
||||
isStale,
|
||||
ageMinutes,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
exists = true;
|
||||
try {
|
||||
const stat = statSync(worktreePath);
|
||||
const ageMs = nowMs - stat.mtimeMs;
|
||||
ageMinutes = Math.round(ageMs / 60000);
|
||||
if (ageMs > staleAfterMs) {
|
||||
isStale = true;
|
||||
}
|
||||
} catch {
|
||||
// Keep historical behavior: stat failures are ignored.
|
||||
}
|
||||
entries.push({
|
||||
path: worktreePath,
|
||||
exists,
|
||||
isStale,
|
||||
ageMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: 'ok',
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveWorktreeContext,
|
||||
parseWorktreePorcelain,
|
||||
planWorktreePrune,
|
||||
executeWorktreePrunePlan,
|
||||
listLinkedWorktreePaths,
|
||||
inspectWorktreeHealth,
|
||||
snapshotWorktreeInventory,
|
||||
};
|
||||
@@ -382,6 +382,7 @@ AskUserQuestion([
|
||||
{ label: "qwen", description: "Qwen CLI." },
|
||||
{ label: "opencode", description: "OpenCode (uses anthropic/ prefix)." },
|
||||
{ label: "copilot", description: "GitHub Copilot." },
|
||||
{ label: "hermes", description: "Hermes (uses anthropic/ prefix)." },
|
||||
{ label: "Other (Group B or custom)", description: "kilo, cline, cursor, windsurf, augment, trae, codebuddy, antigravity, or a custom runtime string. Overrides are honored even though no built-in map exists." }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user