feat: support ticket-based phase identifiers for team workflows (#1019)
Teams sharing a GSD repo collide on sequential phase numbers when multiple
people create phases concurrently. This adds support for arbitrary string
IDs (e.g. PROJ-42, AUTH-101) as phase identifiers.
New config option:
{ "phase_naming": "custom" }
Changes:
- core.cjs: normalizePhaseName() handles non-numeric IDs gracefully
- core.cjs: comparePhaseNum() falls back to string comparison for custom IDs
- core.cjs: searchPhaseInDir() matches custom ID prefixes case-insensitively
- core.cjs: getMilestonePhaseFilter() recognizes custom ID directory names
- core.cjs: getRoadmapPhaseInternal() matches Phase headers with any word chars
- core.cjs: loadConfig() adds phase_naming option (default: 'sequential')
- phase.cjs: cmdPhaseAdd() accepts --id flag for custom phase IDs
- verify.cjs: health check skips sequential numbering validation in custom mode
- gsd-tools.cjs: phase add --id PROJ-42 "description" wired up
Usage:
# Sequential mode (default, backward compatible)
gsd-tools phase add "User Authentication" # → Phase 3
# Custom mode (config: phase_naming: custom)
gsd-tools phase add --id PROJ-42 "User Authentication"
# Creates: .planning/phases/PROJ-42-user-authentication/
# ROADMAP: ### Phase PROJ-42: User Authentication
All 755 existing tests pass.
Fixes #1019
This commit is contained in:
@@ -35,7 +35,7 @@
|
|||||||
*
|
*
|
||||||
* Phase Operations:
|
* Phase Operations:
|
||||||
* phase next-decimal <phase> Calculate next decimal phase number
|
* phase next-decimal <phase> Calculate next decimal phase number
|
||||||
* phase add <description> Append new phase to roadmap + create dir
|
* phase add <description> [--id ID] Append new phase to roadmap + create dir
|
||||||
* phase insert <after> <description> Insert decimal phase after existing
|
* phase insert <after> <description> Insert decimal phase after existing
|
||||||
* phase remove <phase> [--force] Remove phase, renumber all subsequent
|
* phase remove <phase> [--force] Remove phase, renumber all subsequent
|
||||||
* phase complete <phase> Mark phase done, update state + roadmap
|
* phase complete <phase> Mark phase done, update state + roadmap
|
||||||
@@ -470,7 +470,18 @@ async function main() {
|
|||||||
if (subcommand === 'next-decimal') {
|
if (subcommand === 'next-decimal') {
|
||||||
phase.cmdPhaseNextDecimal(cwd, args[2], raw);
|
phase.cmdPhaseNextDecimal(cwd, args[2], raw);
|
||||||
} else if (subcommand === 'add') {
|
} else if (subcommand === 'add') {
|
||||||
phase.cmdPhaseAdd(cwd, args.slice(2).join(' '), raw);
|
const idIdx = args.indexOf('--id');
|
||||||
|
let customId = null;
|
||||||
|
const descArgs = [];
|
||||||
|
for (let i = 2; i < args.length; i++) {
|
||||||
|
if (args[i] === '--id' && i + 1 < args.length) {
|
||||||
|
customId = args[i + 1];
|
||||||
|
i++; // skip value
|
||||||
|
} else {
|
||||||
|
descArgs.push(args[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase.cmdPhaseAdd(cwd, descArgs.join(' '), raw, customId);
|
||||||
} else if (subcommand === 'insert') {
|
} else if (subcommand === 'insert') {
|
||||||
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
|
phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
|
||||||
} else if (subcommand === 'remove') {
|
} else if (subcommand === 'remove') {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function loadConfig(cwd) {
|
|||||||
brave_search: false,
|
brave_search: false,
|
||||||
resolve_model_ids: false, // when true, resolve aliases (opus/sonnet/haiku) to full model IDs
|
resolve_model_ids: false, // when true, resolve aliases (opus/sonnet/haiku) to full model IDs
|
||||||
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
|
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
|
||||||
|
phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -110,6 +111,7 @@ function loadConfig(cwd) {
|
|||||||
brave_search: get('brave_search') ?? defaults.brave_search,
|
brave_search: get('brave_search') ?? defaults.brave_search,
|
||||||
resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
|
resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
|
||||||
context_window: get('context_window') ?? defaults.context_window,
|
context_window: get('context_window') ?? defaults.context_window,
|
||||||
|
phase_naming: get('phase_naming') ?? defaults.phase_naming,
|
||||||
model_overrides: parsed.model_overrides || null,
|
model_overrides: parsed.model_overrides || null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -280,17 +282,23 @@ function escapeRegex(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePhaseName(phase) {
|
function normalizePhaseName(phase) {
|
||||||
const match = String(phase).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
const str = String(phase);
|
||||||
if (!match) return phase;
|
// Standard numeric phases: 1, 01, 12A, 12.1
|
||||||
const padded = match[1].padStart(2, '0');
|
const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||||
const letter = match[2] ? match[2].toUpperCase() : '';
|
if (match) {
|
||||||
const decimal = match[3] || '';
|
const padded = match[1].padStart(2, '0');
|
||||||
return padded + letter + decimal;
|
const letter = match[2] ? match[2].toUpperCase() : '';
|
||||||
|
const decimal = match[3] || '';
|
||||||
|
return padded + letter + decimal;
|
||||||
|
}
|
||||||
|
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
|
||||||
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparePhaseNum(a, b) {
|
function comparePhaseNum(a, b) {
|
||||||
const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||||
const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||||
|
// If either is non-numeric (custom ID), fall back to string comparison
|
||||||
if (!pa || !pb) return String(a).localeCompare(String(b));
|
if (!pa || !pb) return String(a).localeCompare(String(b));
|
||||||
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
||||||
if (intDiff !== 0) return intDiff;
|
if (intDiff !== 0) return intDiff;
|
||||||
@@ -320,10 +328,19 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
|
|||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
|
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 = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
||||||
const match = dirs.find(d => d.startsWith(normalized));
|
// Match: starts with normalized (numeric) OR contains normalized as prefix segment (custom ID)
|
||||||
|
const match = dirs.find(d => {
|
||||||
|
if (d.startsWith(normalized)) return true;
|
||||||
|
// For custom IDs like PROJ-42, match case-insensitively
|
||||||
|
if (d.toUpperCase().startsWith(normalized.toUpperCase())) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
// Extract phase number and name — supports both numeric (01-name) and custom (PROJ-42-name)
|
||||||
|
const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
||||||
|
|| match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
|
||||||
|
|| [null, match, null];
|
||||||
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
||||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||||
const phaseDir = path.join(baseDir, match);
|
const phaseDir = path.join(baseDir, match);
|
||||||
@@ -556,6 +573,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|||||||
try {
|
try {
|
||||||
const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
||||||
const escapedPhase = escapeRegex(phaseNum.toString());
|
const escapedPhase = escapeRegex(phaseNum.toString());
|
||||||
|
// Match both numeric (Phase 1:) and custom (Phase PROJ-42:) headers
|
||||||
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
|
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
|
||||||
const headerMatch = content.match(phasePattern);
|
const headerMatch = content.match(phasePattern);
|
||||||
if (!headerMatch) return null;
|
if (!headerMatch) return null;
|
||||||
@@ -563,7 +581,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|||||||
const phaseName = headerMatch[1].trim();
|
const phaseName = headerMatch[1].trim();
|
||||||
const headerIndex = headerMatch.index;
|
const headerIndex = headerMatch.index;
|
||||||
const restOfContent = content.slice(headerIndex);
|
const restOfContent = content.slice(headerIndex);
|
||||||
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
|
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
|
||||||
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
|
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
|
||||||
const section = content.slice(headerIndex, sectionEnd).trim();
|
const section = content.slice(headerIndex, sectionEnd).trim();
|
||||||
|
|
||||||
@@ -681,7 +699,8 @@ function getMilestonePhaseFilter(cwd) {
|
|||||||
const milestonePhaseNums = new Set();
|
const milestonePhaseNums = new Set();
|
||||||
try {
|
try {
|
||||||
const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'), cwd);
|
const roadmap = extractCurrentMilestone(fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8'), cwd);
|
||||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
|
// 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;
|
let m;
|
||||||
while ((m = phasePattern.exec(roadmap)) !== null) {
|
while ((m = phasePattern.exec(roadmap)) !== null) {
|
||||||
milestonePhaseNums.add(m[1]);
|
milestonePhaseNums.add(m[1]);
|
||||||
@@ -699,9 +718,13 @@ function getMilestonePhaseFilter(cwd) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
function isDirInMilestone(dirName) {
|
function isDirInMilestone(dirName) {
|
||||||
|
// Try numeric match first
|
||||||
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
||||||
if (!m) return false;
|
if (m && normalized.has(m[1].toLowerCase())) return true;
|
||||||
return normalized.has(m[1].toLowerCase());
|
// Try custom ID match (e.g. PROJ-42-description → PROJ-42)
|
||||||
|
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
||||||
|
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
||||||
return isDirInMilestone;
|
return isDirInMilestone;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { escapeRegex, 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, output, error } = require('./core.cjs');
|
||||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||||
const { writeStateMd } = require('./state.cjs');
|
const { writeStateMd } = require('./state.cjs');
|
||||||
|
|
||||||
@@ -308,11 +308,12 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|||||||
output(result, raw);
|
output(result, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmdPhaseAdd(cwd, description, raw) {
|
function cmdPhaseAdd(cwd, description, raw, customId) {
|
||||||
if (!description) {
|
if (!description) {
|
||||||
error('description required for phase add');
|
error('description required for phase add');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(cwd);
|
||||||
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md');
|
||||||
if (!fs.existsSync(roadmapPath)) {
|
if (!fs.existsSync(roadmapPath)) {
|
||||||
error('ROADMAP.md not found');
|
error('ROADMAP.md not found');
|
||||||
@@ -322,18 +323,29 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|||||||
const content = extractCurrentMilestone(rawContent, cwd);
|
const content = extractCurrentMilestone(rawContent, cwd);
|
||||||
const slug = generateSlugInternal(description);
|
const slug = generateSlugInternal(description);
|
||||||
|
|
||||||
// Find highest integer phase number (in current milestone only)
|
let newPhaseId;
|
||||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
let dirName;
|
||||||
let maxPhase = 0;
|
|
||||||
let m;
|
if (customId || config.phase_naming === 'custom') {
|
||||||
while ((m = phasePattern.exec(content)) !== null) {
|
// Custom phase naming: use provided ID or generate from description
|
||||||
const num = parseInt(m[1], 10);
|
newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
|
||||||
if (num > maxPhase) maxPhase = num;
|
if (!newPhaseId) error('--id required when phase_naming is "custom"');
|
||||||
|
dirName = `${newPhaseId}-${slug}`;
|
||||||
|
} else {
|
||||||
|
// Sequential mode: find highest integer phase number (in current milestone only)
|
||||||
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||||
|
let maxPhase = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = phasePattern.exec(content)) !== null) {
|
||||||
|
const num = parseInt(m[1], 10);
|
||||||
|
if (num > maxPhase) maxPhase = num;
|
||||||
|
}
|
||||||
|
|
||||||
|
newPhaseId = maxPhase + 1;
|
||||||
|
const paddedNum = String(newPhaseId).padStart(2, '0');
|
||||||
|
dirName = `${paddedNum}-${slug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPhaseNum = maxPhase + 1;
|
|
||||||
const paddedNum = String(newPhaseNum).padStart(2, '0');
|
|
||||||
const dirName = `${paddedNum}-${slug}`;
|
|
||||||
const dirPath = path.join(cwd, '.planning', 'phases', dirName);
|
const dirPath = path.join(cwd, '.planning', 'phases', dirName);
|
||||||
|
|
||||||
// Create directory with .gitkeep so git tracks empty folders
|
// Create directory with .gitkeep so git tracks empty folders
|
||||||
@@ -341,7 +353,8 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|||||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||||
|
|
||||||
// Build phase entry
|
// Build phase entry
|
||||||
const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseNum} to break down)\n`;
|
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
||||||
|
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseId} to break down)\n`;
|
||||||
|
|
||||||
// Find insertion point: before last "---" or at end
|
// Find insertion point: before last "---" or at end
|
||||||
let updatedContent;
|
let updatedContent;
|
||||||
@@ -355,14 +368,15 @@ function cmdPhaseAdd(cwd, description, raw) {
|
|||||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
phase_number: newPhaseNum,
|
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
||||||
padded: paddedNum,
|
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
||||||
name: description,
|
name: description,
|
||||||
slug,
|
slug,
|
||||||
directory: `.planning/phases/${dirName}`,
|
directory: `.planning/phases/${dirName}`,
|
||||||
|
naming_mode: config.phase_naming,
|
||||||
};
|
};
|
||||||
|
|
||||||
output(result, raw, paddedNum);
|
output(result, raw, result.padded);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { safeReadFile, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error } = require('./core.cjs');
|
const { safeReadFile, loadConfig, normalizePhaseName, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error } = require('./core.cjs');
|
||||||
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
||||||
const { writeStateMd } = require('./state.cjs');
|
const { writeStateMd } = require('./state.cjs');
|
||||||
|
|
||||||
@@ -445,15 +445,18 @@ function cmdValidateConsistency(cwd, raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check: sequential phase numbers (integers only)
|
// Check: sequential phase numbers (integers only, skip in custom naming mode)
|
||||||
const integerPhases = [...diskPhases]
|
const config = loadConfig(cwd);
|
||||||
.filter(p => !p.includes('.'))
|
if (config.phase_naming !== 'custom') {
|
||||||
.map(p => parseInt(p, 10))
|
const integerPhases = [...diskPhases]
|
||||||
.sort((a, b) => a - b);
|
.filter(p => !p.includes('.'))
|
||||||
|
.map(p => parseInt(p, 10))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
for (let i = 1; i < integerPhases.length; i++) {
|
for (let i = 1; i < integerPhases.length; i++) {
|
||||||
if (integerPhases[i] !== integerPhases[i - 1] + 1) {
|
if (integerPhases[i] !== integerPhases[i - 1] + 1) {
|
||||||
warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`);
|
warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user