Merge pull request #1268 from SalesTeamToolbox/workstreams-v2

feat: workstream namespacing for parallel Claude Code instances
This commit is contained in:
Tom Boucher
2026-03-22 10:42:45 -04:00
committed by GitHub
23 changed files with 1764 additions and 527 deletions

View File

@@ -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 <name>` | Create a new workstream |
| `status <name>` | Detailed status for one workstream |
| `switch <name>` | Set active workstream |
| `progress` | Progress summary across all workstreams |
| `complete <name>` | Archive a completed workstream |
| `resume <name>` | 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 <name> --raw --cwd "$CWD"`
After creation, display the new workstream path and suggest next steps:
- `/gsd:new-milestone --ws <name>` to set up the milestone
### status
Run: `node "$GSD_TOOLS" workstream status <name> --raw --cwd "$CWD"`
Display detailed phase breakdown and state information.
### switch
Run: `node "$GSD_TOOLS" workstream set <name> --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 <name> --raw --cwd "$CWD"`
Archive the workstream to milestones/.
### resume
Set the workstream as active and suggest `/gsd:resume-work --ws <name>`.
## 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.

View File

@@ -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,47 @@ 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');
// ─── Arg parsing helpers ──────────────────────────────────────────────────────
/**
* Extract named --flag <value> 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;
}
/**
* 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 ───────────────────────────────────────────────────────────────
@@ -185,6 +227,33 @@ 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);
}
// 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;
}
const rawIndex = args.indexOf('--raw');
const raw = rawIndex !== -1;
if (rawIndex !== -1) args.splice(rawIndex, 1);
@@ -203,7 +272,7 @@ async function main() {
const command = args[0];
if (!command) {
error('Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>]\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 <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>]\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/.
@@ -300,74 +369,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 {
@@ -423,24 +445,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');
@@ -452,18 +468,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');
}
@@ -614,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');
@@ -678,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;
@@ -862,6 +862,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}`);
}

View File

@@ -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:

View File

@@ -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,
@@ -155,8 +155,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)) {
@@ -176,8 +176,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);
@@ -200,13 +200,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);
@@ -250,7 +250,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 = {};
@@ -313,7 +313,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 <key.path>');

View File

@@ -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(planningRoot(cwd), 'active-workstream');
try {
const name = fs.readFileSync(filePath, 'utf-8').trim();
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 {
return null;
}
}
/**
* Set the active workstream. Pass null to clear.
*/
function setActiveWorkstream(cwd, name) {
const filePath = path.join(planningRoot(cwd), 'active-workstream');
if (!name) {
try { fs.unlinkSync(filePath); } catch {}
return;
}
fs.writeFileSync(filePath, name + '\n', 'utf-8');
}
// ─── Phase utilities ──────────────────────────────────────────────────────────
function escapeRegex(value) {
@@ -593,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;
@@ -611,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', ''))
@@ -650,11 +694,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)
@@ -702,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({
@@ -753,7 +797,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 +880,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 +995,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 +1038,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;
@@ -1026,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,
@@ -1058,5 +1146,12 @@ module.exports = {
reapStaleTempFiles,
MODEL_ALIAS_MAP,
planningDir,
planningRoot,
planningPaths,
getActiveWorkstream,
setActiveWorkstream,
filterPlanFiles,
filterSummaryFiles,
getPhaseFileStats,
readSubdirectories,
};

View File

@@ -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);

View File

@@ -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, readSubdirectories } = 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,262 +455,175 @@ 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);
}
/**
* 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');
}
if (!targetPhase) error('phase number required for phase remove');
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
const phasesDir = path.join(cwd, '.planning', 'phases');
const force = options.force || false;
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
const phasesDir = path.join(planningDir(cwd), 'phases');
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(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
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) {
@@ -718,9 +631,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 +712,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);

View File

@@ -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;

View File

@@ -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,

View File

@@ -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',

View File

@@ -0,0 +1,491 @@
/**
* 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, filterPlanFiles, filterSummaryFiles, readSubdirectories } = require('./core.cjs');
const { stateExtractField } = require('./state.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 <name>');
}
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');
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 {}
}
let status = 'unknown', currentPhase = null;
try {
const stateContent = fs.readFileSync(path.join(wsDir, 'STATE.md'), 'utf-8');
status = stateExtractField(stateContent, 'Status') || 'unknown';
currentPhase = stateExtractField(stateContent, 'Current Phase');
} 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 <name>');
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 = [];
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' :
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');
stateInfo = {
status: stateExtractField(stateContent, 'Status') || 'unknown',
current_phase: stateExtractField(stateContent, 'Current Phase'),
last_activity: stateExtractField(stateContent, 'Last Activity'),
};
} 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 <name>');
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;
}
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);
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');
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 {}
}
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');
status = stateExtractField(stateContent, 'Status') || 'unknown';
currentPhase = stateExtractField(stateContent, 'Current Phase');
} 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');
status = stateExtractField(content, 'Status') || 'unknown';
currentPhase = stateExtractField(content, 'Current Phase');
} catch {}
if (status.toLowerCase().includes('milestone complete') ||
status.toLowerCase().includes('archived')) {
continue;
}
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 {}
}
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,
};

View File

@@ -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 <name>` | Create a new workstream |
| `status <name>` | Detailed status for one workstream |
| `switch <name>` | Set active workstream |
| `progress` | Progress summary across all workstreams |
| `complete <name>` | Archive a completed workstream |
| `resume <name>` | 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 <name> --raw --cwd "$CWD"`
After creation, display the new workstream path and suggest next steps:
- `/gsd:new-milestone --ws <name>` to set up the milestone
### status
Run: `node "$GSD_TOOLS" workstream status <name> --raw --cwd "$CWD"`
Display detailed phase breakdown and state information.
### switch
Run: `node "$GSD_TOOLS" workstream set <name> --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 <name> --raw --cwd "$CWD"`
Archive the workstream to milestones/.
### resume
Set the workstream as active and suggest `/gsd:resume-work --ws <name>`.
## 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.

View File

@@ -0,0 +1,58 @@
# Workstream Flag (`--ws`)
## Overview
The `--ws <name>` 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 <name>` 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 <name>` 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 <name>
node gsd-tools.cjs workstream list
node gsd-tools.cjs workstream status <name>
node gsd-tools.cjs workstream complete <name>
```

View File

@@ -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}`
<sub>`/clear` first → fresh context window</sub>
---
**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}
<sub>/clear first → fresh context window</sub>
```
- **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:**

View File

@@ -457,8 +457,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:**
@@ -603,7 +603,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:**
@@ -680,15 +680,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}`
<sub>`/clear` first → fresh context window</sub>
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.
</step>
<step name="update_roadmap">
@@ -802,10 +802,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.

View File

@@ -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
<sub>`/clear` first → fresh context window</sub>
Also: `/gsd:plan-phase [N]` — skip discussion, plan directly
Also: `/gsd:plan-phase [N] ${GSD_WS}` — skip discussion, plan directly
```
</process>
@@ -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.
</success_criteria>

View File

@@ -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}
<sub>/clear first → fresh context window</sub>

View File

@@ -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 `<objective>` section.
**{phase}-{plan}: [Plan Name]** — [objective summary from PLAN.md]
`/gsd:execute-phase {phase}`
`/gsd:execute-phase {phase} ${GSD_WS}`
<sub>`/clear` first → fresh context window</sub>
@@ -245,7 +245,7 @@ PHASE_HAS_UI=$(echo "$PHASE_SECTION" | grep -qi "UI hint.*yes" && echo "true" ||
**Phase {N}: {Name}** — {Goal from ROADMAP.md}
<sub>✓ Context gathered, ready to plan</sub>
`/gsd:plan-phase {phase-number}`
`/gsd:plan-phase {phase-number} ${GSD_WS}`
<sub>`/clear` first → fresh context window</sub>
@@ -284,15 +284,15 @@ PHASE_HAS_UI=$(echo "$PHASE_SECTION" | grep -qi "UI hint.*yes" && echo "true" ||
**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
<sub>`/clear` first → fresh context window</sub>
---
**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
---
```
@@ -310,15 +310,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}`
<sub>`/clear` first → fresh context window</sub>
---
**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
---
```
@@ -336,15 +336,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
<sub>`/clear` first → fresh context window</sub>
---
**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
---
```
@@ -417,15 +417,15 @@ NEXT_HAS_UI=$(echo "$NEXT_PHASE_SECTION" | grep -qi "UI hint.*yes" && echo "true
**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
<sub>`/clear` first → fresh context window</sub>
---
**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
---
```
@@ -445,14 +445,14 @@ All {N} phases finished!
**Complete Milestone** — archive and prepare for next
`/gsd:complete-milestone`
`/gsd:complete-milestone ${GSD_WS}`
<sub>`/clear` first → fresh context window</sub>
---
**Also available:**
- `/gsd:verify-work` — user acceptance test before completing milestone
- `/gsd:verify-work ${GSD_WS}` — user acceptance test before completing milestone
---
```
@@ -476,7 +476,7 @@ Ready to plan the next milestone.
**Start Next Milestone** — questioning → research → requirements → roadmap
`/gsd:new-milestone`
`/gsd:new-milestone ${GSD_WS}`
<sub>`/clear` first → fresh context window</sub>
@@ -488,10 +488,10 @@ Ready to plan the next milestone.
<step name="edge_cases">
**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}`
</step>
</process>

View File

@@ -692,7 +692,7 @@ Commit: ${commit_hash}
---
Ready for next task: /gsd:quick
Ready for next task: /gsd:quick ${GSD_WS}
```
**If NOT `$FULL_MODE`:**
@@ -709,7 +709,7 @@ Commit: ${commit_hash}
---
Ready for next task: /gsd:quick
Ready for next task: /gsd:quick ${GSD_WS}
```
</process>

View File

@@ -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}`
<sub>`/clear` first → fresh context window</sub>
@@ -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}`
<sub>`/clear` first → fresh context window</sub>
---
**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
---
```

View File

@@ -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}")
</if>
@@ -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
<sub>`/clear` first → fresh context window</sub>
---
**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]
<sub>✓ Context gathered, ready to plan</sub>
`/gsd:plan-phase [X+1]`
`/gsd:plan-phase [X+1] ${GSD_WS}`
<sub>`/clear` first → fresh context window</sub>
---
**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
```
<if mode="yolo">
Override auto-advance: do NOT auto-continue to milestone completion.
Present the blocking information and stop.
</if>
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}`
<sub>Milestone completion will be available once all workstreams finish.</sub>
---
```
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}")
</if>
@@ -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}`
<sub>`/clear` first → fresh context window</sub>

View File

@@ -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.

439
tests/workstream.test.cjs Normal file
View File

@@ -0,0 +1,439 @@
/**
* 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}`);
});
});
// ─── 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 {}
});
});
});