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:
@@ -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: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,5 +40,6 @@
|
||||
"hooks": {
|
||||
"context_warnings": true
|
||||
},
|
||||
"project_code": null,
|
||||
"agent_skills": {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user