Merge pull request #1268 from SalesTeamToolbox/workstreams-v2
feat: workstream namespacing for parallel Claude Code instances
This commit is contained in:
63
commands/gsd/workstreams.md
Normal file
63
commands/gsd/workstreams.md
Normal 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.
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
491
get-shit-done/bin/lib/workstream.cjs
Normal file
491
get-shit-done/bin/lib/workstream.cjs
Normal 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,
|
||||
};
|
||||
63
get-shit-done/commands/gsd/workstreams.md
Normal file
63
get-shit-done/commands/gsd/workstreams.md
Normal 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.
|
||||
58
get-shit-done/references/workstream-flag.md
Normal file
58
get-shit-done/references/workstream-flag.md
Normal 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>
|
||||
```
|
||||
@@ -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:**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
439
tests/workstream.test.cjs
Normal 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 {}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user