refactor(query): deepen phase lifecycle seams (#3267)

* refactor(query): extract phase lifecycle policy module

* refactor(query): extract phase fs and roadmap mutation adapters

* fix(sdk): propagate non-ENOENT readdir errors in phase-filesystem-adapter (CR finding 1)

Swallow only ENOENT in listDirectories; rethrow EACCES, EIO, and other
unexpected errors so callers surface real failures rather than silently
treating a permission-denied phases dir as empty.

Also adds regression test: EACCES from readdir now propagates as thrown
error instead of returning [].

* fix(sdk): propagate non-ENOENT readFile errors in phase-roadmap-mutation (CR finding 4)

readModifyWriteRoadmapMd now falls back to empty content only on ENOENT;
EACCES, EIO, and other errors are rethrown so a subsequent write cannot
clobber real roadmap content that is temporarily unreadable.

Regression tests: EACCES propagates; absent ROADMAP.md still starts empty.

* fix(sdk): omit Depends on: Phase 0 for first sequential phase; align prefix grammar (CR findings 2+3)

Finding 2: buildPhaseRoadmapEntry now omits the "Depends on" line when
phaseId == 1 (prevPhase would be 0, which is not a valid predecessor).
The guard is `prevPhase < 1` so future phase-0 configs are also safe.

Finding 3: collectDecimalSuffixesFromDirNames regex prefix pattern
updated from `[A-Z]{1,6}` to `[A-Z][A-Z0-9]*` (case-insensitive flag
added), matching the grammar used by scanSequentialMaxPhaseFromDirs.
Prevents k014 parity drift for alphanumeric project-code prefixes longer
than six characters or containing digits.

Regression tests for both fixes included.
This commit is contained in:
Tom Boucher
2026-05-09 00:14:59 -04:00
committed by GitHub
parent d8a93ad12d
commit 65abc4fc90
6 changed files with 481 additions and 326 deletions

View File

@@ -0,0 +1,35 @@
import { existsSync } from 'node:fs';
import { mkdir, readdir, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
export async function listDirectories(dirPath: string): Promise<string[]> {
if (!existsSync(dirPath)) return [];
try {
const entries = await readdir(dirPath, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
}
export async function ensureDirectoryWithGitkeep(dirPath: string): Promise<void> {
await mkdir(dirPath, { recursive: true });
await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8');
}
export async function archiveDirectories(
sourceDir: string,
archiveDir: string,
shouldArchive: (dirName: string) => boolean,
): Promise<number> {
await mkdir(archiveDir, { recursive: true });
const sourceDirs = await listDirectories(sourceDir);
let archivedCount = 0;
for (const dirName of sourceDirs) {
if (!shouldArchive(dirName)) continue;
await rename(join(sourceDir, dirName), join(archiveDir, dirName));
archivedCount++;
}
return archivedCount;
}

View File

@@ -0,0 +1,171 @@
import { GSDError, ErrorClassification } from '../errors.js';
import { escapeRegex } from './helpers.js';
export interface PhaseDirectoryComputation {
phaseId: number | string;
dirName: string;
}
export interface NextDecimalPhaseResult {
next: string;
existing: string[];
}
/** Reject strings containing null bytes (path traversal defense). */
export function assertNoNullBytes(value: string, label: string): void {
if (value.includes('\0')) {
throw new GSDError(`${label} contains null byte`, ErrorClassification.Validation);
}
}
/** Reject `..` or path separators in phase directory names. */
export function assertSafePhaseDirName(dirName: string, label = 'phase directory'): void {
if (/[/\\]|\.\./.test(dirName)) {
throw new GSDError(`${label} contains invalid path segments`, ErrorClassification.Validation);
}
}
export function assertSafeProjectCode(code: string): void {
if (code && /[/\\]|\.\./.test(code)) {
throw new GSDError('project_code contains invalid characters', ErrorClassification.Validation);
}
}
/** Generate kebab-case slug from description. */
export function generatePhaseSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 60);
}
export function parseMultiwordArg(args: string[], flag: string): string | null {
const idx = args.indexOf(`--${flag}`);
if (idx === -1) return null;
const tokens: string[] = [];
for (let i = idx + 1; i < args.length; i++) {
if (args[i]!.startsWith('--')) break;
tokens.push(args[i]!);
}
return tokens.length > 0 ? tokens.join(' ') : null;
}
export function extractOneLinerFromBody(content: string): string | null {
if (!content) return null;
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
return match ? match[1]!.trim() : null;
}
/**
* Scan highest sequential phase number in milestone content.
* Skips backlog lanes (`999.x`).
*/
export function scanSequentialMaxPhaseFromMilestone(milestoneContent: string): number {
const phasePattern = /(?:^|\n)\s*(?:[-*]\s*(?:\[[x ]\]\s*)?|#{2,4}\s*|\*{1,2}\s*)Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let maxPhase = 0;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(milestoneContent)) !== null) {
const num = parseInt(m[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
return maxPhase;
}
/**
* Scan highest sequential phase number from phase directory names.
* Supports optional project-code prefix and optional decimal suffixes.
*/
export function scanSequentialMaxPhaseFromDirs(dirNames: string[]): number {
let maxPhase = 0;
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)[A-Z]?(?:\.\d+)*-/i;
for (const dirName of dirNames) {
const match = dirNumPattern.exec(dirName);
if (!match) continue;
const num = parseInt(match[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
return maxPhase;
}
export function computeNextSequentialPhaseId(milestoneContent: string, dirNames: string[]): number {
return Math.max(
scanSequentialMaxPhaseFromMilestone(milestoneContent),
scanSequentialMaxPhaseFromDirs(dirNames),
) + 1;
}
export function computePhaseDirectory(
namingMode: unknown,
descriptionSlug: string,
prefix: string,
nextSequentialPhaseId: number,
customId?: string | null,
): PhaseDirectoryComputation {
if (customId || namingMode === 'custom') {
const phaseId = customId || descriptionSlug.toUpperCase().replace(/-/g, '_');
if (!phaseId) {
throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation);
}
assertSafePhaseDirName(String(phaseId), 'custom phase id');
const dirName = `${prefix}${phaseId}-${descriptionSlug}`;
assertSafePhaseDirName(dirName);
return { phaseId, dirName };
}
const phaseId = nextSequentialPhaseId;
const paddedNum = String(phaseId).padStart(2, '0');
const dirName = `${prefix}${paddedNum}-${descriptionSlug}`;
assertSafePhaseDirName(dirName);
return { phaseId, dirName };
}
export function buildPhaseRoadmapEntry(
phaseId: number | string,
description: string,
namingMode: unknown,
): string {
const prevPhase = typeof phaseId === 'number' ? phaseId - 1 : null;
const dependsOn = namingMode === 'custom' || prevPhase === null || prevPhase < 1
? ''
: `\n**Depends on:** Phase ${prevPhase}`;
return `\n### Phase ${phaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${phaseId} to break down)\n`;
}
export function collectDecimalSuffixesFromDirNames(basePhase: string, dirNames: string[]): Set<number> {
const decimalSet = new Set<number>();
const decimalPattern = new RegExp(`^(?:[A-Z][A-Z0-9]*-)?${escapeRegex(basePhase)}\\.(\\d+)`, 'i');
for (const dir of dirNames) {
const match = dir.match(decimalPattern);
if (match) decimalSet.add(parseInt(match[1], 10));
}
return decimalSet;
}
export function collectDecimalSuffixesFromRoadmap(basePhase: string, roadmapContent: string): Set<number> {
const decimalSet = new Set<number>();
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+0*${escapeRegex(basePhase)}\\.(\\d+)\\s*:`,
'gi',
);
let match: RegExpExecArray | null;
while ((match = phasePattern.exec(roadmapContent)) !== null) {
decimalSet.add(parseInt(match[1], 10));
}
return decimalSet;
}
export function computeNextDecimalPhase(basePhase: string, decimalSet: Set<number>): NextDecimalPhaseResult {
const existing = Array.from(decimalSet)
.sort((a, b) => a - b)
.map((n) => `${basePhase}.${n}`);
const next = decimalSet.size === 0
? `${basePhase}.1`
: `${basePhase}.${Math.max(...decimalSet) + 1}`;
return { next, existing };
}

View File

@@ -1600,3 +1600,122 @@ describe('lifecycle handlers in registry', () => {
}
});
});
// ─── CR-3267 regression: error-propagation in listDirectories ─────────────
describe('listDirectories — CR-3267 finding 1: non-ENOENT errors propagate', () => {
it('propagates EACCES from readdir instead of returning []', async () => {
const { listDirectories } = await import('./phase-filesystem-adapter.js');
// Create a real directory then remove read permission
const dir = await mkdtemp(join(tmpdir(), 'gsd-fs-acl-'));
const inner = join(dir, 'phases');
await mkdir(inner);
try {
await import('node:fs/promises').then(m => m.chmod(inner, 0o000));
await expect(listDirectories(inner)).rejects.toThrow();
} finally {
// Restore so cleanup can delete
await import('node:fs/promises').then(m => m.chmod(inner, 0o755));
await rm(dir, { recursive: true, force: true });
}
});
it('returns [] for ENOENT (directory gone between existsSync and readdir)', async () => {
// existsSync passes, but the directory has been removed before readdir —
// the ENOENT branch must still return [].
const { listDirectories } = await import('./phase-filesystem-adapter.js');
// We can't easily race the real FS, but we can verify the function tolerates
// a path that truly does not exist (existsSync returns false → early []).
const nonExistent = join(tmpdir(), 'gsd-does-not-exist-' + Date.now());
const result = await listDirectories(nonExistent);
expect(result).toEqual([]);
});
});
// ─── CR-3267 regression: error-propagation in readModifyWriteRoadmapMd ─────
describe('readModifyWriteRoadmapMd — CR-3267 finding 4: non-ENOENT errors propagate', () => {
it('propagates EACCES on ROADMAP.md readFile instead of treating as empty', async () => {
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
const dir = await mkdtemp(join(tmpdir(), 'gsd-roadmap-acl-'));
const planningDir = join(dir, '.planning');
await mkdir(planningDir, { recursive: true });
const roadmapPath = join(planningDir, 'ROADMAP.md');
await writeFile(roadmapPath, '# Roadmap\n', 'utf-8');
try {
await import('node:fs/promises').then(m => m.chmod(roadmapPath, 0o000));
await expect(
readModifyWriteRoadmapMd(dir, (c) => c)
).rejects.toThrow();
} finally {
await import('node:fs/promises').then(m => m.chmod(roadmapPath, 0o644));
await rm(dir, { recursive: true, force: true });
}
});
it('starts with empty content when ROADMAP.md is absent (ENOENT)', async () => {
const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js');
const dir = await mkdtemp(join(tmpdir(), 'gsd-roadmap-noent-'));
const planningDir = join(dir, '.planning');
await mkdir(planningDir, { recursive: true });
// No ROADMAP.md written — must default to '' and create it
try {
const result = await readModifyWriteRoadmapMd(dir, (c) => c + 'NEW');
expect(result).toBe('NEW');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
// ─── CR-3267 regression: buildPhaseRoadmapEntry — no "Phase 0" dependency ──
describe('buildPhaseRoadmapEntry — CR-3267 finding 2: first sequential phase has no predecessor', () => {
it('omits Depends on line when phaseId is 1', async () => {
const { buildPhaseRoadmapEntry } = await import('./phase-lifecycle-policy.js');
const entry = buildPhaseRoadmapEntry(1, 'Bootstrap', 'sequential');
expect(entry).not.toContain('Depends on');
expect(entry).not.toContain('Phase 0');
});
it('includes Depends on line when phaseId is 2', async () => {
const { buildPhaseRoadmapEntry } = await import('./phase-lifecycle-policy.js');
const entry = buildPhaseRoadmapEntry(2, 'Second Phase', 'sequential');
expect(entry).toContain('**Depends on:** Phase 1');
});
it('omits Depends on line for custom naming mode regardless of id', async () => {
const { buildPhaseRoadmapEntry } = await import('./phase-lifecycle-policy.js');
const entry = buildPhaseRoadmapEntry('ALPHA', 'Custom', 'custom');
expect(entry).not.toContain('Depends on');
});
});
// ─── CR-3267 regression: collectDecimalSuffixesFromDirNames prefix grammar ─
describe('collectDecimalSuffixesFromDirNames — CR-3267 finding 3: alphanumeric prefixes accepted', () => {
it('matches directories with long alphanumeric project-code prefix', async () => {
const { collectDecimalSuffixesFromDirNames } = await import('./phase-lifecycle-policy.js');
// Prefix "MYAPP01" is longer than 6 chars and contains digits — was rejected before fix
const dirs = ['MYAPP01-3.1-some-work', 'MYAPP01-3.2-other-work', 'unrelated-dir'];
const result = collectDecimalSuffixesFromDirNames('3', dirs);
expect(result.has(1)).toBe(true);
expect(result.has(2)).toBe(true);
});
it('still matches directories with short uppercase-only prefix', async () => {
const { collectDecimalSuffixesFromDirNames } = await import('./phase-lifecycle-policy.js');
const dirs = ['AB-5.1-task', 'AB-5.3-other'];
const result = collectDecimalSuffixesFromDirNames('5', dirs);
expect(result.has(1)).toBe(true);
expect(result.has(3)).toBe(true);
});
it('matches directories with no prefix', async () => {
const { collectDecimalSuffixesFromDirNames } = await import('./phase-lifecycle-policy.js');
const dirs = ['3.1-plain', '3.2-also-plain'];
const result = collectDecimalSuffixesFromDirNames('3', dirs);
expect(result.has(1)).toBe(true);
expect(result.has(2)).toBe(true);
});
});

View File

@@ -42,143 +42,31 @@ import {
stateReplaceField,
} from './state-mutation.js';
import type { QueryHandler } from './utils.js';
import {
assertNoNullBytes,
assertSafePhaseDirName,
assertSafeProjectCode,
buildPhaseRoadmapEntry,
collectDecimalSuffixesFromDirNames,
collectDecimalSuffixesFromRoadmap,
computeNextDecimalPhase,
computeNextSequentialPhaseId,
computePhaseDirectory,
extractOneLinerFromBody,
generatePhaseSlug,
parseMultiwordArg,
} from './phase-lifecycle-policy.js';
import {
archiveDirectories,
ensureDirectoryWithGitkeep,
listDirectories,
} from './phase-filesystem-adapter.js';
import {
readModifyWriteRoadmapMd,
replaceInCurrentMilestone,
} from './phase-roadmap-mutation.js';
// ─── Null byte validation ────────────────────────────────────────────────
/** Reject strings containing null bytes (path traversal defense). */
function assertNoNullBytes(value: string, label: string): void {
if (value.includes('\0')) {
throw new GSDError(`${label} contains null byte`, ErrorClassification.Validation);
}
}
/** Reject `..` or path separators in phase directory names. */
function assertSafePhaseDirName(dirName: string, label = 'phase directory'): void {
if (/[/\\]|\.\./.test(dirName)) {
throw new GSDError(`${label} contains invalid path segments`, ErrorClassification.Validation);
}
}
function assertSafeProjectCode(code: string): void {
if (code && /[/\\]|\.\./.test(code)) {
throw new GSDError('project_code contains invalid characters', ErrorClassification.Validation);
}
}
// ─── Slug generation (inline) ────────────────────────────────────────────
/** Generate kebab-case slug from description. Port of generateSlugInternal. */
function generateSlugInternal(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 60);
}
// ─── replaceInCurrentMilestone ──────────────────────────────────────────
/**
* Replace a pattern only in the current milestone section of ROADMAP.md.
*
* Port of replaceInCurrentMilestone from core.cjs line 1197-1206.
* If no `</details>` blocks exist, replaces in the entire content.
* Otherwise, only replaces in content after the last `</details>` close tag.
*
* Edge case: when the active milestone is itself wrapped in a `<details>` block
* (e.g. collapsed before it is fully shipped), the last `</details>` belongs to
* the active milestone and the `after` slice is empty. In that case the function
* falls back to searching the full content with all complete `<details>` blocks
* stripped, so archived milestones are never touched.
*
* @param content - Full ROADMAP.md content
* @param pattern - Regex or string pattern to match
* @param replacement - Replacement string
* @returns Modified content
*/
export function replaceInCurrentMilestone(
content: string,
pattern: string | RegExp,
replacement: string,
): string {
const lastDetailsClose = content.lastIndexOf('</details>');
if (lastDetailsClose === -1) {
return content.replace(pattern, replacement);
}
const offset = lastDetailsClose + '</details>'.length;
const before = content.slice(0, offset);
const after = content.slice(offset);
// Fast path: the current milestone is not inside a <details> block — the
// pattern lives in the plain text after the last </details>.
const replacedAfter = after.replace(pattern, replacement);
if (replacedAfter !== after) {
return before + replacedAfter;
}
// Slow path: the active milestone is inside the last <details> block.
// Strip every complete <details>…</details> block except the last one, then
// apply the replacement inside that last block while leaving the stripped
// (archived) blocks untouched.
//
// Strategy:
// 1. Collect all complete <details>…</details> spans.
// 2. Replace only inside the LAST span; leave earlier spans unchanged.
const detailsBlockRe = /<details>[\s\S]*?<\/details>/gi;
const spans: { start: number; end: number; text: string }[] = [];
let m: RegExpExecArray | null;
while ((m = detailsBlockRe.exec(content)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
}
if (spans.length === 0) {
// No complete blocks found — fall back to full-content replace.
return content.replace(pattern, replacement);
}
const lastSpan = spans[spans.length - 1];
const updatedLastBlock = lastSpan.text.replace(pattern, replacement);
return (
content.slice(0, lastSpan.start) +
updatedLastBlock +
content.slice(lastSpan.end)
);
}
// ─── readModifyWriteRoadmapMd ───────────────────────────────────────────
/**
* Atomic read-modify-write for ROADMAP.md.
*
* Holds a lockfile across the entire read -> transform -> write cycle.
* Uses the same acquireStateLock/releaseStateLock mechanism as STATE.md
* but with a ROADMAP.md-specific lock path.
*
* @param projectDir - Project root directory
* @param modifier - Function to transform ROADMAP.md content
* @returns The final written content
*/
export async function readModifyWriteRoadmapMd(
projectDir: string,
modifier: (content: string) => string | Promise<string>,
workstream?: string,
): Promise<string> {
const roadmapPath = planningPaths(projectDir, workstream).roadmap;
const lockPath = await acquireStateLock(roadmapPath);
try {
let content: string;
try {
content = await readFile(roadmapPath, 'utf-8');
} catch {
content = '';
}
const modified = await modifier(content);
await writeFile(roadmapPath, modified, 'utf-8');
return modified;
} finally {
await releaseStateLock(lockPath);
}
}
export { readModifyWriteRoadmapMd, replaceInCurrentMilestone };
// ─── phaseAdd handler ───────────────────────────────────────────────────
@@ -230,7 +118,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
config = JSON.parse(await readFile(configPath, 'utf-8'));
} catch { /* use defaults */ }
const slug = generateSlugInternal(description);
const slug = generatePhaseSlug(description);
// positional[1] is the optional customId — flags are already stripped
const customId = positional[1] || null;
@@ -245,55 +133,17 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
// there is no race condition to guard against).
const computePhaseFields = async (rawRoadmapContent: string) => {
const milestoneContent = await extractCurrentMilestone(rawRoadmapContent, projectDir);
const phasesDir = planningPaths(projectDir, workstream).phases;
const dirNames = await listDirectories(phasesDir);
let resolvedPhaseId: number | string = '';
let resolvedDirName = '';
if (customId || config.phase_naming === 'custom') {
// Custom phase naming
resolvedPhaseId = customId || slug.toUpperCase().replace(/-/g, '_');
if (!resolvedPhaseId) {
throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation);
}
assertSafePhaseDirName(String(resolvedPhaseId), 'custom phase id');
resolvedDirName = `${prefix}${resolvedPhaseId}-${slug}`;
} else {
// Sequential mode: find highest integer phase number (in current milestone only)
// Skip 999.x backlog phases — they live outside the active sequence
// Matches heading (## Phase N:), bullet checklist (- [x] Phase N:), and bold (**Phase N:**)
const phasePattern = /(?:^|\n)\s*(?:[-*]\s*(?:\[[x ]\]\s*)?|#{2,4}\s*|\*{1,2}\s*)Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let maxPhase = 0;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(milestoneContent)) !== null) {
const num = parseInt(m[1], 10);
if (num >= 999) continue; // backlog phases use 999.x numbering
if (num > maxPhase) maxPhase = num;
}
// Also scan on-disk phase directories (union semantics) — ROADMAP and disk
// may diverge temporarily (e.g. a previous run created the dir but didn't
// update ROADMAP). Taking the max of both sources prevents collisions.
const phasesDir = planningPaths(projectDir, workstream).phases;
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirMatch = /^(?:[A-Z][A-Z0-9]*-)?(\d+)[A-Z]?(?:\.\d+)*-/i.exec(entry.name);
if (!dirMatch) continue;
const num = parseInt(dirMatch[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
} catch {
// phases dir may not exist yet — leave maxPhase as 0
}
resolvedPhaseId = maxPhase + 1;
const paddedNum = String(resolvedPhaseId).padStart(2, '0');
resolvedDirName = `${prefix}${paddedNum}-${slug}`;
}
assertSafePhaseDirName(resolvedDirName);
const nextSequentialPhaseId = computeNextSequentialPhaseId(milestoneContent, dirNames);
const { phaseId: resolvedPhaseId, dirName: resolvedDirName } = computePhaseDirectory(
config.phase_naming,
slug,
prefix,
nextSequentialPhaseId,
customId || undefined,
);
if (!resolvedDirName) {
throw new GSDError('Phase directory name was not computed', ErrorClassification.Execution);
@@ -302,10 +152,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
throw new GSDError('Phase ID was not computed', ErrorClassification.Execution);
}
const dependsOn = config.phase_naming === 'custom'
? ''
: `\n**Depends on:** Phase ${typeof resolvedPhaseId === 'number' ? resolvedPhaseId - 1 : 'TBD'}`;
const resolvedEntry = `\n### Phase ${resolvedPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${resolvedPhaseId} to break down)\n`;
const resolvedEntry = buildPhaseRoadmapEntry(resolvedPhaseId, description, config.phase_naming);
return { resolvedPhaseId, resolvedDirName, resolvedEntry };
};
@@ -339,8 +186,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => {
const dirPath = join(planningPaths(projectDir, workstream).phases, dirName);
// Create directory with .gitkeep so git tracks empty folders
await mkdir(dirPath, { recursive: true });
await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8');
await ensureDirectoryWithGitkeep(dirPath);
// Find insertion point: before last "---" or at end
const lastSeparator = roadmapRaw.lastIndexOf('\n---');
@@ -436,31 +282,13 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream)
let maxPhase = 0;
if (config.phase_naming !== 'custom') {
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
const phasesOnDisk = planningPaths(projectDir, workstream).phases;
if (existsSync(phasesOnDisk)) {
const entries = await readdir(phasesOnDisk, { withFileTypes: true });
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = entry.name.match(dirNumPattern);
if (!match) continue;
const num = parseInt(match[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
}
const dirNames = await listDirectories(phasesOnDisk);
maxPhase = computeNextSequentialPhaseId(content, dirNames) - 1;
}
for (const description of descriptions) {
const slug = generateSlugInternal(description);
const slug = generatePhaseSlug(description);
let newPhaseId: number | string;
let dirName: string;
@@ -476,14 +304,10 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream)
assertSafePhaseDirName(dirName);
const dirPath = join(planningPaths(projectDir, workstream).phases, dirName);
await mkdir(dirPath, { recursive: true });
await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8');
await ensureDirectoryWithGitkeep(dirPath);
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`;
const phaseEntry =
buildPhaseRoadmapEntry(newPhaseId, description, config.phase_naming);
const lastSeparator = rawContent.lastIndexOf('\n---');
rawContent =
@@ -530,7 +354,7 @@ export const phaseInsert: QueryHandler = async (args, projectDir, workstream) =>
assertNoNullBytes(afterPhase, 'afterPhase');
assertNoNullBytes(description, 'description');
const slug = generateSlugInternal(description);
const slug = generatePhaseSlug(description);
let decimalPhase = '';
let dirName = '';
@@ -552,26 +376,17 @@ export const phaseInsert: QueryHandler = async (args, projectDir, workstream) =>
const decimalSet = new Set<number>();
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`);
for (const dir of dirs) {
const dm = dir.match(decimalPattern);
if (dm) decimalSet.add(parseInt(dm[1], 10));
const dirs = await listDirectories(phasesDir);
for (const suffix of collectDecimalSuffixesFromDirNames(normalizedBase, dirs)) {
decimalSet.add(suffix);
}
} catch { /* intentionally empty */ }
// Also scan ROADMAP.md content for decimal entries
const rmPhasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+0*${escapeRegex(normalizedBase)}\\.(\\d+)\\s*:`, 'gi'
);
let rmMatch: RegExpExecArray | null;
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
decimalSet.add(parseInt(rmMatch[1], 10));
for (const suffix of collectDecimalSuffixesFromRoadmap(normalizedBase, rawContent)) {
decimalSet.add(suffix);
}
const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1;
decimalPhase = `${normalizedBase}.${nextDecimal}`;
decimalPhase = computeNextDecimalPhase(normalizedBase, decimalSet).next;
// Optional project code prefix
let insertConfig: Record<string, unknown> = {};
@@ -586,8 +401,7 @@ export const phaseInsert: QueryHandler = async (args, projectDir, workstream) =>
const dirPath = join(phasesDir, dirName);
// Create directory with .gitkeep
await mkdir(dirPath, { recursive: true });
await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8');
await ensureDirectoryWithGitkeep(dirPath);
// Build phase entry
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${decimalPhase} to break down)\n`;
@@ -645,25 +459,19 @@ async function findPhaseDir(
): Promise<{ dirPath: string; dirName: string; phaseName: string | null } | null> {
const phasesDir = planningPaths(projectDir, workstream).phases;
const normalized = normalizePhaseName(phase);
const dirs = await listDirectories(phasesDir);
const match = dirs.find((d) => phaseTokenMatches(d, normalized));
if (!match) return null;
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) return null;
// Extract phase name from directory
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)?\d+[A-Z]?(?:\.\d+)*-(.+)/i);
const phaseName = dirMatch ? dirMatch[1] : null;
// Extract phase name from directory
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)?\d+[A-Z]?(?:\.\d+)*-(.+)/i);
const phaseName = dirMatch ? dirMatch[1] : null;
return {
dirPath: join(phasesDir, match),
dirName: match,
phaseName,
};
} catch {
return null;
}
return {
dirPath: join(phasesDir, match),
dirName: match,
phaseName,
};
}
/**
@@ -730,14 +538,13 @@ export const phaseScaffold: QueryHandler = async (args, projectDir, workstream)
if (!phase || !name) {
throw new GSDError('phase and name required for phase-dir scaffold', ErrorClassification.Validation);
}
const slug = generateSlugInternal(name);
const slug = generatePhaseSlug(name);
const dirNameNew = `${padded}-${slug}`;
assertSafePhaseDirName(dirNameNew, 'scaffold phase directory');
const phasesParent = planningPaths(projectDir, workstream).phases;
await mkdir(phasesParent, { recursive: true });
const dirPath = join(phasesParent, dirNameNew);
await mkdir(dirPath, { recursive: true });
await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8');
await ensureDirectoryWithGitkeep(dirPath);
return {
data: {
created: true,
@@ -1672,39 +1479,23 @@ export const phaseNextDecimal: QueryHandler = async (args, projectDir, workstrea
const decimalSet = new Set<number>();
let baseExists = false;
if (existsSync(phasesDir)) {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
baseExists = dirNames.some(d => phaseTokenMatches(d, normalized));
const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
for (const dir of dirNames) {
const match = dir.match(dirPattern);
if (match) decimalSet.add(parseInt(match[1], 10));
}
const dirNames = await listDirectories(phasesDir);
baseExists = dirNames.some((d) => phaseTokenMatches(d, normalized));
for (const suffix of collectDecimalSuffixesFromDirNames(normalized, dirNames)) {
decimalSet.add(suffix);
}
const roadmapPath = paths.roadmap;
if (existsSync(roadmapPath)) {
try {
const roadmapContent = await readFile(roadmapPath, 'utf-8');
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+0*${escapeRegex(normalized)}\\.(\\d+)\\s*:`, 'gi',
);
let pm;
while ((pm = phasePattern.exec(roadmapContent)) !== null) {
decimalSet.add(parseInt(pm[1], 10));
for (const suffix of collectDecimalSuffixesFromRoadmap(normalized, roadmapContent)) {
decimalSet.add(suffix);
}
} catch { /* ROADMAP.md read failure is non-fatal */ }
}
const existingDecimals = Array.from(decimalSet)
.sort((a, b) => a - b)
.map(n => `${normalized}.${n}`);
const nextDecimal = decimalSet.size === 0
? `${normalized}.1`
: `${normalized}.${Math.max(...decimalSet) + 1}`;
const { next: nextDecimal, existing: existingDecimals } = computeNextDecimalPhase(normalized, decimalSet);
return {
data: {
@@ -1728,19 +1519,7 @@ export const phasesArchive: QueryHandler = async (args, projectDir, workstream)
const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream);
const archiveDir = join(paths.planning, 'milestones', `${version}-phases`);
await mkdir(archiveDir, { recursive: true });
let archivedCount = 0;
if (existsSync(phasesDir)) {
const entries = await readdir(phasesDir, { withFileTypes: true });
const phaseDirNames = entries.filter(e => e.isDirectory()).map(e => e.name);
for (const dir of phaseDirNames) {
if (!isDirInMilestone(dir)) continue;
await rename(join(phasesDir, dir), join(archiveDir, dir));
archivedCount++;
}
}
const archivedCount = await archiveDirectories(phasesDir, archiveDir, (dirName) => isDirInMilestone(dirName));
return {
data: {
@@ -1753,26 +1532,6 @@ export const phasesArchive: QueryHandler = async (args, projectDir, workstream)
// ─── milestoneComplete ────────────────────────────────────────────────────
/** Port of `parseMultiwordArg` in `gsd-tools.cjs`. */
function parseMultiwordArg(args: string[], flag: string): string | null {
const idx = args.indexOf(`--${flag}`);
if (idx === -1) return null;
const tokens: string[] = [];
for (let i = idx + 1; i < args.length; i++) {
if (args[i]!.startsWith('--')) break;
tokens.push(args[i]!);
}
return tokens.length > 0 ? tokens.join(' ') : null;
}
/** Port of `extractOneLinerFromBody` from `core.cjs` / `summary.ts`. */
function extractOneLinerFromBody(content: string): string | null {
if (!content) return null;
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
return match ? match[1]!.trim() : null;
}
/**
* Query handler for `milestone.complete` — port of `cmdMilestoneComplete` from `milestone.cjs`.
*/
@@ -1814,8 +1573,7 @@ export const milestoneComplete: QueryHandler = async (args, projectDir, workstre
const accomplishments: string[] = [];
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
const dirs = (await listDirectories(phasesDir)).sort();
for (const dir of dirs) {
if (!isDirInMilestone(dir)) continue;
@@ -1941,16 +1699,11 @@ export const milestoneComplete: QueryHandler = async (args, projectDir, workstre
if (archivePhases) {
try {
const phaseArchiveDir = join(archiveDir, `${version}-phases`);
await mkdir(phaseArchiveDir, { recursive: true });
const phaseEntries = await readdir(phasesDir, { withFileTypes: true });
const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
let archivedCount = 0;
for (const dir of phaseDirNames) {
if (!isDirInMilestone(dir)) continue;
await rename(join(phasesDir, dir), join(phaseArchiveDir, dir));
archivedCount++;
}
const archivedCount = await archiveDirectories(
phasesDir,
phaseArchiveDir,
(dirName) => isDirInMilestone(dirName),
);
phasesArchived = archivedCount > 0;
} catch {
/* intentionally empty */

View File

@@ -0,0 +1,77 @@
import { readFile, writeFile } from 'node:fs/promises';
import { planningPaths } from './helpers.js';
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
/**
* Replace a pattern only in the current milestone section of ROADMAP.md.
*
* Port of replaceInCurrentMilestone from core.cjs line 1197-1206.
*/
export function replaceInCurrentMilestone(
content: string,
pattern: string | RegExp,
replacement: string,
): string {
const lastDetailsClose = content.lastIndexOf('</details>');
if (lastDetailsClose === -1) {
return content.replace(pattern, replacement);
}
const offset = lastDetailsClose + '</details>'.length;
const before = content.slice(0, offset);
const after = content.slice(offset);
const replacedAfter = after.replace(pattern, replacement);
if (replacedAfter !== after) {
return before + replacedAfter;
}
const detailsBlockRe = /<details>[\s\S]*?<\/details>/gi;
const spans: { start: number; end: number; text: string }[] = [];
let m: RegExpExecArray | null;
while ((m = detailsBlockRe.exec(content)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
}
if (spans.length === 0) {
return content.replace(pattern, replacement);
}
const lastSpan = spans[spans.length - 1];
const updatedLastBlock = lastSpan.text.replace(pattern, replacement);
return (
content.slice(0, lastSpan.start) +
updatedLastBlock +
content.slice(lastSpan.end)
);
}
/**
* Atomic read-modify-write for ROADMAP.md.
*
* Holds a lockfile across the entire read -> transform -> write cycle.
*/
export async function readModifyWriteRoadmapMd(
projectDir: string,
modifier: (content: string) => string | Promise<string>,
workstream?: string,
): Promise<string> {
const roadmapPath = planningPaths(projectDir, workstream).roadmap;
const lockPath = await acquireStateLock(roadmapPath);
try {
let content: string;
try {
content = await readFile(roadmapPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
content = '';
} else {
throw err;
}
}
const modified = await modifier(content);
await writeFile(roadmapPath, modified, 'utf-8');
return modified;
} finally {
await releaseStateLock(lockPath);
}
}

View File

@@ -8,7 +8,7 @@
*/
import { findPhase } from './phase.js';
import { readModifyWriteRoadmapMd, replaceInCurrentMilestone } from './phase-lifecycle.js';
import { readModifyWriteRoadmapMd, replaceInCurrentMilestone } from './phase-roadmap-mutation.js';
import { existsSync } from 'node:fs';
import { escapeRegex, planningPaths } from './helpers.js';
import { GSDError, ErrorClassification } from '../errors.js';