feat: add project_code config for phase directory prefixing

When project_code is set (e.g., "CK"), phase directories are prefixed
with the code: CK-01-foundation, CK-02-api, etc. This disambiguates
phases across multiple GSD projects in the same session.

Changes:
- Add project_code to VALID_CONFIG_KEYS and buildNewProjectConfig defaults
- Add project_code to loadConfig in core.cjs
- Prepend prefix in cmdPhaseAdd and cmdPhaseInsert
- Update searchPhaseInDir, cmdFindPhase, comparePhaseNum, and
  normalizePhaseName to strip prefix before matching/sorting
- Support {project} placeholder in git.phase_branch_template
- Add 4 tests covering prefixed add, null code, find, and sort

Closes #1019

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Higgins
2026-03-26 08:43:05 -06:00
parent 604a78b30b
commit 1f34965717
6 changed files with 132 additions and 11 deletions

View File

@@ -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: {},
};

View File

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

View File

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

View File

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

View File

@@ -40,5 +40,6 @@
"hooks": {
"context_warnings": true
},
"project_code": null,
"agent_skills": {}
}

View File

@@ -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
// ─────────────────────────────────────────────────────────────────────────────