Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: concurrent milestone execution with isolated state (#291)
Add centralized path resolution layer (paths.cjs) that enables
milestone-scoped directories for parallel milestone work. All 11 lib
files refactored to use resolvePlanningPaths() instead of hardcoded
.planning/ paths.

Key changes:
- New paths.cjs: resolvePlanningPaths(cwd, milestoneOverride) resolves
  abs/rel paths based on ACTIVE_MILESTONE file or --milestone CLI flag
- --milestone <name> CLI flag parsed in gsd-tools.cjs
- New commands: milestone create/switch/list/status
- All init commands output milestone, is_multi_milestone, planning_base
- Legacy mode (no ACTIVE_MILESTONE) returns identical paths to before
- Auto-migration: first milestone create copies existing global state
- 25 new tests for paths and milestone commands (457 total, 0 failures)
  • Loading branch information
Ethan Hurst committed Mar 3, 2026
commit 79a9b8a5f323a75f727f30d7d3136a1642b0cbed
26 changes: 25 additions & 1 deletion get-shit-done/bin/gsd-tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
const fs = require('fs');
const path = require('path');
const { error } = require('./lib/core.cjs');
const { setMilestoneOverride } = require('./lib/paths.cjs');
const state = require('./lib/state.cjs');
const phase = require('./lib/phase.cjs');
const roadmap = require('./lib/roadmap.cjs');
Expand Down Expand Up @@ -165,6 +166,21 @@ async function main() {
error(`Invalid --cwd: ${cwd}`);
}

// Optional --milestone override for multi-milestone support
const msEqArg = args.find(arg => arg.startsWith('--milestone='));
const msIdx = args.indexOf('--milestone');
if (msEqArg) {
const value = msEqArg.slice('--milestone='.length).trim();
if (!value) error('Missing value for --milestone');
args.splice(args.indexOf(msEqArg), 1);
setMilestoneOverride(value);
} else if (msIdx !== -1) {
const value = args[msIdx + 1];
if (!value || value.startsWith('--')) error('Missing value for --milestone');
args.splice(msIdx, 2);
setMilestoneOverride(value);
}

const rawIndex = args.indexOf('--raw');
const raw = rawIndex !== -1;
if (rawIndex !== -1) args.splice(rawIndex, 1);
Expand Down Expand Up @@ -463,8 +479,16 @@ async function main() {
milestoneName = nameArgs.join(' ') || null;
}
milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases }, raw);
} else if (subcommand === 'create') {
milestone.cmdMilestoneCreate(cwd, args[2], raw);
} else if (subcommand === 'switch') {
milestone.cmdMilestoneSwitch(cwd, args[2], raw);
} else if (subcommand === 'list') {
milestone.cmdMilestoneList(cwd, raw);
} else if (subcommand === 'status') {
milestone.cmdMilestoneStatus(cwd, raw);
} else {
error('Unknown milestone subcommand. Available: complete');
error('Unknown milestone subcommand. Available: complete, create, switch, list, status');
}
break;
}
Expand Down
20 changes: 12 additions & 8 deletions get-shit-done/bin/lib/commands.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const path = require('path');
const { execSync } = require('child_process');
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, resolveModelInternal, MODEL_PROFILES, output, error, findPhaseInternal } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { resolvePlanningPaths } = require('./paths.cjs');

function cmdGenerateSlug(text, raw) {
if (!text) {
Expand Down Expand Up @@ -42,7 +43,8 @@ function cmdCurrentTimestamp(format, raw) {
}

function cmdListTodos(cwd, area, raw) {
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const paths = resolvePlanningPaths(cwd);
const pendingDir = path.join(paths.abs.planningRoot, 'todos', 'pending');

let count = 0;
const todos = [];
Expand All @@ -68,7 +70,7 @@ function cmdListTodos(cwd, area, raw) {
created: createdMatch ? createdMatch[1].trim() : 'unknown',
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: todoArea,
path: path.join('.planning', 'todos', 'pending', file),
path: '.planning/todos/pending/' + file,
});
} catch {}
}
Expand Down Expand Up @@ -97,7 +99,7 @@ function cmdVerifyPathExists(cwd, targetPath, raw) {
}

function cmdHistoryDigest(cwd, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const phasesDir = resolvePlanningPaths(cwd).abs.phases;
const digest = { phases: {}, decisions: [], tech_stack: new Set() };

// Collect all phase directories: archived + current
Expand Down Expand Up @@ -380,8 +382,9 @@ async function cmdWebsearch(query, options, raw) {
}

function cmdProgressRender(cwd, format, raw) {
const phasesDir = path.join(cwd, '.planning', 'phases');
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const paths = resolvePlanningPaths(cwd);
const phasesDir = paths.abs.phases;
const roadmapPath = paths.abs.roadmap;
const milestone = getMilestoneInfo(cwd);

const phases = [];
Expand Down Expand Up @@ -452,8 +455,9 @@ function cmdTodoComplete(cwd, filename, raw) {
error('filename required for todo complete');
}

const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const completedDir = path.join(cwd, '.planning', 'todos', 'completed');
const planningRoot = resolvePlanningPaths(cwd).abs.planningRoot;
const pendingDir = path.join(planningRoot, 'todos', 'pending');
const completedDir = path.join(planningRoot, 'todos', 'completed');
const sourcePath = path.join(pendingDir, filename);

if (!fs.existsSync(sourcePath)) {
Expand Down Expand Up @@ -511,7 +515,7 @@ function cmdScaffold(cwd, type, options, raw) {
}
const slug = generateSlugInternal(name);
const dirName = `${padded}-${slug}`;
const phasesParent = path.join(cwd, '.planning', 'phases');
const phasesParent = resolvePlanningPaths(cwd).abs.phases;
fs.mkdirSync(phasesParent, { recursive: true });
const dirPath = path.join(phasesParent, dirName);
fs.mkdirSync(dirPath, { recursive: true });
Expand Down
12 changes: 7 additions & 5 deletions get-shit-done/bin/lib/config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
const fs = require('fs');
const path = require('path');
const { output, error } = require('./core.cjs');
const { resolvePlanningPaths } = require('./paths.cjs');

function cmdConfigEnsureSection(cwd, raw) {
const configPath = path.join(cwd, '.planning', 'config.json');
const planningDir = path.join(cwd, '.planning');
const paths = resolvePlanningPaths(cwd);
const configPath = paths.abs.config;
const planningDir = paths.abs.base;

// Ensure .planning directory exists
try {
Expand Down Expand Up @@ -67,15 +69,15 @@ function cmdConfigEnsureSection(cwd, raw) {

try {
fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
const result = { created: true, path: '.planning/config.json' };
const result = { created: true, path: paths.rel.config };
output(result, raw, 'created');
} catch (err) {
error('Failed to create config.json: ' + err.message);
}
}

function cmdConfigSet(cwd, keyPath, value, raw) {
const configPath = path.join(cwd, '.planning', 'config.json');
const configPath = resolvePlanningPaths(cwd).abs.config;

if (!keyPath) {
error('Usage: config-set <key.path> <value>');
Expand Down Expand Up @@ -120,7 +122,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
}

function cmdConfigGet(cwd, keyPath, raw) {
const configPath = path.join(cwd, '.planning', 'config.json');
const configPath = resolvePlanningPaths(cwd).abs.config;

if (!keyPath) {
error('Usage: config-get <key.path>');
Expand Down
31 changes: 19 additions & 12 deletions get-shit-done/bin/lib/core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { resolvePlanningPaths } = require('./paths.cjs');

// ─── Path helpers ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -64,8 +65,9 @@ function safeReadFile(filePath) {
}
}

function loadConfig(cwd) {
const configPath = path.join(cwd, '.planning', 'config.json');
function loadConfig(cwd, paths) {
const p = paths || resolvePlanningPaths(cwd);
const configPath = p.abs.config;
const defaults = {
model_profile: 'balanced',
commit_docs: true,
Expand Down Expand Up @@ -247,18 +249,19 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
}
}

function findPhaseInternal(cwd, phase) {
function findPhaseInternal(cwd, phase, paths) {
if (!phase) return null;

const phasesDir = path.join(cwd, '.planning', 'phases');
const p = paths || resolvePlanningPaths(cwd);
const phasesDir = p.abs.phases;
const normalized = normalizePhaseName(phase);

// Search current phases first
const current = searchPhaseInDir(phasesDir, '.planning/phases', normalized);
const current = searchPhaseInDir(phasesDir, p.rel.phases, normalized);
if (current) return current;

// Search archived milestone phases (newest first)
const milestonesDir = path.join(cwd, '.planning', 'milestones');
const milestonesDir = p.global.abs.milestonesDir;
if (!fs.existsSync(milestonesDir)) return null;

try {
Expand All @@ -284,8 +287,9 @@ function findPhaseInternal(cwd, phase) {
return null;
}

function getArchivedPhaseDirs(cwd) {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
function getArchivedPhaseDirs(cwd, paths) {
const p = paths || resolvePlanningPaths(cwd);
const milestonesDir = p.global.abs.milestonesDir;
const results = [];

if (!fs.existsSync(milestonesDir)) return results;
Expand Down Expand Up @@ -321,9 +325,10 @@ function getArchivedPhaseDirs(cwd) {

// ─── Roadmap & model utilities ────────────────────────────────────────────────

function getRoadmapPhaseInternal(cwd, phaseNum) {
function getRoadmapPhaseInternal(cwd, phaseNum, paths) {
if (!phaseNum) return null;
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const p = paths || resolvePlanningPaths(cwd);
const roadmapPath = p.abs.roadmap;
if (!fs.existsSync(roadmapPath)) return null;

try {
Expand Down Expand Up @@ -389,9 +394,10 @@ function generateSlugInternal(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}

function getMilestoneInfo(cwd) {
function getMilestoneInfo(cwd, paths) {
try {
const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
const p = paths || resolvePlanningPaths(cwd);
const roadmap = fs.readFileSync(p.abs.roadmap, 'utf-8');

// First: check for list-format roadmaps using 🚧 (in-progress) marker
// e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
Expand Down Expand Up @@ -480,4 +486,5 @@ module.exports = {
getMilestoneInfo,
getMilestonePhaseFilter,
toPosixPath,
resolvePlanningPaths,
};
Loading