From c83b69bbb5190ab9242e91c43d9ba61dc33e09e6 Mon Sep 17 00:00:00 2001 From: SalesTeamToolbox Date: Fri, 20 Mar 2026 14:20:23 -0700 Subject: [PATCH 1/6] feat: workstream namespacing for parallel milestone work Enable multiple Claude Code instances to work on the same codebase simultaneously by scoping .planning/ state into workstreams. Core changes: - planningDir(cwd, ws?) and planningPaths(cwd, ws?) are now workstream-aware via GSD_WORKSTREAM env var (auto-detected from --ws flag or active-workstream file) - All bin/lib modules use planningDir(cwd) for scoped paths (STATE.md, ROADMAP.md, phases/, REQUIREMENTS.md) and planningRoot(cwd) for shared paths (milestones/, PROJECT.md, config.json, codebase/) - New workstream.cjs module: create, list, status, complete, set, get, progress - gsd-tools.cjs: --ws flag parsing with priority chain (--ws > GSD_WORKSTREAM env > active-workstream file > flat mode) - Collision detection: transition.md checks for other active workstreams before suggesting next-milestone continuation (prevents WS A from trampling WS B) - ${GSD_WS} routing propagation across all 9 workflow files ensures workstream scope chains automatically through the workflow lifecycle New files: - get-shit-done/bin/lib/workstream.cjs (CRUD + collision detection) - get-shit-done/commands/gsd/workstreams.md (slash command) - get-shit-done/references/workstream-flag.md (documentation) - tests/workstream.test.cjs (20 tests covering CRUD, env var routing, --ws flag) All 1062 tests passing (1042 existing + 20 new workstream tests). Co-Authored-By: Claude Opus 4.6 (1M context) --- commands/gsd/workstreams.md | 63 +++ get-shit-done/bin/gsd-tools.cjs | 56 ++- get-shit-done/bin/lib/commands.cjs | 14 +- get-shit-done/bin/lib/config.cjs | 22 +- get-shit-done/bin/lib/core.cjs | 80 +++- get-shit-done/bin/lib/init.cjs | 110 ++--- get-shit-done/bin/lib/phase.cjs | 40 +- get-shit-done/bin/lib/state.cjs | 6 +- get-shit-done/bin/lib/uat.cjs | 10 +- get-shit-done/bin/lib/verify.cjs | 21 +- get-shit-done/bin/lib/workstream.cjs | 497 ++++++++++++++++++++ get-shit-done/commands/gsd/workstreams.md | 63 +++ get-shit-done/references/workstream-flag.md | 58 +++ get-shit-done/workflows/discuss-phase.md | 20 +- get-shit-done/workflows/execute-phase.md | 20 +- get-shit-done/workflows/new-milestone.md | 8 +- get-shit-done/workflows/plan-phase.md | 16 +- get-shit-done/workflows/progress.md | 42 +- get-shit-done/workflows/quick.md | 4 +- get-shit-done/workflows/resume-project.md | 14 +- get-shit-done/workflows/transition.md | 109 ++++- get-shit-done/workflows/validate-phase.md | 6 +- tests/workstream.test.cjs | 359 ++++++++++++++ 23 files changed, 1436 insertions(+), 202 deletions(-) create mode 100644 commands/gsd/workstreams.md create mode 100644 get-shit-done/bin/lib/workstream.cjs create mode 100644 get-shit-done/commands/gsd/workstreams.md create mode 100644 get-shit-done/references/workstream-flag.md create mode 100644 tests/workstream.test.cjs diff --git a/commands/gsd/workstreams.md b/commands/gsd/workstreams.md new file mode 100644 index 000000000..1a9191036 --- /dev/null +++ b/commands/gsd/workstreams.md @@ -0,0 +1,63 @@ +--- +description: Manage parallel workstreams — list, create, switch, status, progress, complete, and resume +--- + +# /gsd:workstreams + +Manage parallel workstreams for concurrent milestone work. + +## Usage + +`/gsd:workstreams [subcommand] [args]` + +### Subcommands + +| Command | Description | +|---------|-------------| +| `list` | List all workstreams with status | +| `create ` | Create a new workstream | +| `status ` | Detailed status for one workstream | +| `switch ` | Set active workstream | +| `progress` | Progress summary across all workstreams | +| `complete ` | Archive a completed workstream | +| `resume ` | Resume work in a workstream | + +## Step 1: Parse Subcommand + +Parse the user's input to determine which workstream operation to perform. +If no subcommand given, default to `list`. + +## Step 2: Execute Operation + +### list +Run: `node "$GSD_TOOLS" workstream list --raw --cwd "$CWD"` +Display the workstreams in a table format showing name, status, current phase, and progress. + +### create +Run: `node "$GSD_TOOLS" workstream create --raw --cwd "$CWD"` +After creation, display the new workstream path and suggest next steps: +- `/gsd:new-milestone --ws ` to set up the milestone + +### status +Run: `node "$GSD_TOOLS" workstream status --raw --cwd "$CWD"` +Display detailed phase breakdown and state information. + +### switch +Run: `node "$GSD_TOOLS" workstream set --raw --cwd "$CWD"` +Also set `GSD_WORKSTREAM` env var for the current session. + +### progress +Run: `node "$GSD_TOOLS" workstream progress --raw --cwd "$CWD"` +Display a progress overview across all workstreams. + +### complete +Run: `node "$GSD_TOOLS" workstream complete --raw --cwd "$CWD"` +Archive the workstream to milestones/. + +### resume +Set the workstream as active and suggest `/gsd:resume-work --ws `. + +## Step 3: Display Results + +Format the JSON output from gsd-tools into a human-readable display. +Include the `${GSD_WS}` flag in any routing suggestions. diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 2bd34c619..bf93e83a4 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -135,7 +135,8 @@ const fs = require('fs'); const path = require('path'); -const { error, findProjectRoot } = require('./lib/core.cjs'); +const core = require('./lib/core.cjs'); +const { error, findProjectRoot, getActiveWorkstream } = core; const state = require('./lib/state.cjs'); const phase = require('./lib/phase.cjs'); const roadmap = require('./lib/roadmap.cjs'); @@ -148,6 +149,7 @@ const init = require('./lib/init.cjs'); const frontmatter = require('./lib/frontmatter.cjs'); const profilePipeline = require('./lib/profile-pipeline.cjs'); const profileOutput = require('./lib/profile-output.cjs'); +const workstream = require('./lib/workstream.cjs'); // ─── CLI Router ─────────────────────────────────────────────────────────────── @@ -185,6 +187,29 @@ async function main() { } } + // Optional workstream override for parallel milestone work. + // Priority: --ws flag > GSD_WORKSTREAM env var > active-workstream file > null (flat mode) + const wsEqArg = args.find(arg => arg.startsWith('--ws=')); + const wsIdx = args.indexOf('--ws'); + 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); + } + // Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths + if (ws) { + process.env.GSD_WORKSTREAM = ws; + } + const rawIndex = args.indexOf('--raw'); const raw = rawIndex !== -1; if (rawIndex !== -1) args.splice(rawIndex, 1); @@ -203,7 +228,7 @@ async function main() { const command = args[0]; if (!command) { - error('Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-new-project, init'); + error('Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--ws ]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, config-new-project, init, workstream'); } // Multi-repo guard: resolve project root for commands that read/write .planning/. @@ -862,6 +887,33 @@ async function runCommand(command, args, cwd, raw) { break; } + case 'workstream': { + const subcommand = args[1]; + if (subcommand === 'create') { + const migrateNameIdx = args.indexOf('--migrate-name'); + const noMigrate = args.includes('--no-migrate'); + workstream.cmdWorkstreamCreate(cwd, args[2], { + migrate: !noMigrate, + migrateName: migrateNameIdx !== -1 ? args[migrateNameIdx + 1] : null, + }, raw); + } else if (subcommand === 'list') { + workstream.cmdWorkstreamList(cwd, raw); + } else if (subcommand === 'status') { + workstream.cmdWorkstreamStatus(cwd, args[2], raw); + } else if (subcommand === 'complete') { + workstream.cmdWorkstreamComplete(cwd, args[2], {}, raw); + } else if (subcommand === 'set') { + workstream.cmdWorkstreamSet(cwd, args[2], raw); + } else if (subcommand === 'get') { + workstream.cmdWorkstreamGet(cwd, raw); + } else if (subcommand === 'progress') { + workstream.cmdWorkstreamProgress(cwd, raw); + } else { + error('Unknown workstream subcommand. Available: create, list, status, complete, set, get, progress'); + } + break; + } + default: error(`Unknown command: ${command}`); } diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 3e83d8b1b..425199dde 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, planningPaths, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs'); +const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, planningDir, planningPaths, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { MODEL_PROFILES } = require('./model-profiles.cjs'); @@ -43,7 +43,7 @@ function cmdCurrentTimestamp(format, raw) { } function cmdListTodos(cwd, area, raw) { - const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); + const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); let count = 0; const todos = []; @@ -69,7 +69,7 @@ function cmdListTodos(cwd, area, raw) { created: createdMatch ? createdMatch[1].trim() : 'unknown', title: titleMatch ? titleMatch[1].trim() : 'Untitled', area: todoArea, - path: toPosixPath(path.join('.planning', 'todos', 'pending', file)), + path: toPosixPath(path.relative(cwd, path.join(pendingDir, file))), }); } catch { /* intentionally empty */ } } @@ -580,7 +580,7 @@ function cmdProgressRender(cwd, format, raw) { function cmdTodoMatchPhase(cwd, phase, raw) { if (!phase) { error('phase required for todo match-phase'); } - const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); + const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); const todos = []; // Load pending todos @@ -701,8 +701,8 @@ 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 pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); + const completedDir = path.join(planningDir(cwd), 'todos', 'completed'); const sourcePath = path.join(pendingDir, filename); if (!fs.existsSync(sourcePath)) { @@ -764,7 +764,7 @@ function cmdScaffold(cwd, type, options, raw) { fs.mkdirSync(phasesParent, { recursive: true }); const dirPath = path.join(phasesParent, dirName); fs.mkdirSync(dirPath, { recursive: true }); - output({ created: true, directory: `.planning/phases/${dirName}`, path: dirPath }, raw, dirPath); + output({ created: true, directory: toPosixPath(path.relative(cwd, dirPath)), path: dirPath }, raw, dirPath); return; } default: diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 1570e29ae..764d870a3 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { output, error } = require('./core.cjs'); +const { output, error, planningRoot } = require('./core.cjs'); const { VALID_PROFILES, getAgentToModelMapForProfile, @@ -153,8 +153,8 @@ function buildNewProjectConfig(userChoices) { * Idempotent: if config.json already exists, returns { created: false }. */ function cmdConfigNewProject(cwd, choicesJson, raw) { - const configPath = path.join(cwd, '.planning', 'config.json'); - const planningDir = path.join(cwd, '.planning'); + const planningBase = planningRoot(cwd); + const configPath = path.join(planningBase, 'config.json'); // Idempotent: don't overwrite existing config if (fs.existsSync(configPath)) { @@ -174,8 +174,8 @@ function cmdConfigNewProject(cwd, choicesJson, raw) { // Ensure .planning directory exists try { - if (!fs.existsSync(planningDir)) { - fs.mkdirSync(planningDir, { recursive: true }); + if (!fs.existsSync(planningBase)) { + fs.mkdirSync(planningBase, { recursive: true }); } } catch (err) { error('Failed to create .planning directory: ' + err.message); @@ -198,13 +198,13 @@ function cmdConfigNewProject(cwd, choicesJson, raw) { * the happy path. But note that `error()` will still `exit(1)` out of the process. */ function ensureConfigFile(cwd) { - const configPath = path.join(cwd, '.planning', 'config.json'); - const planningDir = path.join(cwd, '.planning'); + const planningBase = planningRoot(cwd); + const configPath = path.join(planningBase, 'config.json'); // Ensure .planning directory exists try { - if (!fs.existsSync(planningDir)) { - fs.mkdirSync(planningDir, { recursive: true }); + if (!fs.existsSync(planningBase)) { + fs.mkdirSync(planningBase, { recursive: true }); } } catch (err) { error('Failed to create .planning directory: ' + err.message); @@ -248,7 +248,7 @@ function cmdConfigEnsureSection(cwd, raw) { * the happy path. But note that `error()` will still `exit(1)` out of the process. */ function setConfigValue(cwd, keyPath, parsedValue) { - const configPath = path.join(cwd, '.planning', 'config.json'); + const configPath = path.join(planningRoot(cwd), 'config.json'); // Load existing config or start with empty object let config = {}; @@ -311,7 +311,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) { } function cmdConfigGet(cwd, keyPath, raw) { - const configPath = path.join(cwd, '.planning', 'config.json'); + const configPath = path.join(planningRoot(cwd), 'config.json'); if (!keyPath) { error('Usage: config-get '); diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 61b069401..4c2f2cebe 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -523,25 +523,75 @@ function withPlanningLock(cwd, fn) { return fn(); } -/** Get the .planning directory path */ -function planningDir(cwd) { +/** + * Get the .planning directory path, workstream-aware. + * When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var), + * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`. + * + * @param {string} cwd - project root + * @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var + */ +function planningDir(cwd, ws) { + if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; + if (!ws) return path.join(cwd, '.planning'); + return path.join(cwd, '.planning', 'workstreams', ws); +} + +/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */ +function planningRoot(cwd) { return path.join(cwd, '.planning'); } -/** Get common .planning file paths */ -function planningPaths(cwd) { - const base = path.join(cwd, '.planning'); +/** + * Get common .planning file paths, workstream-aware. + * Scoped paths (state, roadmap, phases, requirements) resolve to the active workstream. + * Shared paths (project, config) always resolve to the root .planning/. + */ +function planningPaths(cwd, ws) { + const base = planningDir(cwd, ws); + const root = path.join(cwd, '.planning'); 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'), + project: path.join(root, 'PROJECT.md'), + config: path.join(root, 'config.json'), phases: path.join(base, 'phases'), requirements: path.join(base, 'REQUIREMENTS.md'), }; } +// ─── Active Workstream Detection ───────────────────────────────────────────── + +/** + * Get the active workstream name from .planning/active-workstream file. + * Returns null if no active workstream or file doesn't exist. + */ +function getActiveWorkstream(cwd) { + const filePath = path.join(cwd, '.planning', 'active-workstream'); + try { + const name = fs.readFileSync(filePath, 'utf-8').trim(); + if (!name) return null; + const wsDir = path.join(cwd, '.planning', 'workstreams', name); + if (!fs.existsSync(wsDir)) return null; + return name; + } catch { + return null; + } +} + +/** + * Set the active workstream. Pass null to clear. + */ +function setActiveWorkstream(cwd, name) { + const filePath = path.join(cwd, '.planning', 'active-workstream'); + if (!name) { + try { fs.unlinkSync(filePath); } catch {} + return; + } + fs.writeFileSync(filePath, name + '\n', 'utf-8'); +} + // ─── Phase utilities ────────────────────────────────────────────────────────── function escapeRegex(value) { @@ -650,11 +700,12 @@ function searchPhaseInDir(baseDir, relBase, normalized) { function findPhaseInternal(cwd, phase) { if (!phase) return null; - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const normalized = normalizePhaseName(phase); // Search current phases first - const current = searchPhaseInDir(phasesDir, '.planning/phases', normalized); + const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir)); + const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized); if (current) return current; // Search archived milestone phases (newest first) @@ -753,7 +804,7 @@ function extractCurrentMilestone(content, cwd) { // 1. Get current milestone version from STATE.md frontmatter let version = null; try { - const statePath = path.join(cwd, '.planning', 'STATE.md'); + const statePath = path.join(planningDir(cwd), 'STATE.md'); if (fs.existsSync(statePath)) { const stateRaw = fs.readFileSync(statePath, 'utf-8'); const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); @@ -836,7 +887,7 @@ function replaceInCurrentMilestone(content, pattern, replacement) { function getRoadmapPhaseInternal(cwd, phaseNum) { if (!phaseNum) return null; - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) return null; try { @@ -951,7 +1002,7 @@ function generateSlugInternal(text) { function getMilestoneInfo(cwd) { try { - const roadmap = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'); + const roadmap = fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'); // First: check for list-format roadmaps using 🚧 (in-progress) marker // e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)" @@ -994,7 +1045,7 @@ function getMilestoneInfo(cwd) { function getMilestonePhaseFilter(cwd) { const milestonePhaseNums = new Set(); try { - const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'), cwd); + const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd); // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:) const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; let m; @@ -1058,5 +1109,8 @@ module.exports = { reapStaleTempFiles, MODEL_ALIAS_MAP, planningDir, + planningRoot, planningPaths, + getActiveWorkstream, + setActiveWorkstream, }; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 7fadfc97e..4c94a032a 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -5,10 +5,10 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, planningPaths, toPosixPath, output, error } = require('./core.cjs'); +const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, planningPaths, planningDir, planningRoot, toPosixPath, output, error } = require('./core.cjs'); function getLatestCompletedMilestone(cwd) { - const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md'); + const milestonesPath = path.join(planningRoot(cwd), 'MILESTONES.md'); if (!fs.existsSync(milestonesPath)) return null; try { @@ -116,13 +116,13 @@ function cmdInitExecutePhase(cwd, phase, raw) { milestone_slug: generateSlugInternal(milestone.name), // File existence - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - config_exists: pathExistsInternal(cwd, '.planning/config.json'), + state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), + config_exists: fs.existsSync(path.join(planningDir(cwd), 'config.json')), // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - config_path: '.planning/config.json', + state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))), + roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))), + config_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'config.json'))), }; output(withProjectRoot(cwd, result), raw); @@ -191,13 +191,13 @@ function cmdInitPlanPhase(cwd, phase, raw) { plan_count: phaseInfo?.plans?.length || 0, // Environment - planning_exists: pathExistsInternal(cwd, '.planning'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + planning_exists: fs.existsSync(planningDir(cwd)), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - requirements_path: '.planning/REQUIREMENTS.md', + state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))), + roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))), + requirements_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'REQUIREMENTS.md'))), }; if (phaseInfo?.directory) { @@ -313,7 +313,7 @@ function cmdInitNewMilestone(cwd, raw) { const config = loadConfig(cwd); const milestone = getMilestoneInfo(cwd); const latestCompleted = getLatestCompletedMilestone(cwd); - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); let phaseDirCount = 0; try { @@ -340,17 +340,17 @@ function cmdInitNewMilestone(cwd, raw) { latest_completed_milestone: latestCompleted?.version || null, latest_completed_milestone_name: latestCompleted?.name || null, phase_dir_count: phaseDirCount, - phase_archive_path: latestCompleted ? `.planning/milestones/${latestCompleted.version}-phases` : null, + phase_archive_path: latestCompleted ? toPosixPath(path.relative(cwd, path.join(planningRoot(cwd), 'milestones', `${latestCompleted.version}-phases`))) : null, // File existence project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), + state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), // File paths project_path: '.planning/PROJECT.md', - roadmap_path: '.planning/ROADMAP.md', - state_path: '.planning/STATE.md', + roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))), + state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))), }; output(withProjectRoot(cwd, result), raw); @@ -406,8 +406,8 @@ function cmdInitQuick(cwd, description, raw) { task_dir: slug ? `.planning/quick/${quickId}-${slug}` : null, // File existence - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - planning_exists: pathExistsInternal(cwd, '.planning'), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), + planning_exists: fs.existsSync(planningRoot(cwd)), }; @@ -420,19 +420,19 @@ function cmdInitResume(cwd, raw) { // Check for interrupted agent let interruptedAgentId = null; try { - interruptedAgentId = fs.readFileSync(path.join(cwd, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); + interruptedAgentId = fs.readFileSync(path.join(planningRoot(cwd), 'current-agent-id.txt'), 'utf-8').trim(); } catch { /* intentionally empty */ } const result = { // File existence - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), + state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - planning_exists: pathExistsInternal(cwd, '.planning'), + planning_exists: fs.existsSync(planningRoot(cwd)), // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', + state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))), + roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))), project_path: '.planning/PROJECT.md', // Agent state @@ -568,13 +568,13 @@ function cmdInitPhaseOp(cwd, phase, raw) { plan_count: phaseInfo?.plans?.length || 0, // File existence - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - planning_exists: pathExistsInternal(cwd, '.planning'), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), + planning_exists: fs.existsSync(planningDir(cwd)), // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', - requirements_path: '.planning/REQUIREMENTS.md', + state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))), + roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))), + requirements_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'REQUIREMENTS.md'))), }; if (phaseInfo?.directory) { @@ -612,7 +612,7 @@ function cmdInitTodos(cwd, area, raw) { const now = new Date(); // List todos (reuse existing logic) - const pendingDir = path.join(cwd, '.planning', 'todos', 'pending'); + const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); let count = 0; const todos = []; @@ -634,7 +634,7 @@ function cmdInitTodos(cwd, area, raw) { created: createdMatch ? createdMatch[1].trim() : 'unknown', title: titleMatch ? titleMatch[1].trim() : 'Untitled', area: todoArea, - path: '.planning/todos/pending/' + file, + path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'todos', 'pending', file))), }); } catch { /* intentionally empty */ } } @@ -654,13 +654,13 @@ function cmdInitTodos(cwd, area, raw) { area_filter: area || null, // Paths - pending_dir: '.planning/todos/pending', - completed_dir: '.planning/todos/completed', + pending_dir: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'todos', 'pending'))), + completed_dir: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'todos', 'completed'))), // File existence - planning_exists: pathExistsInternal(cwd, '.planning'), - todos_dir_exists: pathExistsInternal(cwd, '.planning/todos'), - pending_dir_exists: pathExistsInternal(cwd, '.planning/todos/pending'), + planning_exists: fs.existsSync(planningDir(cwd)), + todos_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos')), + pending_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos', 'pending')), }; output(withProjectRoot(cwd, result), raw); @@ -673,7 +673,7 @@ function cmdInitMilestoneOp(cwd, raw) { // Count phases let phaseCount = 0; let completedPhases = 0; - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); @@ -690,7 +690,7 @@ function cmdInitMilestoneOp(cwd, raw) { } catch { /* intentionally empty */ } // Check archive - const archiveDir = path.join(cwd, '.planning', 'archive'); + const archiveDir = path.join(planningRoot(cwd), 'archive'); let archivedMilestones = []; try { archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true }) @@ -718,10 +718,10 @@ function cmdInitMilestoneOp(cwd, raw) { // File existence project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), - archive_exists: pathExistsInternal(cwd, '.planning/archive'), - phases_dir_exists: pathExistsInternal(cwd, '.planning/phases'), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), + state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), + archive_exists: fs.existsSync(path.join(planningRoot(cwd), 'archive')), + phases_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'phases')), }; output(withProjectRoot(cwd, result), raw); @@ -731,7 +731,7 @@ function cmdInitMapCodebase(cwd, raw) { const config = loadConfig(cwd); // Check for existing codebase maps - const codebaseDir = path.join(cwd, '.planning', 'codebase'); + const codebaseDir = path.join(planningRoot(cwd), 'codebase'); let existingMaps = []; try { existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md')); @@ -1013,7 +1013,7 @@ function cmdInitProgress(cwd, raw) { const milestone = getMilestoneInfo(cwd); // Analyze phases — filter to current milestone and include ROADMAP-only phases - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const phases = []; let currentPhase = null; let nextPhase = null; @@ -1023,7 +1023,7 @@ function cmdInitProgress(cwd, raw) { const roadmapPhaseNames = new Map(); try { const roadmapContent = extractCurrentMilestone( - fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'), cwd + fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd ); const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; let hm; @@ -1067,7 +1067,7 @@ function cmdInitProgress(cwd, raw) { const phaseInfo = { number: phaseNumber, name: phaseName, - directory: '.planning/phases/' + dir, + directory: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'phases', dir))), status, plan_count: plans.length, summary_count: summaries.length, @@ -1112,7 +1112,7 @@ function cmdInitProgress(cwd, raw) { // Check for paused work let pausedAt = null; try { - const state = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf-8'); + const state = fs.readFileSync(path.join(planningDir(cwd), 'STATE.md'), 'utf-8'); const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/); if (pauseMatch) pausedAt = pauseMatch[1].trim(); } catch { /* intentionally empty */ } @@ -1143,13 +1143,13 @@ function cmdInitProgress(cwd, raw) { // File existence project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), - roadmap_exists: pathExistsInternal(cwd, '.planning/ROADMAP.md'), - state_exists: pathExistsInternal(cwd, '.planning/STATE.md'), + roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')), + state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')), // File paths - state_path: '.planning/STATE.md', - roadmap_path: '.planning/ROADMAP.md', + state_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'STATE.md'))), + roadmap_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'ROADMAP.md'))), project_path: '.planning/PROJECT.md', - config_path: '.planning/config.json', + config_path: toPosixPath(path.relative(cwd, path.join(planningDir(cwd), 'config.json'))), }; output(withProjectRoot(cwd, result), raw); diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 5b01f2bbd..97a075316 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -4,12 +4,12 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error } = require('./core.cjs'); +const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs'); function cmdPhasesList(cwd, options, raw) { - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const { type, phase, includeArchived } = options; // If no phases directory, return empty @@ -85,7 +85,7 @@ function cmdPhasesList(cwd, options, raw) { } function cmdPhaseNextDecimal(cwd, basePhase, raw) { - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const normalized = normalizePhaseName(basePhase); // Check if phases directory exists @@ -154,7 +154,7 @@ function cmdFindPhase(cwd, phase, raw) { error('phase identifier required'); } - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const normalized = normalizePhaseName(phase); const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] }; @@ -180,7 +180,7 @@ function cmdFindPhase(cwd, phase, raw) { const result = { found: true, - directory: toPosixPath(path.join('.planning', 'phases', match)), + directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', match)), phase_number: phaseNumber, phase_name: phaseName, plans, @@ -203,7 +203,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) { error('phase required for phase-plan-index'); } - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const normalized = normalizePhaseName(phase); // Find phase directory @@ -314,7 +314,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) { } const config = loadConfig(cwd); - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) { error('ROADMAP.md not found'); } @@ -346,7 +346,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) { dirName = `${paddedNum}-${slug}`; } - const dirPath = path.join(cwd, '.planning', 'phases', dirName); + const dirPath = path.join(planningDir(cwd), 'phases', dirName); // Create directory with .gitkeep so git tracks empty folders fs.mkdirSync(dirPath, { recursive: true }); @@ -372,7 +372,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) { padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId), name: description, slug, - directory: `.planning/phases/${dirName}`, + directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)), naming_mode: config.phase_naming, }; @@ -384,7 +384,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { error('after-phase and description required for phase insert'); } - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) { error('ROADMAP.md not found'); } @@ -403,7 +403,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { } // Calculate next decimal using existing logic - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const normalizedBase = normalizePhaseName(afterPhase); let existingDecimals = []; @@ -420,7 +420,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; const decimalPhase = `${normalizedBase}.${nextDecimal}`; const dirName = `${decimalPhase}-${slug}`; - const dirPath = path.join(cwd, '.planning', 'phases', dirName); + const dirPath = path.join(planningDir(cwd), 'phases', dirName); // Create directory with .gitkeep so git tracks empty folders fs.mkdirSync(dirPath, { recursive: true }); @@ -455,7 +455,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { after_phase: afterPhase, name: description, slug, - directory: `.planning/phases/${dirName}`, + directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', dirName)), }; output(result, raw, decimalPhase); @@ -466,8 +466,8 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { error('phase number required for phase remove'); } - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const phasesDir = path.join(cwd, '.planning', 'phases'); + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const force = options.force || false; if (!fs.existsSync(roadmapPath)) { @@ -682,7 +682,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); // Update STATE.md phase count - const statePath = path.join(cwd, '.planning', 'STATE.md'); + const statePath = path.join(planningDir(cwd), 'STATE.md'); if (fs.existsSync(statePath)) { let stateContent = fs.readFileSync(statePath, 'utf-8'); // Update "Total Phases" field — supports both bold and plain formats @@ -718,9 +718,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { error('phase number required for phase complete'); } - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const statePath = path.join(cwd, '.planning', 'STATE.md'); - const phasesDir = path.join(cwd, '.planning', 'phases'); + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); + const statePath = path.join(planningDir(cwd), 'STATE.md'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const normalized = normalizePhaseName(phaseNum); const today = new Date().toISOString().split('T')[0]; @@ -799,7 +799,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); // Update REQUIREMENTS.md traceability for this phase's requirements - const reqPath = path.join(cwd, '.planning', 'REQUIREMENTS.md'); + const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md'); if (fs.existsSync(reqPath)) { // Extract the current phase section from roadmap (scoped to avoid cross-phase matching) const phaseEsc = escapeRegex(phaseNum); diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index d93f2e2ac..a7d769f46 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningPaths, output, error } = require('./core.cjs'); +const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, planningDir, planningPaths, output, error } = require('./core.cjs'); const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs'); /** Shorthand — every state command needs this path */ @@ -905,7 +905,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) { * Fixes #1034. */ function cmdSignalWaiting(cwd, type, question, options, phase, raw) { - const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : path.join(cwd, '.planning'); + const gsdDir = fs.existsSync(path.join(cwd, '.gsd')) ? path.join(cwd, '.gsd') : planningDir(cwd); const waitingPath = path.join(gsdDir, 'WAITING.json'); const signal = { @@ -932,7 +932,7 @@ function cmdSignalWaiting(cwd, type, question, options, phase, raw) { function cmdSignalResume(cwd, raw) { const paths = [ path.join(cwd, '.gsd', 'WAITING.json'), - path.join(cwd, '.planning', 'WAITING.json'), + path.join(planningDir(cwd), 'WAITING.json'), ]; let removed = false; diff --git a/get-shit-done/bin/lib/uat.cjs b/get-shit-done/bin/lib/uat.cjs index 652fae16e..1af4b815e 100644 --- a/get-shit-done/bin/lib/uat.cjs +++ b/get-shit-done/bin/lib/uat.cjs @@ -7,13 +7,13 @@ const fs = require('fs'); const path = require('path'); -const { output, error, getMilestonePhaseFilter } = require('./core.cjs'); +const { output, error, getMilestonePhaseFilter, planningDir, toPosixPath } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); function cmdAuditUat(cwd, raw) { - const phasesDir = path.join(cwd, '.planning', 'phases'); + const phasesDir = path.join(planningDir(cwd), 'phases'); if (!fs.existsSync(phasesDir)) { - error('No .planning/phases directory found'); + error('No phases directory found in planning directory'); } const isDirInMilestone = getMilestonePhaseFilter(cwd); @@ -41,7 +41,7 @@ function cmdAuditUat(cwd, raw) { phase: phaseNum, phase_dir: dir, file, - file_path: `.planning/phases/${dir}/${file}`, + file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))), type: 'uat', status: (extractFrontmatter(content).status || 'unknown'), items, @@ -60,7 +60,7 @@ function cmdAuditUat(cwd, raw) { phase: phaseNum, phase_dir: dir, file, - file_path: `.planning/phases/${dir}/${file}`, + file_path: toPosixPath(path.relative(cwd, path.join(phaseDir, file))), type: 'verification', status, items, diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 57adb1878..c0610f4c8 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { safeReadFile, loadConfig, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error } = require('./core.cjs'); +const { safeReadFile, loadConfig, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error } = require('./core.cjs'); const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs'); const { writeStateMd } = require('./state.cjs'); @@ -396,8 +396,8 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) { } function cmdValidateConsistency(cwd, raw) { - const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); - const phasesDir = path.join(cwd, '.planning', 'phases'); + const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); + const phasesDir = path.join(planningDir(cwd), 'phases'); const errors = []; const warnings = []; @@ -533,12 +533,13 @@ function cmdValidateHealth(cwd, options, raw) { return; } - const planningDir = path.join(cwd, '.planning'); - const projectPath = path.join(planningDir, 'PROJECT.md'); - const roadmapPath = path.join(planningDir, 'ROADMAP.md'); - const statePath = path.join(planningDir, 'STATE.md'); - const configPath = path.join(planningDir, 'config.json'); - const phasesDir = path.join(planningDir, 'phases'); + const planBase = planningDir(cwd); + const planRoot = planningRoot(cwd); + const projectPath = path.join(planRoot, 'PROJECT.md'); + const roadmapPath = path.join(planBase, 'ROADMAP.md'); + const statePath = path.join(planBase, 'STATE.md'); + const configPath = path.join(planRoot, 'config.json'); + const phasesDir = path.join(planBase, 'phases'); const errors = []; const warnings = []; @@ -554,7 +555,7 @@ function cmdValidateHealth(cwd, options, raw) { }; // ─── Check 1: .planning/ exists ─────────────────────────────────────────── - if (!fs.existsSync(planningDir)) { + if (!fs.existsSync(planBase)) { addIssue('error', 'E001', '.planning/ directory not found', 'Run /gsd:new-project to initialize'); output({ status: 'broken', diff --git a/get-shit-done/bin/lib/workstream.cjs b/get-shit-done/bin/lib/workstream.cjs new file mode 100644 index 000000000..7a21c56bb --- /dev/null +++ b/get-shit-done/bin/lib/workstream.cjs @@ -0,0 +1,497 @@ +/** + * Workstream — CRUD operations for workstream namespacing + * + * Workstreams enable parallel milestones by scoping ROADMAP.md, STATE.md, + * REQUIREMENTS.md, and phases/ into .planning/workstreams/{name}/ directories. + * + * When no workstreams/ directory exists, GSD operates in "flat mode" with + * everything at .planning/ — backward compatible with pre-workstream installs. + */ + +const fs = require('fs'); +const path = require('path'); +const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream } = require('./core.cjs'); + +// ─── Migration ────────────────────────────────────────────────────────────── + +/** + * Migrate flat .planning/ layout to workstream mode. + * Moves per-workstream files (ROADMAP.md, STATE.md, REQUIREMENTS.md, phases/) + * into .planning/workstreams/{name}/. Shared files (PROJECT.md, config.json, + * milestones/, research/, codebase/, todos/) stay in place. + */ +function migrateToWorkstreams(cwd, workstreamName) { + if (!workstreamName || /[/\\]/.test(workstreamName) || workstreamName === '.' || workstreamName === '..') { + throw new Error('Invalid workstream name for migration'); + } + + const baseDir = planningRoot(cwd); + const wsDir = path.join(baseDir, 'workstreams', workstreamName); + + if (fs.existsSync(path.join(baseDir, 'workstreams'))) { + throw new Error('Already in workstream mode — .planning/workstreams/ exists'); + } + + const toMove = [ + { name: 'ROADMAP.md', type: 'file' }, + { name: 'STATE.md', type: 'file' }, + { name: 'REQUIREMENTS.md', type: 'file' }, + { name: 'phases', type: 'dir' }, + ]; + + fs.mkdirSync(wsDir, { recursive: true }); + + const filesMoved = []; + try { + for (const item of toMove) { + const src = path.join(baseDir, item.name); + if (fs.existsSync(src)) { + const dest = path.join(wsDir, item.name); + fs.renameSync(src, dest); + filesMoved.push(item.name); + } + } + } catch (err) { + for (const name of filesMoved) { + try { fs.renameSync(path.join(wsDir, name), path.join(baseDir, name)); } catch {} + } + try { fs.rmSync(wsDir, { recursive: true }); } catch {} + try { fs.rmdirSync(path.join(baseDir, 'workstreams')); } catch {} + throw err; + } + + return { migrated: true, workstream: workstreamName, files_moved: filesMoved }; +} + +// ─── CRUD Commands ────────────────────────────────────────────────────────── + +function cmdWorkstreamCreate(cwd, name, options, raw) { + if (!name) { + error('workstream name required. Usage: workstream create '); + } + + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + if (!slug) { + error('Invalid workstream name — must contain at least one alphanumeric character'); + } + + const baseDir = planningRoot(cwd); + if (!fs.existsSync(baseDir)) { + error('.planning/ directory not found — run /gsd:new-project first'); + } + + const wsRoot = path.join(baseDir, 'workstreams'); + const wsDir = path.join(wsRoot, slug); + + if (fs.existsSync(wsDir) && fs.existsSync(path.join(wsDir, 'STATE.md'))) { + output({ created: false, error: 'already_exists', workstream: slug, path: toPosixPath(path.relative(cwd, wsDir)) }, raw); + return; + } + + const isFlatMode = !fs.existsSync(wsRoot); + let migration = null; + if (isFlatMode && options.migrate !== false) { + const hasExistingWork = fs.existsSync(path.join(baseDir, 'ROADMAP.md')) || + fs.existsSync(path.join(baseDir, 'STATE.md')) || + fs.existsSync(path.join(baseDir, 'phases')); + + if (hasExistingWork) { + const migrateName = options.migrateName || null; + let existingWsName; + if (migrateName) { + existingWsName = migrateName; + } else { + try { + const milestone = getMilestoneInfo(cwd); + existingWsName = generateSlugInternal(milestone.name) || 'default'; + } catch { + existingWsName = 'default'; + } + } + + try { + migration = migrateToWorkstreams(cwd, existingWsName); + } catch (e) { + output({ created: false, error: 'migration_failed', message: e.message }, raw); + return; + } + } else { + fs.mkdirSync(wsRoot, { recursive: true }); + } + } + + fs.mkdirSync(wsDir, { recursive: true }); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + + const today = new Date().toISOString().split('T')[0]; + const stateContent = [ + '---', + `workstream: ${slug}`, + `created: ${today}`, + '---', + '', + '# Project State', + '', + '## Current Position', + '**Status:** Not started', + '**Current Phase:** None', + `**Last Activity:** ${today}`, + '**Last Activity Description:** Workstream created', + '', + '## Progress', + '**Phases Complete:** 0', + '**Current Plan:** N/A', + '', + '## Session Continuity', + '**Stopped At:** N/A', + '**Resume File:** None', + '', + ].join('\n'); + + const statePath = path.join(wsDir, 'STATE.md'); + if (!fs.existsSync(statePath)) { + fs.writeFileSync(statePath, stateContent, 'utf-8'); + } + + setActiveWorkstream(cwd, slug); + + const relPath = toPosixPath(path.relative(cwd, wsDir)); + output({ + created: true, + workstream: slug, + path: relPath, + state_path: relPath + '/STATE.md', + phases_path: relPath + '/phases', + migration: migration || null, + active: true, + }, raw); +} + +function cmdWorkstreamList(cwd, raw) { + const wsRoot = path.join(planningRoot(cwd), 'workstreams'); + + if (!fs.existsSync(wsRoot)) { + output({ mode: 'flat', workstreams: [], message: 'No workstreams — operating in flat mode' }, raw); + return; + } + + const entries = fs.readdirSync(wsRoot, { withFileTypes: true }); + const workstreams = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const wsDir = path.join(wsRoot, entry.name); + const phasesDir = path.join(wsDir, 'phases'); + + let phaseCount = 0, completedCount = 0; + try { + const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); + phaseCount = dirs.length; + for (const d of dirs) { + const files = fs.readdirSync(path.join(phasesDir, d.name)); + const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (plans.length > 0 && summaries.length >= plans.length) completedCount++; + } + } catch {} + + let status = 'unknown', currentPhase = null; + try { + const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8'); + const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); + if (statusMatch) status = statusMatch[1].trim(); + const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); + if (phaseMatch) currentPhase = phaseMatch[1].trim(); + } catch {} + + workstreams.push({ + name: entry.name, + path: toPosixPath(path.relative(cwd, wsDir)), + has_roadmap: fs.existsSync(path.join(wsDir, 'ROADMAP.md')), + has_state: fs.existsSync(path.join(wsDir, 'STATE.md')), + status, + current_phase: currentPhase, + phase_count: phaseCount, + completed_phases: completedCount, + }); + } + + output({ mode: 'workstream', workstreams, count: workstreams.length }, raw); +} + +function cmdWorkstreamStatus(cwd, name, raw) { + if (!name) error('workstream name required. Usage: workstream status '); + if (/[/\\]/.test(name) || name === '.' || name === '..') error('Invalid workstream name'); + + const wsDir = path.join(planningRoot(cwd), 'workstreams', name); + if (!fs.existsSync(wsDir)) { + output({ found: false, workstream: name }, raw); + return; + } + + const p = planningPaths(cwd, name); + const relPath = toPosixPath(path.relative(cwd, wsDir)); + + const files = { + roadmap: fs.existsSync(p.roadmap), + state: fs.existsSync(p.state), + requirements: fs.existsSync(p.requirements), + }; + + const phases = []; + try { + const dirs = fs.readdirSync(p.phases, { withFileTypes: true }) + .filter(e => e.isDirectory()).map(e => e.name).sort(); + for (const dir of dirs) { + const phasePath = path.join(p.phases, dir); + const phaseFiles = fs.readdirSync(phasePath); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + phases.push({ + directory: dir, + status: summaries.length >= plans.length && plans.length > 0 ? 'complete' : + plans.length > 0 ? 'in_progress' : 'pending', + plan_count: plans.length, + summary_count: summaries.length, + }); + } + } catch {} + + let stateInfo = {}; + try { + const stateContent = fs.readFileSync(p.state, 'utf-8'); + const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); + const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); + const activityMatch = stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/); + stateInfo = { + status: statusMatch ? statusMatch[1].trim() : 'unknown', + current_phase: phaseMatch ? phaseMatch[1].trim() : null, + last_activity: activityMatch ? activityMatch[1].trim() : null, + }; + } catch {} + + output({ + found: true, + workstream: name, + path: relPath, + files, + phases, + phase_count: phases.length, + completed_phases: phases.filter(ph => ph.status === 'complete').length, + ...stateInfo, + }, raw); +} + +function cmdWorkstreamComplete(cwd, name, options, raw) { + if (!name) error('workstream name required. Usage: workstream complete '); + if (/[/\\]/.test(name) || name === '.' || name === '..') error('Invalid workstream name'); + + const root = planningRoot(cwd); + const wsRoot = path.join(root, 'workstreams'); + const wsDir = path.join(wsRoot, name); + + if (!fs.existsSync(wsDir)) { + output({ completed: false, error: 'not_found', workstream: name }, raw); + return; + } + + const active = getActiveWorkstream(cwd); + if (active === name) setActiveWorkstream(cwd, null); + + const archiveDir = path.join(root, 'milestones'); + const today = new Date().toISOString().split('T')[0]; + let archivePath = path.join(archiveDir, `ws-${name}-${today}`); + let suffix = 1; + while (fs.existsSync(archivePath)) { + archivePath = path.join(archiveDir, `ws-${name}-${today}-${suffix++}`); + } + + fs.mkdirSync(archivePath, { recursive: true }); + + const filesMoved = []; + try { + const entries = fs.readdirSync(wsDir, { withFileTypes: true }); + for (const entry of entries) { + fs.renameSync(path.join(wsDir, entry.name), path.join(archivePath, entry.name)); + filesMoved.push(entry.name); + } + } catch (err) { + for (const fname of filesMoved) { + try { fs.renameSync(path.join(archivePath, fname), path.join(wsDir, fname)); } catch {} + } + try { fs.rmSync(archivePath, { recursive: true }); } catch {} + if (active === name) setActiveWorkstream(cwd, name); + output({ completed: false, error: 'archive_failed', message: err.message, workstream: name }, raw); + return; + } + + try { fs.rmdirSync(wsDir); } catch {} + + let remainingWs = 0; + try { + remainingWs = fs.readdirSync(wsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).length; + if (remainingWs === 0) fs.rmdirSync(wsRoot); + } catch {} + + output({ + completed: true, + workstream: name, + archived_to: toPosixPath(path.relative(cwd, archivePath)), + remaining_workstreams: remainingWs, + reverted_to_flat: remainingWs === 0, + }, raw); +} + +// ─── Active Workstream Commands ────────────────────────────────────────────── + +function cmdWorkstreamSet(cwd, name, raw) { + if (!name) { + setActiveWorkstream(cwd, null); + output({ active: null, cleared: true }, raw); + return; + } + + const wsDir = path.join(planningRoot(cwd), 'workstreams', name); + if (!fs.existsSync(wsDir)) { + output({ active: null, error: 'not_found', workstream: name }, raw); + return; + } + + setActiveWorkstream(cwd, name); + output({ active: name, set: true }, raw, name); +} + +function cmdWorkstreamGet(cwd, raw) { + const active = getActiveWorkstream(cwd); + const wsRoot = path.join(planningRoot(cwd), 'workstreams'); + output({ active, mode: fs.existsSync(wsRoot) ? 'workstream' : 'flat' }, raw, active || 'none'); +} + +function cmdWorkstreamProgress(cwd, raw) { + const root = planningRoot(cwd); + const wsRoot = path.join(root, 'workstreams'); + + if (!fs.existsSync(wsRoot)) { + output({ mode: 'flat', workstreams: [], message: 'No workstreams — operating in flat mode' }, raw); + return; + } + + const active = getActiveWorkstream(cwd); + const entries = fs.readdirSync(wsRoot, { withFileTypes: true }); + const workstreams = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const wsDir = path.join(wsRoot, entry.name); + const phasesDir = path.join(wsDir, 'phases'); + + let phaseCount = 0, completedCount = 0, totalPlans = 0, completedPlans = 0; + try { + const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); + phaseCount = dirs.length; + for (const d of dirs) { + const files = fs.readdirSync(path.join(phasesDir, d.name)); + const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + totalPlans += plans.length; + completedPlans += Math.min(summaries.length, plans.length); + if (plans.length > 0 && summaries.length >= plans.length) completedCount++; + } + } catch {} + + let roadmapPhaseCount = phaseCount; + try { + const roadmapContent = fs.readFileSync(path.join(wsDir, 'ROADMAP.md'), 'utf-8'); + const phaseMatches = roadmapContent.match(/^###?\s+Phase\s+\d/gm); + if (phaseMatches) roadmapPhaseCount = phaseMatches.length; + } catch {} + + let status = 'unknown', currentPhase = null; + try { + const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8'); + const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); + if (statusMatch) status = statusMatch[1].trim(); + const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); + if (phaseMatch) currentPhase = phaseMatch[1].trim(); + } catch {} + + workstreams.push({ + name: entry.name, + active: entry.name === active, + status, + current_phase: currentPhase, + phases: `${completedCount}/${roadmapPhaseCount}`, + plans: `${completedPlans}/${totalPlans}`, + progress_percent: roadmapPhaseCount > 0 ? Math.round((completedCount / roadmapPhaseCount) * 100) : 0, + }); + } + + output({ mode: 'workstream', active, workstreams, count: workstreams.length }, raw); +} + +// ─── Collision Detection ──────────────────────────────────────────────────── + +/** + * Return other workstreams that are NOT complete. + * Used to detect whether the milestone has active parallel work + * when a workstream finishes its last phase. + */ +function getOtherActiveWorkstreams(cwd, excludeWs) { + const wsRoot = path.join(planningRoot(cwd), 'workstreams'); + if (!fs.existsSync(wsRoot)) return []; + + const entries = fs.readdirSync(wsRoot, { withFileTypes: true }); + const others = []; + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === excludeWs) continue; + + const wsDir = path.join(wsRoot, entry.name); + const statePath = path.join(wsDir, 'STATE.md'); + + let status = 'unknown', currentPhase = null; + try { + const content = fs.readFileSync(statePath, 'utf-8'); + const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/); + if (statusMatch) status = statusMatch[1].trim(); + const phaseMatch = content.match(/\*\*Current Phase:\*\*\s*(.+)/); + if (phaseMatch) currentPhase = phaseMatch[1].trim(); + } catch {} + + if (status.toLowerCase().includes('milestone complete') || + status.toLowerCase().includes('archived')) { + continue; + } + + let phaseCount = 0, completedCount = 0; + try { + const phasesDir = path.join(wsDir, 'phases'); + const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); + phaseCount = dirs.length; + for (const d of dirs) { + const files = fs.readdirSync(path.join(phasesDir, d.name)); + const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (plans.length > 0 && summaries.length >= plans.length) completedCount++; + } + } catch {} + + others.push({ name: entry.name, status, current_phase: currentPhase, phases: `${completedCount}/${phaseCount}` }); + } + + return others; +} + +module.exports = { + migrateToWorkstreams, + cmdWorkstreamCreate, + cmdWorkstreamList, + cmdWorkstreamStatus, + cmdWorkstreamComplete, + cmdWorkstreamSet, + cmdWorkstreamGet, + cmdWorkstreamProgress, + getOtherActiveWorkstreams, +}; diff --git a/get-shit-done/commands/gsd/workstreams.md b/get-shit-done/commands/gsd/workstreams.md new file mode 100644 index 000000000..1a9191036 --- /dev/null +++ b/get-shit-done/commands/gsd/workstreams.md @@ -0,0 +1,63 @@ +--- +description: Manage parallel workstreams — list, create, switch, status, progress, complete, and resume +--- + +# /gsd:workstreams + +Manage parallel workstreams for concurrent milestone work. + +## Usage + +`/gsd:workstreams [subcommand] [args]` + +### Subcommands + +| Command | Description | +|---------|-------------| +| `list` | List all workstreams with status | +| `create ` | Create a new workstream | +| `status ` | Detailed status for one workstream | +| `switch ` | Set active workstream | +| `progress` | Progress summary across all workstreams | +| `complete ` | Archive a completed workstream | +| `resume ` | Resume work in a workstream | + +## Step 1: Parse Subcommand + +Parse the user's input to determine which workstream operation to perform. +If no subcommand given, default to `list`. + +## Step 2: Execute Operation + +### list +Run: `node "$GSD_TOOLS" workstream list --raw --cwd "$CWD"` +Display the workstreams in a table format showing name, status, current phase, and progress. + +### create +Run: `node "$GSD_TOOLS" workstream create --raw --cwd "$CWD"` +After creation, display the new workstream path and suggest next steps: +- `/gsd:new-milestone --ws ` to set up the milestone + +### status +Run: `node "$GSD_TOOLS" workstream status --raw --cwd "$CWD"` +Display detailed phase breakdown and state information. + +### switch +Run: `node "$GSD_TOOLS" workstream set --raw --cwd "$CWD"` +Also set `GSD_WORKSTREAM` env var for the current session. + +### progress +Run: `node "$GSD_TOOLS" workstream progress --raw --cwd "$CWD"` +Display a progress overview across all workstreams. + +### complete +Run: `node "$GSD_TOOLS" workstream complete --raw --cwd "$CWD"` +Archive the workstream to milestones/. + +### resume +Set the workstream as active and suggest `/gsd:resume-work --ws `. + +## Step 3: Display Results + +Format the JSON output from gsd-tools into a human-readable display. +Include the `${GSD_WS}` flag in any routing suggestions. diff --git a/get-shit-done/references/workstream-flag.md b/get-shit-done/references/workstream-flag.md new file mode 100644 index 000000000..71277a310 --- /dev/null +++ b/get-shit-done/references/workstream-flag.md @@ -0,0 +1,58 @@ +# Workstream Flag (`--ws`) + +## Overview + +The `--ws ` flag scopes GSD operations to a specific workstream, enabling +parallel milestone work by multiple Claude Code instances on the same codebase. + +## Resolution Priority + +1. `--ws ` flag (explicit, highest priority) +2. `GSD_WORKSTREAM` environment variable (per-instance) +3. `.planning/active-workstream` file (shared, last-writer-wins) +4. `null` — flat mode (no workstreams) + +## Routing Propagation + +All workflow routing commands include `${GSD_WS}` which: +- Expands to `--ws ` when a workstream is active +- Expands to empty string in flat mode (backward compatible) + +This ensures workstream scope chains automatically through the workflow: +`new-milestone → discuss-phase → plan-phase → execute-phase → transition` + +## Directory Structure + +``` +.planning/ +├── PROJECT.md # Shared +├── config.json # Shared +├── milestones/ # Shared +├── codebase/ # Shared +├── active-workstream # Points to current ws +└── workstreams/ + ├── feature-a/ # Workstream A + │ ├── STATE.md + │ ├── ROADMAP.md + │ ├── REQUIREMENTS.md + │ └── phases/ + └── feature-b/ # Workstream B + ├── STATE.md + ├── ROADMAP.md + ├── REQUIREMENTS.md + └── phases/ +``` + +## CLI Usage + +```bash +# All gsd-tools commands accept --ws +node gsd-tools.cjs state json --ws feature-a +node gsd-tools.cjs find-phase 3 --ws feature-b + +# Workstream CRUD +node gsd-tools.cjs workstream create +node gsd-tools.cjs workstream list +node gsd-tools.cjs workstream status +node gsd-tools.cjs workstream complete +``` diff --git a/get-shit-done/workflows/discuss-phase.md b/get-shit-done/workflows/discuss-phase.md index 0f37b78ba..8a1c4b541 100644 --- a/get-shit-done/workflows/discuss-phase.md +++ b/get-shit-done/workflows/discuss-phase.md @@ -142,7 +142,7 @@ Parse JSON for: `commit_docs`, `phase_found`, `phase_dir`, `phase_number`, `phas ``` Phase [X] not found in roadmap. -Use /gsd:progress to see available phases. +Use /gsd:progress ${GSD_WS} to see available phases. ``` Exit workflow. @@ -189,7 +189,7 @@ Check `has_plans` and `plan_count` from init. **If `has_plans` is true:** - header: "Plans exist" - question: "Phase [X] already has {plan_count} plan(s) created without user context. Your decisions here won't affect existing plans unless you replan." - options: - - "Continue and replan after" — Capture context, then run /gsd:plan-phase {X} to replan + - "Continue and replan after" — Capture context, then run /gsd:plan-phase {X} ${GSD_WS} to replan - "View existing plans" — Show plans before deciding - "Cancel" — Skip discuss-phase @@ -871,15 +871,15 @@ Created: .planning/phases/${PADDED_PHASE}-${SLUG}/${PADDED_PHASE}-CONTEXT.md **Phase ${PHASE}: [Name]** — [Goal from ROADMAP.md] -`/gsd:plan-phase ${PHASE}` +`/gsd:plan-phase ${PHASE} ${GSD_WS}` `/clear` first → fresh context window --- **Also available:** -- `/gsd:plan-phase ${PHASE} --skip-research` — plan without research -- `/gsd:ui-phase ${PHASE}` — generate UI design contract before planning (if phase has frontend work) +- `/gsd:plan-phase ${PHASE} --skip-research ${GSD_WS}` — plan without research +- `/gsd:ui-phase ${PHASE} ${GSD_WS}` — generate UI design contract before planning (if phase has frontend work) - Review/edit CONTEXT.md before continuing --- @@ -990,7 +990,7 @@ Context captured. Launching plan-phase... Launch plan-phase using the Skill tool to avoid nested Task sessions (which cause runtime freezes due to deep agent nesting — see #686): ``` -Skill(skill="gsd:plan-phase", args="${PHASE} --auto") +Skill(skill="gsd:plan-phase", args="${PHASE} --auto ${GSD_WS}") ``` This keeps the auto-advance chain flat — discuss, plan, and execute all run at the same nesting level rather than spawning increasingly deep Task agents. @@ -1004,23 +1004,23 @@ This keeps the auto-advance chain flat — discuss, plan, and execute all run at Auto-advance pipeline finished: discuss → plan → execute - Next: /gsd:discuss-phase ${NEXT_PHASE} --auto + Next: /gsd:discuss-phase ${NEXT_PHASE} --auto ${GSD_WS} /clear first → fresh context window ``` - **PLANNING COMPLETE** → Planning done, execution didn't complete: ``` Auto-advance partial: Planning complete, execution did not finish. - Continue: /gsd:execute-phase ${PHASE} + Continue: /gsd:execute-phase ${PHASE} ${GSD_WS} ``` - **PLANNING INCONCLUSIVE / CHECKPOINT** → Stop chain: ``` Auto-advance stopped: Planning needs input. - Continue: /gsd:plan-phase ${PHASE} + Continue: /gsd:plan-phase ${PHASE} ${GSD_WS} ``` - **GAPS FOUND** → Stop chain: ``` Auto-advance stopped: Gaps found during execution. - Continue: /gsd:plan-phase ${PHASE} --gaps + Continue: /gsd:plan-phase ${PHASE} --gaps ${GSD_WS} ``` **If neither `--auto` nor config enabled:** diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 742cd7418..9ab1f6275 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -456,8 +456,8 @@ Apply the same "incomplete" filtering rules as earlier: Selected wave finished successfully. This phase still has incomplete plans, so phase-level verification and completion were intentionally skipped. -/gsd:execute-phase {phase} # Continue remaining waves -/gsd:execute-phase {phase} --wave {next} # Run the next wave explicitly +/gsd:execute-phase {phase} ${GSD_WS} # Continue remaining waves +/gsd:execute-phase {phase} --wave {next} ${GSD_WS} # Run the next wave explicitly ``` **If no incomplete plans remain after the selected wave finishes:** @@ -602,7 +602,7 @@ grep "^status:" "$PHASE_DIR"/*-VERIFICATION.md | cut -d: -f2 | tr -d ' ' |--------|--------| | `passed` | → update_roadmap | | `human_needed` | Present items for human testing, get approval or feedback | -| `gaps_found` | Present gap summary, offer `/gsd:plan-phase {phase} --gaps` | +| `gaps_found` | Present gap summary, offer `/gsd:plan-phase {phase} --gaps ${GSD_WS}` | **If human_needed:** @@ -679,15 +679,15 @@ Items saved to `{phase_num}-HUMAN-UAT.md` — they will appear in `/gsd:progress --- ## ▶ Next Up -`/gsd:plan-phase {X} --gaps` +`/gsd:plan-phase {X} --gaps ${GSD_WS}` `/clear` first → fresh context window Also: `cat {phase_dir}/{phase_num}-VERIFICATION.md` — full report -Also: `/gsd:verify-work {X}` — manual testing first +Also: `/gsd:verify-work {X} ${GSD_WS}` — manual testing first ``` -Gap closure cycle: `/gsd:plan-phase {X} --gaps` reads VERIFICATION.md → creates gap plans with `gap_closure: true` → user runs `/gsd:execute-phase {X} --gaps-only` → verifier re-runs. +Gap closure cycle: `/gsd:plan-phase {X} --gaps ${GSD_WS}` reads VERIFICATION.md → creates gap plans with `gap_closure: true` → user runs `/gsd:execute-phase {X} --gaps-only ${GSD_WS}` → verifier re-runs. @@ -801,10 +801,10 @@ Read and follow `~/.claude/get-shit-done/workflows/transition.md`, passing throu ``` ## ✓ Phase {X}: {Name} Complete -/gsd:progress — see updated roadmap -/gsd:discuss-phase {next} — discuss next phase before planning -/gsd:plan-phase {next} — plan next phase -/gsd:execute-phase {next} — execute next phase +/gsd:progress ${GSD_WS} — see updated roadmap +/gsd:discuss-phase {next} ${GSD_WS} — discuss next phase before planning +/gsd:plan-phase {next} ${GSD_WS} — plan next phase +/gsd:execute-phase {next} ${GSD_WS} — execute next phase ``` Only suggest the commands listed above. Do not invent or hallucinate command names. diff --git a/get-shit-done/workflows/new-milestone.md b/get-shit-done/workflows/new-milestone.md index c95c7396b..d239c872c 100644 --- a/get-shit-done/workflows/new-milestone.md +++ b/get-shit-done/workflows/new-milestone.md @@ -161,7 +161,7 @@ Then verify `.planning/phases/` no longer contains old milestone directories bef If `phase_dir_count > 0` but `phase_archive_path` is missing: - Stop and explain that reset numbering is unsafe without a completed milestone archive target. -- Tell the user to complete/archive the previous milestone first, then rerun `/gsd:new-milestone --reset-phase-numbers`. +- Tell the user to complete/archive the previous milestone first, then rerun `/gsd:new-milestone --reset-phase-numbers ${GSD_WS}`. ## 8. Research Decision @@ -442,11 +442,11 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: create milest **Phase [N]: [Phase Name]** — [Goal] -`/gsd:discuss-phase [N]` — gather context and clarify approach +`/gsd:discuss-phase [N] ${GSD_WS}` — gather context and clarify approach `/clear` first → fresh context window -Also: `/gsd:plan-phase [N]` — skip discussion, plan directly +Also: `/gsd:plan-phase [N] ${GSD_WS}` — skip discussion, plan directly ``` @@ -463,7 +463,7 @@ Also: `/gsd:plan-phase [N]` — skip discussion, plan directly - [ ] User feedback incorporated (if any) - [ ] Phase numbering mode respected (continued or reset) - [ ] All commits made (if planning docs committed) -- [ ] User knows next step: `/gsd:discuss-phase [N]` +- [ ] User knows next step: `/gsd:discuss-phase [N] ${GSD_WS}` **Atomic commits:** Each phase commits its artifacts immediately. diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index 9569a2a27..27ec1e5b8 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -208,9 +208,9 @@ If "Run discuss-phase first": does not work correctly in nested subcontexts (#1009). Instead, display the command and exit so the user runs it as a top-level command: ``` - Run this command first, then re-run /gsd:plan-phase {X}: + Run this command first, then re-run /gsd:plan-phase {X} ${GSD_WS}: - /gsd:discuss-phase {X} + /gsd:discuss-phase {X} ${GSD_WS} ``` **Exit the plan-phase workflow. Do not continue.** @@ -363,7 +363,7 @@ Use AskUserQuestion: - header: "UI Design Contract" - question: "Phase {N} has frontend indicators but no UI-SPEC.md. Generate a design contract before planning?" - options: - - "Generate UI-SPEC first" → Display: "Run `/gsd:ui-phase {N}` then re-run `/gsd:plan-phase {N}`". Exit workflow. + - "Generate UI-SPEC first" → Display: "Run `/gsd:ui-phase {N} ${GSD_WS}` then re-run `/gsd:plan-phase {N} ${GSD_WS}`". Exit workflow. - "Continue without UI-SPEC" → Continue to step 6. - "Not a frontend phase" → Continue to step 6. @@ -411,7 +411,7 @@ VALIDATION_EXISTS=$(ls "${PHASE_DIR}"/*-VALIDATION.md 2>/dev/null | head -1) ``` If missing and Nyquist is still enabled/applicable — ask user: -1. Re-run: `/gsd:plan-phase {PHASE} --research` +1. Re-run: `/gsd:plan-phase {PHASE} --research ${GSD_WS}` 2. Disable Nyquist with the exact command: `node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-set workflow.nyquist_validation false` 3. Continue anyway (plans fail Dimension 8) @@ -703,7 +703,7 @@ Plans ready. Launching execute-phase... Launch execute-phase using the Skill tool to avoid nested Task sessions (which cause runtime freezes due to deep agent nesting): ``` -Skill(skill="gsd:execute-phase", args="${PHASE} --auto --no-transition") +Skill(skill="gsd:execute-phase", args="${PHASE} --auto --no-transition ${GSD_WS}") ``` The `--no-transition` flag tells execute-phase to return status after verification instead of chaining further. This keeps the auto-advance chain flat — each phase runs at the same nesting level rather than spawning deeper Task agents. @@ -717,14 +717,14 @@ The `--no-transition` flag tells execute-phase to return status after verificati Auto-advance pipeline finished. - Next: /gsd:discuss-phase ${NEXT_PHASE} --auto + Next: /gsd:discuss-phase ${NEXT_PHASE} --auto ${GSD_WS} ``` - **GAPS FOUND / VERIFICATION FAILED** → Display result, stop chain: ``` Auto-advance stopped: Execution needs review. Review the output above and continue manually: - /gsd:execute-phase ${PHASE} + /gsd:execute-phase ${PHASE} ${GSD_WS} ``` **If neither `--auto` nor config enabled:** @@ -755,7 +755,7 @@ Verification: {Passed | Passed with override | Skipped} **Execute Phase {X}** — run all {N} plans -/gsd:execute-phase {X} +/gsd:execute-phase {X} ${GSD_WS} /clear first → fresh context window diff --git a/get-shit-done/workflows/progress.md b/get-shit-done/workflows/progress.md index 58b7a8ca9..a3f753a29 100644 --- a/get-shit-done/workflows/progress.md +++ b/get-shit-done/workflows/progress.md @@ -185,8 +185,8 @@ Track: `outstanding_debt` — `summary.total_items` from the audit. | {phase} | {filename} | {pending_count} pending, {skipped_count} skipped, {blocked_count} blocked | | {phase} | {filename} | human_needed — {count} items | -Review: `/gsd:audit-uat` — full cross-phase audit -Resume testing: `/gsd:verify-work {phase}` — retest specific phase +Review: `/gsd:audit-uat ${GSD_WS}` — full cross-phase audit +Resume testing: `/gsd:verify-work {phase} ${GSD_WS}` — retest specific phase ``` This is a WARNING, not a blocker — routing proceeds normally. The debt is visible so the user can make an informed choice. @@ -215,7 +215,7 @@ Read its `` section. **{phase}-{plan}: [Plan Name]** — [objective summary from PLAN.md] -`/gsd:execute-phase {phase}` +`/gsd:execute-phase {phase} ${GSD_WS}` `/clear` first → fresh context window @@ -238,7 +238,7 @@ Check if `{phase_num}-CONTEXT.md` exists in phase directory. **Phase {N}: {Name}** — {Goal from ROADMAP.md} ✓ Context gathered, ready to plan -`/gsd:plan-phase {phase-number}` +`/gsd:plan-phase {phase-number} ${GSD_WS}` `/clear` first → fresh context window @@ -254,15 +254,15 @@ Check if `{phase_num}-CONTEXT.md` exists in phase directory. **Phase {N}: {Name}** — {Goal from ROADMAP.md} -`/gsd:discuss-phase {phase}` — gather context and clarify approach +`/gsd:discuss-phase {phase} ${GSD_WS}` — gather context and clarify approach `/clear` first → fresh context window --- **Also available:** -- `/gsd:plan-phase {phase}` — skip discussion, plan directly -- `/gsd:list-phase-assumptions {phase}` — see Claude's assumptions +- `/gsd:plan-phase {phase} ${GSD_WS}` — skip discussion, plan directly +- `/gsd:list-phase-assumptions {phase} ${GSD_WS}` — see Claude's assumptions --- ``` @@ -280,15 +280,15 @@ UAT.md exists with gaps (diagnosed issues). User needs to plan fixes. **{phase_num}-UAT.md** has {N} gaps requiring fixes. -`/gsd:plan-phase {phase} --gaps` +`/gsd:plan-phase {phase} --gaps ${GSD_WS}` `/clear` first → fresh context window --- **Also available:** -- `/gsd:execute-phase {phase}` — execute phase plans -- `/gsd:verify-work {phase}` — run more UAT testing +- `/gsd:execute-phase {phase} ${GSD_WS}` — execute phase plans +- `/gsd:verify-work {phase} ${GSD_WS}` — run more UAT testing --- ``` @@ -306,15 +306,15 @@ UAT.md exists with `status: partial` — testing session ended before all items **{phase_num}-UAT.md** has {N} unresolved tests (pending, blocked, or skipped). -`/gsd:verify-work {phase}` — resume testing from where you left off +`/gsd:verify-work {phase} ${GSD_WS}` — resume testing from where you left off `/clear` first → fresh context window --- **Also available:** -- `/gsd:audit-uat` — full cross-phase UAT audit -- `/gsd:execute-phase {phase}` — execute phase plans +- `/gsd:audit-uat ${GSD_WS}` — full cross-phase UAT audit +- `/gsd:execute-phase {phase} ${GSD_WS}` — execute phase plans --- ``` @@ -353,15 +353,15 @@ Read ROADMAP.md to get the next phase's name and goal. **Phase {Z+1}: {Name}** — {Goal from ROADMAP.md} -`/gsd:discuss-phase {Z+1}` — gather context and clarify approach +`/gsd:discuss-phase {Z+1} ${GSD_WS}` — gather context and clarify approach `/clear` first → fresh context window --- **Also available:** -- `/gsd:plan-phase {Z+1}` — skip discussion, plan directly -- `/gsd:verify-work {Z}` — user acceptance test before continuing +- `/gsd:plan-phase {Z+1} ${GSD_WS}` — skip discussion, plan directly +- `/gsd:verify-work {Z} ${GSD_WS}` — user acceptance test before continuing --- ``` @@ -381,14 +381,14 @@ All {N} phases finished! **Complete Milestone** — archive and prepare for next -`/gsd:complete-milestone` +`/gsd:complete-milestone ${GSD_WS}` `/clear` first → fresh context window --- **Also available:** -- `/gsd:verify-work` — user acceptance test before completing milestone +- `/gsd:verify-work ${GSD_WS}` — user acceptance test before completing milestone --- ``` @@ -412,7 +412,7 @@ Ready to plan the next milestone. **Start Next Milestone** — questioning → research → requirements → roadmap -`/gsd:new-milestone` +`/gsd:new-milestone ${GSD_WS}` `/clear` first → fresh context window @@ -424,10 +424,10 @@ Ready to plan the next milestone. **Handle edge cases:** -- Phase complete but next phase not planned → offer `/gsd:plan-phase [next]` +- Phase complete but next phase not planned → offer `/gsd:plan-phase [next] ${GSD_WS}` - All work complete → offer milestone completion - Blockers present → highlight before offering to continue -- Handoff file exists → mention it, offer `/gsd:resume-work` +- Handoff file exists → mention it, offer `/gsd:resume-work ${GSD_WS}` diff --git a/get-shit-done/workflows/quick.md b/get-shit-done/workflows/quick.md index 7138dd031..2b3547966 100644 --- a/get-shit-done/workflows/quick.md +++ b/get-shit-done/workflows/quick.md @@ -691,7 +691,7 @@ Commit: ${commit_hash} --- -Ready for next task: /gsd:quick +Ready for next task: /gsd:quick ${GSD_WS} ``` **If NOT `$FULL_MODE`:** @@ -708,7 +708,7 @@ Commit: ${commit_hash} --- -Ready for next task: /gsd:quick +Ready for next task: /gsd:quick ${GSD_WS} ``` diff --git a/get-shit-done/workflows/resume-project.md b/get-shit-done/workflows/resume-project.md index a8dafcf2c..06dff4909 100644 --- a/get-shit-done/workflows/resume-project.md +++ b/get-shit-done/workflows/resume-project.md @@ -199,11 +199,11 @@ What would you like to do? [Primary action based on state - e.g.:] 1. Resume interrupted agent [if interrupted agent found] OR -1. Execute phase (/gsd:execute-phase {phase}) +1. Execute phase (/gsd:execute-phase {phase} ${GSD_WS}) OR -1. Discuss Phase 3 context (/gsd:discuss-phase 3) [if CONTEXT.md missing] +1. Discuss Phase 3 context (/gsd:discuss-phase 3 ${GSD_WS}) [if CONTEXT.md missing] OR -1. Plan Phase 3 (/gsd:plan-phase 3) [if CONTEXT.md exists or discuss option declined] +1. Plan Phase 3 (/gsd:plan-phase 3 ${GSD_WS}) [if CONTEXT.md exists or discuss option declined] [Secondary options:] 2. Review current phase status @@ -234,7 +234,7 @@ Based on user selection, route to appropriate workflow: **{phase}-{plan}: [Plan Name]** — [objective from PLAN.md] - `/gsd:execute-phase {phase}` + `/gsd:execute-phase {phase} ${GSD_WS}` `/clear` first → fresh context window @@ -248,15 +248,15 @@ Based on user selection, route to appropriate workflow: **Phase [N]: [Name]** — [Goal from ROADMAP.md] - `/gsd:plan-phase [phase-number]` + `/gsd:plan-phase [phase-number] ${GSD_WS}` `/clear` first → fresh context window --- **Also available:** - - `/gsd:discuss-phase [N]` — gather context first - - `/gsd:research-phase [N]` — investigate unknowns + - `/gsd:discuss-phase [N] ${GSD_WS}` — gather context first + - `/gsd:research-phase [N] ${GSD_WS}` — investigate unknowns --- ``` diff --git a/get-shit-done/workflows/transition.md b/get-shit-done/workflows/transition.md index dec8af17a..226b9d8ee 100644 --- a/get-shit-done/workflows/transition.md +++ b/get-shit-done/workflows/transition.md @@ -381,7 +381,7 @@ Resume file: None The `is_last_phase` field from the phase complete result tells you directly: - `is_last_phase: false` → More phases remain → Go to **Route A** -- `is_last_phase: true` → Milestone complete → Go to **Route B** +- `is_last_phase: true` → Last phase done → **Check for workstream collisions first** The `next_phase` and `next_phase_name` fields give you the next phase details. @@ -394,6 +394,34 @@ This returns all phases with goals, disk status, and completion info. --- +**Workstream collision check (when `is_last_phase: true`):** + +Before routing to Route B, check whether other workstreams are still active. +This prevents one workstream from advancing or completing the milestone while +other workstreams are still working on their phases. + +**Skip this check if NOT in workstream mode** (i.e., `GSD_WORKSTREAM` is not set / flat mode). +In flat mode, go directly to **Route B**. + +```bash +# Only check if we're in workstream mode +if [ -n "$GSD_WORKSTREAM" ]; then + WS_LIST=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" workstream list --raw) +fi +``` + +Parse the JSON result. The output has `{ mode, workstreams: [...] }`. +Each workstream entry has: `name`, `status`, `current_phase`, `phase_count`, `completed_phases`. + +Filter out the current workstream (`$GSD_WORKSTREAM`) and any workstreams with +status containing "milestone complete" or "archived" (case-insensitive). +The remaining entries are **other active workstreams**. + +- **If other active workstreams exist** → Go to **Route B1** +- **If NO other active workstreams** (or flat mode) → Go to **Route B** + +--- + **Route A: More phases remain in milestone** Read ROADMAP.md to get the next phase's name and goal. @@ -418,7 +446,7 @@ Next: Phase [X+1] — [Name] ⚡ Auto-continuing: Plan Phase [X+1] in detail ``` -Exit skill and invoke SlashCommand("/gsd:plan-phase [X+1] --auto") +Exit skill and invoke SlashCommand("/gsd:plan-phase [X+1] --auto ${GSD_WS}") **If CONTEXT.md does NOT exist:** @@ -430,7 +458,7 @@ Next: Phase [X+1] — [Name] ⚡ Auto-continuing: Discuss Phase [X+1] first ``` -Exit skill and invoke SlashCommand("/gsd:discuss-phase [X+1] --auto") +Exit skill and invoke SlashCommand("/gsd:discuss-phase [X+1] --auto ${GSD_WS}") @@ -447,15 +475,15 @@ Exit skill and invoke SlashCommand("/gsd:discuss-phase [X+1] --auto") **Phase [X+1]: [Name]** — [Goal from ROADMAP.md] -`/gsd:discuss-phase [X+1]` — gather context and clarify approach +`/gsd:discuss-phase [X+1] ${GSD_WS}` — gather context and clarify approach `/clear` first → fresh context window --- **Also available:** -- `/gsd:plan-phase [X+1]` — skip discussion, plan directly -- `/gsd:research-phase [X+1]` — investigate unknowns +- `/gsd:plan-phase [X+1] ${GSD_WS}` — skip discussion, plan directly +- `/gsd:research-phase [X+1] ${GSD_WS}` — investigate unknowns --- ``` @@ -472,15 +500,15 @@ Exit skill and invoke SlashCommand("/gsd:discuss-phase [X+1] --auto") **Phase [X+1]: [Name]** — [Goal from ROADMAP.md] ✓ Context gathered, ready to plan -`/gsd:plan-phase [X+1]` +`/gsd:plan-phase [X+1] ${GSD_WS}` `/clear` first → fresh context window --- **Also available:** -- `/gsd:discuss-phase [X+1]` — revisit context -- `/gsd:research-phase [X+1]` — investigate unknowns +- `/gsd:discuss-phase [X+1] ${GSD_WS}` — revisit context +- `/gsd:research-phase [X+1] ${GSD_WS}` — investigate unknowns --- ``` @@ -489,9 +517,68 @@ Exit skill and invoke SlashCommand("/gsd:discuss-phase [X+1] --auto") --- +**Route B1: Workstream done, other workstreams still active** + +This route is reached when `is_last_phase: true` AND the collision check found +other active workstreams. Do NOT suggest completing the milestone or advancing +to the next milestone — other workstreams are still working. + +**Clear auto-advance chain flag** — workstream boundary is the natural stopping point: + +```bash +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-set workflow._auto_chain_active false +``` + + + +Override auto-advance: do NOT auto-continue to milestone completion. +Present the blocking information and stop. + + + +Present (all modes): + +``` +## ✓ Phase {X}: {Phase Name} Complete + +This workstream's phases are complete. Other workstreams are still active: + +| Workstream | Status | Phase | Progress | +|------------|--------|-------|----------| +| {name} | {status} | {current_phase} | {completed_phases}/{phase_count} | +| ... | ... | ... | ... | + +--- + +## Next Steps + +Archive this workstream: + +`/gsd:workstreams complete {current_ws_name} ${GSD_WS}` + +See overall milestone progress: + +`/gsd:workstreams progress ${GSD_WS}` + +Milestone completion will be available once all workstreams finish. + +--- +``` + +Do NOT suggest `/gsd:complete-milestone` or `/gsd:new-milestone`. +Do NOT auto-invoke any further slash commands. + +**Stop here.** The user must explicitly decide what to do next. + +--- + **Route B: Milestone complete (all phases done)** +**This route is only reached when:** +- `is_last_phase: true` AND no other active workstreams exist (or flat mode) + **Clear auto-advance chain flag** — milestone boundary is the natural stopping point: + ```bash node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-set workflow._auto_chain_active false ``` @@ -506,7 +593,7 @@ Phase {X} marked complete. ⚡ Auto-continuing: Complete milestone and archive ``` -Exit skill and invoke SlashCommand("/gsd:complete-milestone {version}") +Exit skill and invoke SlashCommand("/gsd:complete-milestone {version} ${GSD_WS}") @@ -523,7 +610,7 @@ Exit skill and invoke SlashCommand("/gsd:complete-milestone {version}") **Complete Milestone {version}** — archive and prepare for next -`/gsd:complete-milestone {version}` +`/gsd:complete-milestone {version} ${GSD_WS}` `/clear` first → fresh context window diff --git a/get-shit-done/workflows/validate-phase.md b/get-shit-done/workflows/validate-phase.md index 6698e266f..3d3b54339 100644 --- a/get-shit-done/workflows/validate-phase.md +++ b/get-shit-done/workflows/validate-phase.md @@ -35,7 +35,7 @@ SUMMARY_FILES=$(ls "${PHASE_DIR}"/*-SUMMARY.md 2>/dev/null) - **State A** (`VALIDATION_FILE` non-empty): Audit existing - **State B** (`VALIDATION_FILE` empty, `SUMMARY_FILES` non-empty): Reconstruct from artifacts -- **State C** (`SUMMARY_FILES` empty): Exit — "Phase {N} not executed. Run /gsd:execute-phase {N} first." +- **State C** (`SUMMARY_FILES` empty): Exit — "Phase {N} not executed. Run /gsd:execute-phase {N} ${GSD_WS} first." ## 2. Discovery @@ -137,14 +137,14 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs(phase-${PHASE} ``` GSD > PHASE {N} IS NYQUIST-COMPLIANT All requirements have automated verification. -▶ Next: /gsd:audit-milestone +▶ Next: /gsd:audit-milestone ${GSD_WS} ``` **Partial:** ``` GSD > PHASE {N} VALIDATED (PARTIAL) {M} automated, {K} manual-only. -▶ Retry: /gsd:validate-phase {N} +▶ Retry: /gsd:validate-phase {N} ${GSD_WS} ``` Display `/clear` reminder. diff --git a/tests/workstream.test.cjs b/tests/workstream.test.cjs new file mode 100644 index 000000000..084429aed --- /dev/null +++ b/tests/workstream.test.cjs @@ -0,0 +1,359 @@ +/** + * Workstream Tests — CRUD, env-var routing, collision detection + */ + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +// ─── Helper ────────────────────────────────────────────────────────────────── + +function createProjectWithState(tmpDir, roadmap, state) { + if (roadmap) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap, 'utf-8'); + } + if (state) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), state, 'utf-8'); + } +} + +// ─── planningDir / planningPaths env-var awareness ────────────────────────── + +describe('planningDir workstream awareness via env var', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + // Create workstream structure + const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha'); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 1\n'); + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap v1.0: Alpha\n### Phase 1: Setup\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n'); + }); + + after(() => cleanup(tmpDir)); + + test('state json returns workstream-scoped state when GSD_WORKSTREAM is set', () => { + const result = runGsdTools(['state', 'json', '--raw'], tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `state json failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.ok(data.status || data.current_phase !== undefined, 'should return state data'); + }); + + test('state json reads from flat .planning when no workstream set', () => { + // Clear active-workstream so no auto-detection + try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {} + const result = runGsdTools(['state', 'json', '--raw'], tmpDir, { GSD_WORKSTREAM: '' }); + // Should fail or return empty state since flat .planning/ has no STATE.md + assert.ok(!result.success || result.output.includes('not found') || result.output === '{}', + 'should read from flat .planning/'); + // Restore + fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n'); + }); + + test('--ws flag overrides GSD_WORKSTREAM env var', () => { + // Create a second workstream + const betaDir = path.join(tmpDir, '.planning', 'workstreams', 'beta'); + fs.mkdirSync(path.join(betaDir, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(betaDir, 'STATE.md'), '# State\n**Status:** Beta active\n'); + + const result = runGsdTools(['state', 'json', '--raw', '--ws', 'beta'], tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `state json --ws beta failed: ${result.error}`); + }); +}); + +// ─── Workstream CRUD ──────────────────────────────────────────────────────── + +describe('workstream create', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); + }); + + after(() => cleanup(tmpDir)); + + test('creates a new workstream in clean project', () => { + const result = runGsdTools(['workstream', 'create', 'feature-x', '--raw'], tmpDir); + assert.ok(result.success, `create failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.created, true); + assert.strictEqual(data.workstream, 'feature-x'); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-x', 'STATE.md'))); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-x', 'phases'))); + }); + + test('sets created workstream as active', () => { + const active = fs.readFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'utf-8').trim(); + assert.strictEqual(active, 'feature-x'); + }); + + test('rejects duplicate workstream', () => { + const result = runGsdTools(['workstream', 'create', 'feature-x', '--raw'], tmpDir); + assert.ok(result.success); // returns success with error field + const data = JSON.parse(result.output); + assert.strictEqual(data.created, false); + assert.strictEqual(data.error, 'already_exists'); + }); + + test('creates second workstream', () => { + const result = runGsdTools(['workstream', 'create', 'feature-y', '--raw'], tmpDir); + assert.ok(result.success); + const data = JSON.parse(result.output); + assert.strictEqual(data.created, true); + assert.strictEqual(data.workstream, 'feature-y'); + }); +}); + +describe('workstream create with migration', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); + // Existing flat-mode work + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '## Roadmap v1.0: Existing\n### Phase 1: A\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n**Status:** In progress\n'); + }); + + after(() => cleanup(tmpDir)); + + test('migrates existing flat work to named workstream', () => { + const result = runGsdTools(['workstream', 'create', 'new-feature', '--migrate-name', 'existing-work', '--raw'], tmpDir); + assert.ok(result.success, `create with migration failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.created, true); + assert.ok(data.migration, 'should include migration info'); + assert.strictEqual(data.migration.workstream, 'existing-work'); + // Old flat files moved to workstream dir + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'existing-work', 'ROADMAP.md'))); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'existing-work', 'STATE.md'))); + // Shared files stay + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'PROJECT.md'))); + }); +}); + +describe('workstream list', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + // Create two workstreams + for (const ws of ['alpha', 'beta']) { + const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** Working on ${ws}\n**Current Phase:** 1\n`); + } + }); + + after(() => cleanup(tmpDir)); + + test('lists all workstreams', () => { + const result = runGsdTools(['workstream', 'list', '--raw'], tmpDir); + assert.ok(result.success, `list failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.mode, 'workstream'); + assert.strictEqual(data.count, 2); + const names = data.workstreams.map(w => w.name).sort(); + assert.deepStrictEqual(names, ['alpha', 'beta']); + }); + + test('reports flat mode when no workstreams exist', () => { + const flatDir = createTempProject(); + try { + const result = runGsdTools(['workstream', 'list', '--raw'], flatDir); + assert.ok(result.success); + const data = JSON.parse(result.output); + assert.strictEqual(data.mode, 'flat'); + } finally { + cleanup(flatDir); + } + }); +}); + +describe('workstream status', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha'); + fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', 'PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 1 — Setup\n'); + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap\n'); + }); + + after(() => cleanup(tmpDir)); + + test('returns detailed status for workstream', () => { + const result = runGsdTools(['workstream', 'status', 'alpha', '--raw'], tmpDir); + assert.ok(result.success, `status failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.found, true); + assert.strictEqual(data.workstream, 'alpha'); + assert.strictEqual(data.files.roadmap, true); + assert.strictEqual(data.files.state, true); + assert.strictEqual(data.phase_count, 1); + }); + + test('returns not found for missing workstream', () => { + const result = runGsdTools(['workstream', 'status', 'nonexistent', '--raw'], tmpDir); + assert.ok(result.success); + const data = JSON.parse(result.output); + assert.strictEqual(data.found, false); + }); +}); + +describe('workstream complete', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'done-ws'); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** Complete\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'done-ws\n'); + }); + + after(() => cleanup(tmpDir)); + + test('archives workstream to milestones/', () => { + const result = runGsdTools(['workstream', 'complete', 'done-ws', '--raw'], tmpDir); + assert.ok(result.success, `complete failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.completed, true); + assert.ok(data.archived_to.startsWith('.planning/milestones/ws-done-ws')); + // Workstream dir should be gone + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'done-ws'))); + }); + + test('clears active-workstream when completing active one', () => { + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream'))); + }); +}); + +describe('workstream set/get', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + for (const ws of ['ws-a', 'ws-b']) { + const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n'); + } + }); + + after(() => cleanup(tmpDir)); + + test('sets active workstream', () => { + const result = runGsdTools(['workstream', 'set', 'ws-a', '--raw'], tmpDir); + assert.ok(result.success); + assert.strictEqual(result.output, 'ws-a'); + }); + + test('gets active workstream', () => { + const result = runGsdTools(['workstream', 'get', '--raw'], tmpDir); + assert.ok(result.success); + assert.strictEqual(result.output, 'ws-a'); + }); +}); + +// ─── Collision Detection ──────────────────────────────────────────────────── + +describe('getOtherActiveWorkstreams', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + // Create 3 workstreams: alpha (active), beta (active), gamma (completed) + for (const ws of ['alpha', 'beta', 'gamma']) { + const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + } + fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'STATE.md'), + '# State\n**Status:** In progress\n**Current Phase:** 3\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'beta', 'STATE.md'), + '# State\n**Status:** In progress\n**Current Phase:** 5\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'gamma', 'STATE.md'), + '# State\n**Status:** Milestone complete\n'); + }); + + after(() => cleanup(tmpDir)); + + test('workstream list excludes completed workstreams from active count', () => { + const result = runGsdTools(['workstream', 'list', '--raw'], tmpDir); + assert.ok(result.success); + const data = JSON.parse(result.output); + assert.strictEqual(data.count, 3); // all listed + const activeWs = data.workstreams.filter(w => + !w.status.toLowerCase().includes('milestone complete')); + assert.strictEqual(activeWs.length, 2); // alpha and beta active + }); +}); + +describe('workstream progress', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'feature'); + fs.mkdirSync(path.join(wsDir, 'phases', '01-init'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 2\n'); + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap\n### Phase 1: Init\n### Phase 2: Build\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'feature\n'); + }); + + after(() => cleanup(tmpDir)); + + test('returns progress summary', () => { + const result = runGsdTools(['workstream', 'progress', '--raw'], tmpDir); + assert.ok(result.success, `progress failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.mode, 'workstream'); + assert.strictEqual(data.count, 1); + assert.strictEqual(data.workstreams[0].name, 'feature'); + assert.strictEqual(data.workstreams[0].active, true); + assert.strictEqual(data.workstreams[0].progress_percent, 50); + }); +}); + +// ─── Integration: gsd-tools --ws flag ──────────────────────────────────────── + +describe('gsd-tools --ws flag integration', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + // Create a workstream with roadmap + const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'test-ws'); + fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), + '## Roadmap v1.0: Test\n### Phase 1: Setup\nDo setup things.\n'); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), + '---\nmilestone: v1.0\n---\n# State\n**Status:** In progress\n**Current Phase:** 1 — Setup\n'); + fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', 'PLAN.md'), '# Plan\n'); + }); + + after(() => cleanup(tmpDir)); + + test('find-phase resolves to workstream-scoped phases via --ws', () => { + const result = runGsdTools(['find-phase', '1', '--raw', '--ws', 'test-ws'], tmpDir); + assert.ok(result.success, `find-phase failed: ${result.error}`); + assert.ok(result.output.includes('workstreams/test-ws'), `path should be workstream-scoped: ${result.output}`); + }); + + test('find-phase returns JSON with workstream path when not raw', () => { + const result = runGsdTools(['find-phase', '1', '--ws', 'test-ws'], tmpDir); + assert.ok(result.success, `find-phase failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.ok(data.found, 'phase should be found'); + assert.ok(data.directory.includes('workstreams/test-ws'), `path should be workstream-scoped: ${data.directory}`); + }); +}); From e3a427252de4a266358c29102f415b1874b428d5 Mon Sep 17 00:00:00 2001 From: SalesTeamToolbox Date: Fri, 20 Mar 2026 21:58:48 -0700 Subject: [PATCH 2/6] fix: prevent path traversal via workstream name sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates workstream name at all entry points — CLI --ws flag, GSD_WORKSTREAM env var, active-workstream file, and cmdWorkstreamSet — blocking names that don't match [a-zA-Z0-9_-]+. Also fixes getActiveWorkstream to use planningRoot() consistently and validates names read from the active-workstream file before using them in path joins. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/gsd-tools.cjs | 4 ++++ get-shit-done/bin/lib/core.cjs | 6 +++--- get-shit-done/bin/lib/workstream.cjs | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index bf93e83a4..8f72776d6 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -205,6 +205,10 @@ async function main() { } 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; diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 4c2f2cebe..65ce7be88 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -568,11 +568,11 @@ function planningPaths(cwd, ws) { * Returns null if no active workstream or file doesn't exist. */ function getActiveWorkstream(cwd) { - const filePath = path.join(cwd, '.planning', 'active-workstream'); + const filePath = path.join(planningRoot(cwd), 'active-workstream'); try { const name = fs.readFileSync(filePath, 'utf-8').trim(); - if (!name) return null; - const wsDir = path.join(cwd, '.planning', 'workstreams', name); + if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) return null; + const wsDir = path.join(planningRoot(cwd), 'workstreams', name); if (!fs.existsSync(wsDir)) return null; return name; } catch { diff --git a/get-shit-done/bin/lib/workstream.cjs b/get-shit-done/bin/lib/workstream.cjs index 7a21c56bb..2aeaf63ec 100644 --- a/get-shit-done/bin/lib/workstream.cjs +++ b/get-shit-done/bin/lib/workstream.cjs @@ -352,6 +352,11 @@ 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); + return; + } + const wsDir = path.join(planningRoot(cwd), 'workstreams', name); if (!fs.existsSync(wsDir)) { output({ active: null, error: 'not_found', workstream: name }, raw); From 5f95fea4d7a8cf7dc729e58c20f3b9f079c933f8 Mon Sep 17 00:00:00 2001 From: SalesTeamToolbox Date: Fri, 20 Mar 2026 23:00:40 -0700 Subject: [PATCH 3/6] refactor: extract shared utilities to reduce duplication across codebase - core.cjs: add filterPlanFiles, filterSummaryFiles, getPhaseFileStats, readSubdirectories helpers; use them in searchPhaseInDir and getArchivedPhaseDirs - gsd-tools.cjs: add parseNamedArgs() helper; replace ~50 repetitive indexOf/ternary patterns in state record-metric, add-decision, add-blocker, record-session, begin-phase, signal-waiting, template fill, and frontmatter subcommands - phase.cjs: decompose 250-line cmdPhaseRemove into renameDecimalPhases(), renameIntegerPhases(), and updateRoadmapAfterPhaseRemoval(); import readSubdirectories - workstream.cjs: import stateExtractField from state.cjs and shared helpers from core.cjs; replace all inline regex state parsing and readdirSync+filter+map patterns All 1062 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/gsd-tools.cjs | 139 +++++------ get-shit-done/bin/lib/core.cjs | 65 ++++- get-shit-done/bin/lib/phase.cjs | 339 ++++++++++----------------- get-shit-done/bin/lib/workstream.cjs | 109 ++++----- 4 files changed, 281 insertions(+), 371 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 8f72776d6..aa6abfa90 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -151,6 +151,30 @@ const profilePipeline = require('./lib/profile-pipeline.cjs'); const profileOutput = require('./lib/profile-output.cjs'); const workstream = require('./lib/workstream.cjs'); +// ─── Arg parsing helpers ────────────────────────────────────────────────────── + +/** + * Extract named --flag pairs from an args array. + * Returns an object mapping flag names to their values (null if absent). + * Flags listed in `booleanFlags` are treated as boolean (no value consumed). + * + * parseNamedArgs(args, 'phase', 'plan') → { phase: '3', plan: '1' } + * parseNamedArgs(args, [], ['amend', 'force']) → { amend: true, force: false } + */ +function parseNamedArgs(args, valueFlags = [], booleanFlags = []) { + const result = {}; + for (const flag of valueFlags) { + const idx = args.indexOf(`--${flag}`); + result[flag] = idx !== -1 && args[idx + 1] !== undefined && !args[idx + 1].startsWith('--') + ? args[idx + 1] + : null; + } + for (const flag of booleanFlags) { + result[flag] = args.includes(`--${flag}`); + } + return result; +} + // ─── CLI Router ─────────────────────────────────────────────────────────────── async function main() { @@ -329,74 +353,27 @@ async function runCommand(command, args, cwd, raw) { } else if (subcommand === 'advance-plan') { state.cmdStateAdvancePlan(cwd, raw); } else if (subcommand === 'record-metric') { - const phaseIdx = args.indexOf('--phase'); - const planIdx = args.indexOf('--plan'); - const durationIdx = args.indexOf('--duration'); - const tasksIdx = args.indexOf('--tasks'); - const filesIdx = args.indexOf('--files'); - state.cmdStateRecordMetric(cwd, { - phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, - plan: planIdx !== -1 ? args[planIdx + 1] : null, - duration: durationIdx !== -1 ? args[durationIdx + 1] : null, - tasks: tasksIdx !== -1 ? args[tasksIdx + 1] : null, - files: filesIdx !== -1 ? args[filesIdx + 1] : null, - }, raw); + const { phase: p, plan, duration, tasks, files } = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']); + state.cmdStateRecordMetric(cwd, { phase: p, plan, duration, tasks, files }, raw); } else if (subcommand === 'update-progress') { state.cmdStateUpdateProgress(cwd, raw); } else if (subcommand === 'add-decision') { - const phaseIdx = args.indexOf('--phase'); - const summaryIdx = args.indexOf('--summary'); - const summaryFileIdx = args.indexOf('--summary-file'); - const rationaleIdx = args.indexOf('--rationale'); - const rationaleFileIdx = args.indexOf('--rationale-file'); - state.cmdStateAddDecision(cwd, { - phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, - summary: summaryIdx !== -1 ? args[summaryIdx + 1] : null, - summary_file: summaryFileIdx !== -1 ? args[summaryFileIdx + 1] : null, - rationale: rationaleIdx !== -1 ? args[rationaleIdx + 1] : '', - rationale_file: rationaleFileIdx !== -1 ? args[rationaleFileIdx + 1] : null, - }, raw); + const { phase: p, summary, 'summary-file': summary_file, rationale, 'rationale-file': rationale_file } = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']); + state.cmdStateAddDecision(cwd, { phase: p, summary, summary_file, rationale: rationale || '', rationale_file }, raw); } else if (subcommand === 'add-blocker') { - const textIdx = args.indexOf('--text'); - const textFileIdx = args.indexOf('--text-file'); - state.cmdStateAddBlocker(cwd, { - text: textIdx !== -1 ? args[textIdx + 1] : null, - text_file: textFileIdx !== -1 ? args[textFileIdx + 1] : null, - }, raw); + const { text, 'text-file': text_file } = parseNamedArgs(args, ['text', 'text-file']); + state.cmdStateAddBlocker(cwd, { text, text_file }, raw); } else if (subcommand === 'resolve-blocker') { - const textIdx = args.indexOf('--text'); - state.cmdStateResolveBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw); + state.cmdStateResolveBlocker(cwd, parseNamedArgs(args, ['text']).text, raw); } else if (subcommand === 'record-session') { - const stoppedIdx = args.indexOf('--stopped-at'); - const resumeIdx = args.indexOf('--resume-file'); - state.cmdStateRecordSession(cwd, { - stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null, - resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None', - }, raw); + const { 'stopped-at': stopped_at, 'resume-file': resume_file } = parseNamedArgs(args, ['stopped-at', 'resume-file']); + state.cmdStateRecordSession(cwd, { stopped_at, resume_file: resume_file || 'None' }, raw); } else if (subcommand === 'begin-phase') { - const phaseIdx = args.indexOf('--phase'); - const nameIdx = args.indexOf('--name'); - const plansIdx = args.indexOf('--plans'); - state.cmdStateBeginPhase( - cwd, - phaseIdx !== -1 ? args[phaseIdx + 1] : null, - nameIdx !== -1 ? args[nameIdx + 1] : null, - plansIdx !== -1 ? parseInt(args[plansIdx + 1], 10) : null, - raw - ); + const { phase: p, name, plans } = parseNamedArgs(args, ['phase', 'name', 'plans']); + state.cmdStateBeginPhase(cwd, p, name, plans !== null ? parseInt(plans, 10) : null, raw); } else if (subcommand === 'signal-waiting') { - const typeIdx = args.indexOf('--type'); - const qIdx = args.indexOf('--question'); - const optIdx = args.indexOf('--options'); - const phaseIdx = args.indexOf('--phase'); - state.cmdSignalWaiting( - cwd, - typeIdx !== -1 ? args[typeIdx + 1] : null, - qIdx !== -1 ? args[qIdx + 1] : null, - optIdx !== -1 ? args[optIdx + 1] : null, - phaseIdx !== -1 ? args[phaseIdx + 1] : null, - raw - ); + const { type, question, options, phase: p } = parseNamedArgs(args, ['type', 'question', 'options', 'phase']); + state.cmdSignalWaiting(cwd, type, question, options, p, raw); } else if (subcommand === 'signal-resume') { state.cmdSignalResume(cwd, raw); } else { @@ -452,24 +429,18 @@ async function runCommand(command, args, cwd, raw) { template.cmdTemplateSelect(cwd, args[2], raw); } else if (subcommand === 'fill') { const templateType = args[2]; - const phaseIdx = args.indexOf('--phase'); - const planIdx = args.indexOf('--plan'); - const nameIdx = args.indexOf('--name'); - const typeIdx = args.indexOf('--type'); - const waveIdx = args.indexOf('--wave'); - const fieldsIdx = args.indexOf('--fields'); + const { phase, plan, name, type, wave, fields: fieldsRaw } = parseNamedArgs(args, ['phase', 'plan', 'name', 'type', 'wave', 'fields']); + let fields = {}; + if (fieldsRaw) { + const { safeJsonParse } = require('./lib/security.cjs'); + const result = safeJsonParse(fieldsRaw, { label: '--fields' }); + if (!result.ok) error(result.error); + fields = result.value; + } template.cmdTemplateFill(cwd, templateType, { - phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null, - plan: planIdx !== -1 ? args[planIdx + 1] : null, - name: nameIdx !== -1 ? args[nameIdx + 1] : null, - type: typeIdx !== -1 ? args[typeIdx + 1] : 'execute', - wave: waveIdx !== -1 ? args[waveIdx + 1] : '1', - fields: fieldsIdx !== -1 ? (() => { - const { safeJsonParse } = require('./lib/security.cjs'); - const result = safeJsonParse(args[fieldsIdx + 1], { label: '--fields' }); - if (!result.ok) error(result.error); - return result.value; - })() : {}, + phase, plan, name, fields, + type: type || 'execute', + wave: wave || '1', }, raw); } else { error('Unknown template subcommand. Available: select, fill'); @@ -481,18 +452,14 @@ async function runCommand(command, args, cwd, raw) { const subcommand = args[1]; const file = args[2]; if (subcommand === 'get') { - const fieldIdx = args.indexOf('--field'); - frontmatter.cmdFrontmatterGet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, raw); + frontmatter.cmdFrontmatterGet(cwd, file, parseNamedArgs(args, ['field']).field, raw); } else if (subcommand === 'set') { - const fieldIdx = args.indexOf('--field'); - const valueIdx = args.indexOf('--value'); - frontmatter.cmdFrontmatterSet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, valueIdx !== -1 ? args[valueIdx + 1] : undefined, raw); + const { field, value } = parseNamedArgs(args, ['field', 'value']); + frontmatter.cmdFrontmatterSet(cwd, file, field, value !== null ? value : undefined, raw); } else if (subcommand === 'merge') { - const dataIdx = args.indexOf('--data'); - frontmatter.cmdFrontmatterMerge(cwd, file, dataIdx !== -1 ? args[dataIdx + 1] : null, raw); + frontmatter.cmdFrontmatterMerge(cwd, file, parseNamedArgs(args, ['data']).data, raw); } else if (subcommand === 'validate') { - const schemaIdx = args.indexOf('--schema'); - frontmatter.cmdFrontmatterValidate(cwd, file, schemaIdx !== -1 ? args[schemaIdx + 1] : null, raw); + frontmatter.cmdFrontmatterValidate(cwd, file, parseNamedArgs(args, ['schema']).schema, raw); } else { error('Unknown frontmatter subcommand. Available: get, set, merge, validate'); } diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 65ce7be88..b5ffdfe5c 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -643,8 +643,7 @@ function comparePhaseNum(a, b) { function searchPhaseInDir(baseDir, relBase, normalized) { try { - const entries = fs.readdirSync(baseDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); + const dirs = readSubdirectories(baseDir, true); // Match: starts with normalized (numeric) OR contains normalized as prefix segment (custom ID) const match = dirs.find(d => { if (d.startsWith(normalized)) return true; @@ -661,14 +660,9 @@ function searchPhaseInDir(baseDir, relBase, normalized) { const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = path.join(baseDir, match); - const phaseFiles = fs.readdirSync(phaseDir); - - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort(); - const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - const hasReviews = phaseFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); + const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir); + const plans = unsortedPlans.sort(); + const summaries = unsortedSummaries.sort(); const completedPlanIds = new Set( summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) @@ -753,8 +747,7 @@ function getArchivedPhaseDirs(cwd) { for (const archiveName of phaseDirs) { const version = archiveName.match(/^(v[\d.]+)-phases$/)[1]; const archivePath = path.join(milestonesDir, archiveName); - const entries = fs.readdirSync(archivePath, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); + const dirs = readSubdirectories(archivePath, true); for (const dir of dirs) { results.push({ @@ -1077,6 +1070,50 @@ function getMilestonePhaseFilter(cwd) { return isDirInMilestone; } +// ─── Phase file helpers ────────────────────────────────────────────────────── + +/** Filter a file list to just PLAN.md / *-PLAN.md entries. */ +function filterPlanFiles(files) { + return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); +} + +/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */ +function filterSummaryFiles(files) { + return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); +} + +/** + * Read a phase directory and return counts/flags for common file types. + * Returns an object with plans[], summaries[], and boolean flags for + * research/context/verification files. + */ +function getPhaseFileStats(phaseDir) { + const files = fs.readdirSync(phaseDir); + return { + plans: filterPlanFiles(files), + summaries: filterSummaryFiles(files), + hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), + hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'), + hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'), + hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'), + }; +} + +/** + * Read immediate child directories from a path. + * Returns [] if the path doesn't exist or can't be read. + * Pass sort=true to apply comparePhaseNum ordering. + */ +function readSubdirectories(dirPath, sort = false) { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs; + } catch { + return []; + } +} + module.exports = { output, error, @@ -1113,4 +1150,8 @@ module.exports = { planningPaths, getActiveWorkstream, setActiveWorkstream, + filterPlanFiles, + filterSummaryFiles, + getPhaseFileStats, + readSubdirectories, }; diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 97a075316..cfb9d3669 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error } = require('./core.cjs'); +const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error, readSubdirectories } = require('./core.cjs'); const { extractFrontmatter } = require('./frontmatter.cjs'); const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs'); @@ -461,256 +461,169 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { output(result, raw, decimalPhase); } -function cmdPhaseRemove(cwd, targetPhase, options, raw) { - if (!targetPhase) { - error('phase number required for phase remove'); +/** + * Renumber sibling decimal phases after a decimal phase is removed. + * e.g. removing 06.2 → 06.3 becomes 06.2, 06.4 becomes 06.3, etc. + * Returns { renamedDirs, renamedFiles }. + */ +function renameDecimalPhases(phasesDir, baseInt, removedDecimal) { + const renamedDirs = [], renamedFiles = []; + const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); + const dirs = readSubdirectories(phasesDir, true); + const toRename = dirs + .map(dir => { const m = dir.match(decPattern); return m ? { dir, oldDecimal: parseInt(m[1], 10), slug: m[2] } : null; }) + .filter(item => item && item.oldDecimal > removedDecimal) + .sort((a, b) => b.oldDecimal - a.oldDecimal); // descending to avoid conflicts + + for (const item of toRename) { + const newDecimal = item.oldDecimal - 1; + const oldPhaseId = `${baseInt}.${item.oldDecimal}`; + const newPhaseId = `${baseInt}.${newDecimal}`; + const newDirName = `${baseInt}.${newDecimal}-${item.slug}`; + fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) { + if (f.includes(oldPhaseId)) { + const newFileName = f.replace(oldPhaseId, newPhaseId); + fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName)); + renamedFiles.push({ from: f, to: newFileName }); + } + } } + return { renamedDirs, renamedFiles }; +} + +/** + * Renumber all integer phases after removedInt. + * e.g. removing phase 5 → phase 6 becomes 5, phase 7 becomes 6, etc. + * Returns { renamedDirs, renamedFiles }. + */ +function renameIntegerPhases(phasesDir, removedInt) { + const renamedDirs = [], renamedFiles = []; + const dirs = readSubdirectories(phasesDir, true); + const toRename = dirs + .map(dir => { + const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i); + if (!m) return null; + const dirInt = parseInt(m[1], 10); + return dirInt > removedInt ? { dir, oldInt: dirInt, letter: m[2] ? m[2].toUpperCase() : '', decimal: m[3] ? parseInt(m[3], 10) : null, slug: m[4] } : null; + }) + .filter(Boolean) + .sort((a, b) => a.oldInt !== b.oldInt ? b.oldInt - a.oldInt : (b.decimal || 0) - (a.decimal || 0)); + + for (const item of toRename) { + const newInt = item.oldInt - 1; + const newPadded = String(newInt).padStart(2, '0'); + const oldPadded = String(item.oldInt).padStart(2, '0'); + const letterSuffix = item.letter || ''; + const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : ''; + const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`; + const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`; + const newDirName = `${newPrefix}-${item.slug}`; + fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + for (const f of fs.readdirSync(path.join(phasesDir, newDirName))) { + if (f.startsWith(oldPrefix)) { + const newFileName = newPrefix + f.slice(oldPrefix.length); + fs.renameSync(path.join(phasesDir, newDirName, f), path.join(phasesDir, newDirName, newFileName)); + renamedFiles.push({ from: f, to: newFileName }); + } + } + } + return { renamedDirs, renamedFiles }; +} + +/** + * Remove a phase section from ROADMAP.md and renumber all subsequent integer phases. + */ +function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt) { + let content = fs.readFileSync(roadmapPath, 'utf-8'); + const escaped = escapeRegex(targetPhase); + + content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), ''); + content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), ''); + content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), ''); + + if (!isDecimal) { + const MAX_PHASE = 99; + for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) { + const newNum = oldNum - 1; + const oldStr = String(oldNum), newStr = String(newNum); + const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0'); + content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`); + content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`); + content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`); + content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `); + content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`); + } + } + + fs.writeFileSync(roadmapPath, content, 'utf-8'); +} + +function cmdPhaseRemove(cwd, targetPhase, options, raw) { + if (!targetPhase) error('phase number required for phase remove'); const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); const phasesDir = path.join(planningDir(cwd), 'phases'); - const force = options.force || false; - if (!fs.existsSync(roadmapPath)) { - error('ROADMAP.md not found'); - } + if (!fs.existsSync(roadmapPath)) error('ROADMAP.md not found'); - // Normalize the target const normalized = normalizePhaseName(targetPhase); const isDecimal = targetPhase.includes('.'); + const force = options.force || false; - // Find and validate target directory - let targetDir = null; - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized); - } catch { /* intentionally empty */ } + // Find target directory + const targetDir = readSubdirectories(phasesDir, true) + .find(d => d.startsWith(normalized + '-') || d === normalized) || null; - // Check for executed work (SUMMARY.md files) + // Guard against removing executed work if (targetDir && !force) { - const targetPath = path.join(phasesDir, targetDir); - const files = fs.readdirSync(targetPath); + const files = fs.readdirSync(path.join(phasesDir, targetDir)); const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); if (summaries.length > 0) { error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`); } } - // Delete target directory - if (targetDir) { - fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true }); - } + if (targetDir) fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true }); - // Renumber subsequent phases - const renamedDirs = []; - const renamedFiles = []; - - if (isDecimal) { - // Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2) - const baseParts = normalized.split('.'); - const baseInt = baseParts[0]; - const removedDecimal = parseInt(baseParts[1], 10); - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - - // Find sibling decimals with higher numbers - const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`); - const toRename = []; - for (const dir of dirs) { - const dm = dir.match(decPattern); - if (dm && parseInt(dm[1], 10) > removedDecimal) { - toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] }); - } - } - - // Sort descending to avoid conflicts - toRename.sort((a, b) => b.oldDecimal - a.oldDecimal); - - for (const item of toRename) { - const newDecimal = item.oldDecimal - 1; - const oldPhaseId = `${baseInt}.${item.oldDecimal}`; - const newPhaseId = `${baseInt}.${newDecimal}`; - const newDirName = `${baseInt}.${newDecimal}-${item.slug}`; - - // Rename directory - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); - renamedDirs.push({ from: item.dir, to: newDirName }); - - // Rename files inside - const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); - for (const f of dirFiles) { - // Files may have phase prefix like "06.2-01-PLAN.md" - if (f.includes(oldPhaseId)) { - const newFileName = f.replace(oldPhaseId, newPhaseId); - fs.renameSync( - path.join(phasesDir, newDirName, f), - path.join(phasesDir, newDirName, newFileName) - ); - renamedFiles.push({ from: f, to: newFileName }); - } - } - } - } catch { /* intentionally empty */ } - - } else { - // Integer removal: renumber all subsequent integer phases - const removedInt = parseInt(normalized, 10); - - try { - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b)); - - // Collect directories that need renumbering (integer phases > removed, and their decimals/letters) - const toRename = []; - for (const dir of dirs) { - const dm = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i); - if (!dm) continue; - const dirInt = parseInt(dm[1], 10); - if (dirInt > removedInt) { - toRename.push({ - dir, - oldInt: dirInt, - letter: dm[2] ? dm[2].toUpperCase() : '', - decimal: dm[3] ? parseInt(dm[3], 10) : null, - slug: dm[4], - }); - } - } - - // Sort descending to avoid conflicts - toRename.sort((a, b) => { - if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt; - return (b.decimal || 0) - (a.decimal || 0); - }); - - for (const item of toRename) { - const newInt = item.oldInt - 1; - const newPadded = String(newInt).padStart(2, '0'); - const oldPadded = String(item.oldInt).padStart(2, '0'); - const letterSuffix = item.letter || ''; - const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : ''; - const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`; - const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`; - const newDirName = `${newPrefix}-${item.slug}`; - - // Rename directory - fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName)); - renamedDirs.push({ from: item.dir, to: newDirName }); - - // Rename files inside - const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName)); - for (const f of dirFiles) { - if (f.startsWith(oldPrefix)) { - const newFileName = newPrefix + f.slice(oldPrefix.length); - fs.renameSync( - path.join(phasesDir, newDirName, f), - path.join(phasesDir, newDirName, newFileName) - ); - renamedFiles.push({ from: f, to: newFileName }); - } - } - } - } catch { /* intentionally empty */ } - } + // Renumber subsequent phases on disk + let renamedDirs = [], renamedFiles = []; + try { + const renamed = isDecimal + ? renameDecimalPhases(phasesDir, normalized.split('.')[0], parseInt(normalized.split('.')[1], 10)) + : renameIntegerPhases(phasesDir, parseInt(normalized, 10)); + renamedDirs = renamed.renamedDirs; + renamedFiles = renamed.renamedFiles; + } catch { /* intentionally empty */ } // Update ROADMAP.md - let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - - // Remove the target phase section - const targetEscaped = escapeRegex(targetPhase); - const sectionPattern = new RegExp( - `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, - 'i' - ); - roadmapContent = roadmapContent.replace(sectionPattern, ''); - - // Remove from phase list (checkbox) - const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi'); - roadmapContent = roadmapContent.replace(checkboxPattern, ''); - - // Remove from progress table - const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'); - roadmapContent = roadmapContent.replace(tableRowPattern, ''); - - // Renumber references in ROADMAP for subsequent phases - if (!isDecimal) { - const removedInt = parseInt(normalized, 10); - - // Collect all integer phases > removedInt - const maxPhase = 99; // reasonable upper bound - for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) { - const newNum = oldNum - 1; - const oldStr = String(oldNum); - const newStr = String(newNum); - const oldPad = oldStr.padStart(2, '0'); - const newPad = newStr.padStart(2, '0'); - - // Phase headings: ## Phase 18: or ### Phase 18: → ## Phase 17: or ### Phase 17: - roadmapContent = roadmapContent.replace( - new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), - `$1${newStr}$2` - ); - - // Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:** - roadmapContent = roadmapContent.replace( - new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), - `$1${newStr}$2` - ); - - // Plan references: 18-01 → 17-01 - roadmapContent = roadmapContent.replace( - new RegExp(`${oldPad}-(\\d{2})`, 'g'), - `${newPad}-$1` - ); - - // Table rows: | 18. → | 17. - roadmapContent = roadmapContent.replace( - new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), - `$1${newStr}. ` - ); - - // Depends on references - roadmapContent = roadmapContent.replace( - new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), - `$1${newStr}` - ); - } - } - - fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); + updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10)); // Update STATE.md phase count const statePath = path.join(planningDir(cwd), 'STATE.md'); if (fs.existsSync(statePath)) { let stateContent = fs.readFileSync(statePath, 'utf-8'); - // Update "Total Phases" field — supports both bold and plain formats const totalRaw = stateExtractField(stateContent, 'Total Phases'); if (totalRaw) { - const oldTotal = parseInt(totalRaw, 10); - stateContent = stateReplaceField(stateContent, 'Total Phases', String(oldTotal - 1)) || stateContent; + stateContent = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) || stateContent; } - // Update "Phase: X of Y" pattern - const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i; - const ofMatch = stateContent.match(ofPattern); + const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i); if (ofMatch) { - const oldTotal = parseInt(ofMatch[2], 10); - stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`); + stateContent = stateContent.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`); } writeStateMd(statePath, stateContent, cwd); } - const result = { + output({ removed: targetPhase, - directory_deleted: targetDir || null, + directory_deleted: targetDir, renamed_directories: renamedDirs, renamed_files: renamedFiles, roadmap_updated: true, state_updated: fs.existsSync(statePath), - }; - - output(result, raw); + }, raw); } function cmdPhaseComplete(cwd, phaseNum, raw) { diff --git a/get-shit-done/bin/lib/workstream.cjs b/get-shit-done/bin/lib/workstream.cjs index 2aeaf63ec..63e209c5b 100644 --- a/get-shit-done/bin/lib/workstream.cjs +++ b/get-shit-done/bin/lib/workstream.cjs @@ -10,7 +10,8 @@ const fs = require('fs'); const path = require('path'); -const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream } = require('./core.cjs'); +const { output, error, planningPaths, planningRoot, toPosixPath, getMilestoneInfo, generateSlugInternal, setActiveWorkstream, getActiveWorkstream, filterPlanFiles, filterSummaryFiles, readSubdirectories } = require('./core.cjs'); +const { stateExtractField } = require('./state.cjs'); // ─── Migration ────────────────────────────────────────────────────────────── @@ -184,25 +185,23 @@ function cmdWorkstreamList(cwd, raw) { const wsDir = path.join(wsRoot, entry.name); const phasesDir = path.join(wsDir, 'phases'); - let phaseCount = 0, completedCount = 0; - try { - const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); - phaseCount = dirs.length; - for (const d of dirs) { - const files = fs.readdirSync(path.join(phasesDir, d.name)); - const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const phaseDirs = readSubdirectories(phasesDir); + const phaseCount = phaseDirs.length; + let completedCount = 0; + for (const d of phaseDirs) { + try { + const phaseFiles = fs.readdirSync(path.join(phasesDir, d)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); if (plans.length > 0 && summaries.length >= plans.length) completedCount++; - } - } catch {} + } catch {} + } let status = 'unknown', currentPhase = null; try { const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8'); - const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); - if (statusMatch) status = statusMatch[1].trim(); - const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); - if (phaseMatch) currentPhase = phaseMatch[1].trim(); + status = stateExtractField(stateContent, 'Status') || 'unknown'; + currentPhase = stateExtractField(stateContent, 'Current Phase'); } catch {} workstreams.push({ @@ -240,14 +239,11 @@ function cmdWorkstreamStatus(cwd, name, raw) { }; const phases = []; - try { - const dirs = fs.readdirSync(p.phases, { withFileTypes: true }) - .filter(e => e.isDirectory()).map(e => e.name).sort(); - for (const dir of dirs) { - const phasePath = path.join(p.phases, dir); - const phaseFiles = fs.readdirSync(phasePath); - const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + for (const dir of readSubdirectories(p.phases).sort()) { + try { + const phaseFiles = fs.readdirSync(path.join(p.phases, dir)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); phases.push({ directory: dir, status: summaries.length >= plans.length && plans.length > 0 ? 'complete' : @@ -255,19 +251,16 @@ function cmdWorkstreamStatus(cwd, name, raw) { plan_count: plans.length, summary_count: summaries.length, }); - } - } catch {} + } catch {} + } let stateInfo = {}; try { const stateContent = fs.readFileSync(p.state, 'utf-8'); - const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); - const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); - const activityMatch = stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/); stateInfo = { - status: statusMatch ? statusMatch[1].trim() : 'unknown', - current_phase: phaseMatch ? phaseMatch[1].trim() : null, - last_activity: activityMatch ? activityMatch[1].trim() : null, + status: stateExtractField(stateContent, 'Status') || 'unknown', + current_phase: stateExtractField(stateContent, 'Current Phase'), + last_activity: stateExtractField(stateContent, 'Last Activity'), }; } catch {} @@ -392,19 +385,19 @@ function cmdWorkstreamProgress(cwd, raw) { const wsDir = path.join(wsRoot, entry.name); const phasesDir = path.join(wsDir, 'phases'); - let phaseCount = 0, completedCount = 0, totalPlans = 0, completedPlans = 0; - try { - const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); - phaseCount = dirs.length; - for (const d of dirs) { - const files = fs.readdirSync(path.join(phasesDir, d.name)); - const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const phaseDirsProgress = readSubdirectories(phasesDir); + const phaseCount = phaseDirsProgress.length; + let completedCount = 0, totalPlans = 0, completedPlans = 0; + for (const d of phaseDirsProgress) { + try { + const phaseFiles = fs.readdirSync(path.join(phasesDir, d)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); totalPlans += plans.length; completedPlans += Math.min(summaries.length, plans.length); if (plans.length > 0 && summaries.length >= plans.length) completedCount++; - } - } catch {} + } catch {} + } let roadmapPhaseCount = phaseCount; try { @@ -416,10 +409,8 @@ function cmdWorkstreamProgress(cwd, raw) { let status = 'unknown', currentPhase = null; try { const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8'); - const statusMatch = stateContent.match(/\*\*Status:\*\*\s*(.+)/); - if (statusMatch) status = statusMatch[1].trim(); - const phaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(.+)/); - if (phaseMatch) currentPhase = phaseMatch[1].trim(); + status = stateExtractField(stateContent, 'Status') || 'unknown'; + currentPhase = stateExtractField(stateContent, 'Current Phase'); } catch {} workstreams.push({ @@ -459,10 +450,8 @@ function getOtherActiveWorkstreams(cwd, excludeWs) { let status = 'unknown', currentPhase = null; try { const content = fs.readFileSync(statePath, 'utf-8'); - const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/); - if (statusMatch) status = statusMatch[1].trim(); - const phaseMatch = content.match(/\*\*Current Phase:\*\*\s*(.+)/); - if (phaseMatch) currentPhase = phaseMatch[1].trim(); + status = stateExtractField(content, 'Status') || 'unknown'; + currentPhase = stateExtractField(content, 'Current Phase'); } catch {} if (status.toLowerCase().includes('milestone complete') || @@ -470,18 +459,18 @@ function getOtherActiveWorkstreams(cwd, excludeWs) { continue; } - let phaseCount = 0, completedCount = 0; - try { - const phasesDir = path.join(wsDir, 'phases'); - const dirs = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()); - phaseCount = dirs.length; - for (const d of dirs) { - const files = fs.readdirSync(path.join(phasesDir, d.name)); - const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const phasesDir = path.join(wsDir, 'phases'); + const phaseDirsOther = readSubdirectories(phasesDir); + const phaseCount = phaseDirsOther.length; + let completedCount = 0; + for (const d of phaseDirsOther) { + try { + const phaseFiles = fs.readdirSync(path.join(phasesDir, d)); + const plans = filterPlanFiles(phaseFiles); + const summaries = filterSummaryFiles(phaseFiles); if (plans.length > 0 && summaries.length >= plans.length) completedCount++; - } - } catch {} + } catch {} + } others.push({ name: entry.name, status, current_phase: currentPhase, phases: `${completedCount}/${phaseCount}` }); } From 415a094d262668fbdb89c9c4864bd4fe998b7426 Mon Sep 17 00:00:00 2001 From: SalesTeamToolbox Date: Fri, 20 Mar 2026 23:41:25 -0700 Subject: [PATCH 4/6] refactor: add parseMultiwordArg helper and fix scaffold/milestone arg parsing Add parseMultiwordArg() to collect multi-token --flag values until the next flag. Replace manual name-collection loops in milestone complete and scaffold cases. Also fixes a bug in scaffold where args.slice() would include trailing flags in the name value. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/gsd-tools.cjs | 34 ++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index aa6abfa90..3a4710dc4 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -175,6 +175,22 @@ function parseNamedArgs(args, valueFlags = [], booleanFlags = []) { return result; } +/** + * Collect all tokens after --flag until the next --flag or end of args. + * Handles multi-word values like --name Foo Bar Version 1. + * Returns null if the flag is absent. + */ +function parseMultiwordArg(args, flag) { + const idx = args.indexOf(`--${flag}`); + if (idx === -1) return null; + const tokens = []; + for (let i = idx + 1; i < args.length; i++) { + if (args[i].startsWith('--')) break; + tokens.push(args[i]); + } + return tokens.length > 0 ? tokens.join(' ') : null; +} + // ─── CLI Router ─────────────────────────────────────────────────────────────── async function main() { @@ -610,18 +626,8 @@ async function runCommand(command, args, cwd, raw) { case 'milestone': { const subcommand = args[1]; if (subcommand === 'complete') { - const nameIndex = args.indexOf('--name'); + const milestoneName = parseMultiwordArg(args, 'name'); const archivePhases = args.includes('--archive-phases'); - // Collect --name value (everything after --name until next flag or end) - let milestoneName = null; - if (nameIndex !== -1) { - const nameArgs = []; - for (let i = nameIndex + 1; i < args.length; i++) { - if (args[i].startsWith('--')) break; - nameArgs.push(args[i]); - } - milestoneName = nameArgs.join(' ') || null; - } milestone.cmdMilestoneComplete(cwd, args[2], { name: milestoneName, archivePhases }, raw); } else { error('Unknown milestone subcommand. Available: complete'); @@ -674,11 +680,9 @@ async function runCommand(command, args, cwd, raw) { case 'scaffold': { const scaffoldType = args[1]; - const phaseIndex = args.indexOf('--phase'); - const nameIndex = args.indexOf('--name'); const scaffoldOptions = { - phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null, - name: nameIndex !== -1 ? args.slice(nameIndex + 1).join(' ') : null, + phase: parseNamedArgs(args, ['phase']).phase, + name: parseMultiwordArg(args, 'name'), }; commands.cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw); break; From 1ad5ab809796c6d14be92bdb609ac5c67b065143 Mon Sep 17 00:00:00 2001 From: SalesTeamToolbox Date: Sat, 21 Mar 2026 12:49:37 -0700 Subject: [PATCH 5/6] fix: use planningRoot in setActiveWorkstream and add path traversal tests Address review feedback on PR #1268: - setActiveWorkstream now uses planningRoot(cwd) instead of hardcoded path, matching getActiveWorkstream for consistency - Add 33 path traversal rejection tests covering all entry points: CLI --ws flag, GSD_WORKSTREAM env var, cmdWorkstreamSet, and poisoned active-workstream file Co-Authored-By: Claude Opus 4.6 (1M context) --- get-shit-done/bin/lib/core.cjs | 2 +- tests/workstream.test.cjs | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index b5ffdfe5c..06a263b33 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -584,7 +584,7 @@ function getActiveWorkstream(cwd) { * Set the active workstream. Pass null to clear. */ function setActiveWorkstream(cwd, name) { - const filePath = path.join(cwd, '.planning', 'active-workstream'); + const filePath = path.join(planningRoot(cwd), 'active-workstream'); if (!name) { try { fs.unlinkSync(filePath); } catch {} return; diff --git a/tests/workstream.test.cjs b/tests/workstream.test.cjs index 084429aed..e5eab75dc 100644 --- a/tests/workstream.test.cjs +++ b/tests/workstream.test.cjs @@ -357,3 +357,83 @@ describe('gsd-tools --ws flag integration', () => { assert.ok(data.directory.includes('workstreams/test-ws'), `path should be workstream-scoped: ${data.directory}`); }); }); + +// ─── Path Traversal Rejection ──────────────────────────────────────────────── + +describe('path traversal rejection', () => { + let tmpDir; + + before(() => { + tmpDir = createTempProject(); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); + const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'legit'); + fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n'); + }); + + after(() => cleanup(tmpDir)); + + const maliciousNames = [ + '../../etc', + '../foo', + 'ws/../../../passwd', + 'a/b', + 'ws name with spaces', + '..', + '.', + 'ws..traversal', + ]; + + describe('--ws flag rejects traversal attempts', () => { + for (const name of maliciousNames) { + test(`rejects --ws=${name}`, () => { + const result = runGsdTools(['workstream', 'list', '--raw', '--ws', name], tmpDir); + assert.ok(!result.success, `should reject --ws=${name}`); + assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`); + }); + } + }); + + describe('GSD_WORKSTREAM env var rejects traversal attempts', () => { + for (const name of maliciousNames) { + test(`rejects GSD_WORKSTREAM=${name}`, () => { + const result = runGsdTools(['workstream', 'list', '--raw'], tmpDir, { GSD_WORKSTREAM: name }); + assert.ok(!result.success, `should reject GSD_WORKSTREAM=${name}`); + assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`); + }); + } + }); + + describe('cmdWorkstreamSet rejects traversal attempts', () => { + for (const name of maliciousNames) { + test(`rejects set ${name}`, () => { + const result = runGsdTools(['workstream', 'set', name, '--raw'], tmpDir); + // set validates independently — should return error or invalid_name + assert.ok(result.success || !result.success, 'should handle gracefully'); + if (result.success) { + const data = JSON.parse(result.output); + assert.strictEqual(data.error, 'invalid_name', `should return invalid_name error for: ${name}`); + } + }); + } + }); + + describe('getActiveWorkstream rejects poisoned active-workstream file', () => { + for (const name of maliciousNames) { + test(`rejects poisoned file containing ${name}`, () => { + // Write malicious name directly to the active-workstream file + fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), name + '\n'); + const result = runGsdTools(['workstream', 'get'], tmpDir, { GSD_WORKSTREAM: '' }); + assert.ok(result.success, 'get should succeed'); + const data = JSON.parse(result.output); + // getActiveWorkstream should return null for invalid names + assert.strictEqual(data.active, null, `should return null for poisoned name: ${name}`); + }); + } + + // Cleanup: remove poisoned file + test('cleanup: remove active-workstream file', () => { + try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {} + }); + }); +}); From 8931a8766c1541f209845ecbd7f2a565dadcd051 Mon Sep 17 00:00:00 2001 From: SalesTeamToolbox Date: Sat, 21 Mar 2026 15:31:22 -0700 Subject: [PATCH 6/6] test: update copilot skill counts for workstreams command after rebase Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/copilot-install.test.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index a08fe43a0..e8d05d63f 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -620,7 +620,7 @@ describe('copyCommandsAsCopilotSkills', () => { // Count gsd-* directories — should be 31 const dirs = fs.readdirSync(tempDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual(dirs.length, 55, `expected 55 skill folders, got ${dirs.length}`); + assert.strictEqual(dirs.length, 56, `expected 56 skill folders, got ${dirs.length}`); } finally { fs.rmSync(tempDir, { recursive: true }); } @@ -1114,7 +1114,7 @@ const { execFileSync } = require('child_process'); const crypto = require('crypto'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); -const EXPECTED_SKILLS = 55; +const EXPECTED_SKILLS = 56; const EXPECTED_AGENTS = 18; function runCopilotInstall(cwd) {