diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index 67fe5a6b9..fefb63c96 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -25,6 +25,7 @@ const VALID_CONFIG_KEYS = new Set([ 'git.branching_strategy', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template', 'planning.commit_docs', 'planning.search_gitignored', 'hooks.context_warnings', + 'project_code', 'phase_naming', ]); /** @@ -132,6 +133,8 @@ function buildNewProjectConfig(userChoices) { hooks: { context_warnings: true, }, + project_code: null, + phase_naming: 'sequential', agent_skills: {}, }; diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index e509e849a..ebe19ac24 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -217,6 +217,7 @@ function loadConfig(cwd) { resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default) 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) + project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation') }; try { @@ -308,6 +309,7 @@ function loadConfig(cwd) { 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, + project_code: get('project_code') ?? defaults.project_code, model_overrides: parsed.model_overrides || null, agent_skills: parsed.agent_skills || {}, }; @@ -619,8 +621,10 @@ function escapeRegex(value) { function normalizePhaseName(phase) { const str = String(phase); + // Strip optional project_code prefix (e.g., 'CK-01' → '01') + const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, ''); // Standard numeric phases: 1, 01, 12A, 12.1 - const match = str.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); if (match) { const padded = match[1].padStart(2, '0'); const letter = match[2] ? match[2].toUpperCase() : ''; @@ -632,8 +636,11 @@ function normalizePhaseName(phase) { } function comparePhaseNum(a, b) { - const pa = String(a).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); - const pb = String(b).match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + // Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name') + const sa = String(a).replace(/^[A-Z]{1,6}-/, ''); + const sb = String(b).replace(/^[A-Z]{1,6}-/, ''); + const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + const pb = sb.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); @@ -668,12 +675,17 @@ function searchPhaseInDir(baseDir, relBase, normalized) { if (d.startsWith(normalized)) return true; // For custom IDs like PROJ-42, match case-insensitively if (d.toUpperCase().startsWith(normalized.toUpperCase())) return true; + // Strip optional project_code prefix (e.g., 'CK-01-name' → '01-name') and retry + const stripped = d.replace(/^[A-Z]{1,6}-/, ''); + if (stripped.startsWith(normalized)) return true; + if (stripped.toUpperCase().startsWith(normalized.toUpperCase())) return true; return false; }); if (!match) return null; - // Extract phase number and name — supports both numeric (01-name) and custom (PROJ-42-name) - const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) + // Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name) + const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) + || 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; diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index d9ee54e42..0cb2a078f 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -108,6 +108,7 @@ function cmdInitExecutePhase(cwd, phase, raw) { // Branch name (pre-computed) branch_name: config.branching_strategy === 'phase' && phaseInfo ? config.phase_branch_template + .replace('{project}', config.project_code || '') .replace('{phase}', phaseInfo.phase_number) .replace('{slug}', phaseInfo.phase_slug || 'phase') : config.branching_strategy === 'milestone' diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index cfb9d3669..15c7409ce 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -163,13 +163,22 @@ function cmdFindPhase(cwd, phase, raw) { const entries = fs.readdirSync(phasesDir, { 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)); + const match = dirs.find(d => { + if (d.startsWith(normalized)) return true; + if (d.toUpperCase().startsWith(normalized.toUpperCase())) return true; + // Strip optional project_code prefix (e.g., 'CK-01-name' → '01-name') and retry + const stripped = d.replace(/^[A-Z]{1,6}-/, ''); + if (stripped.startsWith(normalized)) return true; + return false; + }); if (!match) { output(notFound, raw, ''); return; } - const dirMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + // Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs + const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) + || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); const phaseNumber = dirMatch ? dirMatch[1] : normalized; const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; @@ -326,11 +335,15 @@ function cmdPhaseAdd(cwd, description, raw, customId) { let newPhaseId; let dirName; + // Optional project code prefix (e.g., 'CK' → 'CK-01-foundation') + const projectCode = config.project_code || ''; + const prefix = projectCode ? `${projectCode}-` : ''; + 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}`; + dirName = `${prefix}${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; @@ -343,7 +356,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) { newPhaseId = maxPhase + 1; const paddedNum = String(newPhaseId).padStart(2, '0'); - dirName = `${paddedNum}-${slug}`; + dirName = `${prefix}${paddedNum}-${slug}`; } const dirPath = path.join(planningDir(cwd), 'phases', dirName); @@ -410,7 +423,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { try { const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`); + const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${normalizedBase}\\.(\\d+)`); for (const dir of dirs) { const dm = dir.match(decimalPattern); if (dm) existingDecimals.push(parseInt(dm[1], 10)); @@ -419,7 +432,12 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) { const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1; const decimalPhase = `${normalizedBase}.${nextDecimal}`; - const dirName = `${decimalPhase}-${slug}`; + + // Optional project code prefix + const config = loadConfig(cwd); + const projectCode = config.project_code || ''; + const prefix = projectCode ? `${projectCode}-` : ''; + const dirName = `${prefix}${decimalPhase}-${slug}`; const dirPath = path.join(planningDir(cwd), 'phases', dirName); // Create directory with .gitkeep so git tracks empty folders diff --git a/get-shit-done/templates/config.json b/get-shit-done/templates/config.json index fd147b6f4..c629c22f4 100644 --- a/get-shit-done/templates/config.json +++ b/get-shit-done/templates/config.json @@ -40,5 +40,6 @@ "hooks": { "context_warnings": true }, + "project_code": null, "agent_skills": {} } diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index bbc0c004e..193d4b739 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -657,6 +657,92 @@ describe('phase add command', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// phase add with project_code prefix +// ───────────────────────────────────────────────────────────────────────────── + + +describe('phase add with project_code', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('prefixes phase directory with project_code', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: 'CK' }) + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n' + ); + + const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_number, 2, 'should be phase 2'); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-user-dashboard')), + 'directory should have CK- prefix' + ); + }); + + test('no prefix when project_code is null', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ project_code: null }) + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n' + ); + + const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-user-dashboard')), + 'directory should have no prefix' + ); + }); + + test('find-phase resolves prefixed directories', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + + const result = runGsdTools('find-phase 01', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.found, true, 'should find prefixed phase'); + assert.strictEqual(output.phase_number, '01', 'should extract numeric phase number'); + }); + + test('phases list sorts prefixed directories correctly', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-api'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-03-ui'), { recursive: true }); + + const result = runGsdTools('phases list', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.deepStrictEqual( + output.directories, + ['CK-01-foundation', 'CK-02-api', 'CK-03-ui'], + 'prefixed phases should sort numerically' + ); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // phase insert command // ─────────────────────────────────────────────────────────────────────────────