diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 633d921ff..bb24d0835 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -35,7 +35,7 @@ * * Phase Operations: * phase next-decimal Calculate next decimal phase number - * phase add Append new phase to roadmap + create dir + * phase add [--id ID] Append new phase to roadmap + create dir * phase insert Insert decimal phase after existing * phase remove [--force] Remove phase, renumber all subsequent * phase complete Mark phase done, update state + roadmap @@ -470,7 +470,18 @@ async function main() { if (subcommand === 'next-decimal') { phase.cmdPhaseNextDecimal(cwd, args[2], raw); } 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') { phase.cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw); } else if (subcommand === 'remove') { diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 66918b018..df71d8c97 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -66,6 +66,7 @@ function loadConfig(cwd) { brave_search: false, 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 + phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs) }; try { @@ -110,6 +111,7 @@ function loadConfig(cwd) { brave_search: get('brave_search') ?? defaults.brave_search, resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids, context_window: get('context_window') ?? defaults.context_window, + phase_naming: get('phase_naming') ?? defaults.phase_naming, model_overrides: parsed.model_overrides || null, }; } catch { @@ -280,17 +282,23 @@ function escapeRegex(value) { } function normalizePhaseName(phase) { - const match = String(phase).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); - if (!match) return phase; - const padded = match[1].padStart(2, '0'); - const letter = match[2] ? match[2].toUpperCase() : ''; - const decimal = match[3] || ''; - return padded + letter + decimal; + const str = String(phase); + // Standard numeric phases: 1, 01, 12A, 12.1 + const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + if (match) { + const padded = match[1].padStart(2, '0'); + 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) { const pa = String(a).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)); const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10); if (intDiff !== 0) return intDiff; @@ -320,10 +328,19 @@ 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 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; - 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 phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; const phaseDir = path.join(baseDir, match); @@ -556,6 +573,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) { try { const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd); 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 headerMatch = content.match(phasePattern); if (!headerMatch) return null; @@ -563,7 +581,7 @@ function getRoadmapPhaseInternal(cwd, phaseNum) { const phaseName = headerMatch[1].trim(); const headerIndex = headerMatch.index; 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 section = content.slice(headerIndex, sectionEnd).trim(); @@ -681,7 +699,8 @@ function getMilestonePhaseFilter(cwd) { const milestonePhaseNums = new Set(); try { 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; while ((m = phasePattern.exec(roadmap)) !== null) { milestonePhaseNums.add(m[1]); @@ -699,9 +718,13 @@ function getMilestonePhaseFilter(cwd) { ); function isDirInMilestone(dirName) { + // Try numeric match first const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); - if (!m) return false; - return normalized.has(m[1].toLowerCase()); + if (m && normalized.has(m[1].toLowerCase())) return true; + // 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; return isDirInMilestone; diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index dc2c52f99..4d737dc78 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); -const { escapeRegex, 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 { writeStateMd } = require('./state.cjs'); @@ -308,11 +308,12 @@ function cmdPhasePlanIndex(cwd, phase, raw) { output(result, raw); } -function cmdPhaseAdd(cwd, description, raw) { +function cmdPhaseAdd(cwd, description, raw, customId) { if (!description) { error('description required for phase add'); } + const config = loadConfig(cwd); const roadmapPath = path.join(cwd, '.planning', 'ROADMAP.md'); if (!fs.existsSync(roadmapPath)) { error('ROADMAP.md not found'); @@ -322,18 +323,29 @@ function cmdPhaseAdd(cwd, description, raw) { const content = extractCurrentMilestone(rawContent, cwd); const slug = generateSlugInternal(description); - // 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; + let newPhaseId; + let dirName; + + if (customId || config.phase_naming === 'custom') { + // Custom phase naming: use provided ID or generate from description + newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-'); + 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); // Create directory with .gitkeep so git tracks empty folders @@ -341,7 +353,8 @@ function cmdPhaseAdd(cwd, description, raw) { fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); // 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 let updatedContent; @@ -355,14 +368,15 @@ function cmdPhaseAdd(cwd, description, raw) { fs.writeFileSync(roadmapPath, updatedContent, 'utf-8'); const result = { - phase_number: newPhaseNum, - padded: paddedNum, + phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId), + padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId), name: description, slug, directory: `.planning/phases/${dirName}`, + naming_mode: config.phase_naming, }; - output(result, raw, paddedNum); + output(result, raw, result.padded); } function cmdPhaseInsert(cwd, afterPhase, description, raw) { diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index 5b3a12c13..c334eaa34 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -5,7 +5,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { safeReadFile, 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 { writeStateMd } = require('./state.cjs'); @@ -445,15 +445,18 @@ function cmdValidateConsistency(cwd, raw) { } } - // Check: sequential phase numbers (integers only) - const integerPhases = [...diskPhases] - .filter(p => !p.includes('.')) - .map(p => parseInt(p, 10)) - .sort((a, b) => a - b); + // Check: sequential phase numbers (integers only, skip in custom naming mode) + const config = loadConfig(cwd); + if (config.phase_naming !== 'custom') { + const integerPhases = [...diskPhases] + .filter(p => !p.includes('.')) + .map(p => parseInt(p, 10)) + .sort((a, b) => a - b); - for (let i = 1; i < integerPhases.length; i++) { - if (integerPhases[i] !== integerPhases[i - 1] + 1) { - warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`); + for (let i = 1; i < integerPhases.length; i++) { + if (integerPhases[i] !== integerPhases[i - 1] + 1) { + warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`); + } } }