ktg-plugin-marketplace/plugins/ultraplan-local/lib/parsers/project-discovery.mjs
2026-05-01 16:43:08 +02:00

106 lines
3.6 KiB
JavaScript

// lib/parsers/project-discovery.mjs
// Discover ultra-suite artifacts inside a project directory.
//
// Layout (post-v3.0.0 project-directory contract):
// .claude/projects/<YYYY-MM-DD>-<slug>/
// brief.md
// research/<NN>-<slug>.md (sorted by filename)
// architecture/overview.md (opt-in, owned by separate ultra-cc-architect plugin)
// plan.md
// progress.json
import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
/**
* @typedef {{
* projectDir: string,
* brief: string|null,
* research: string[],
* architecture: { overview: string|null, gaps: string|null, looseFiles: string[] },
* plan: string|null,
* progress: string|null,
* review: string|null,
* }} ProjectArtifacts
*/
/** @returns {ProjectArtifacts} */
export function discoverProject(projectDir) {
const out = {
projectDir,
brief: null,
research: [],
architecture: { overview: null, gaps: null, looseFiles: [] },
plan: null,
progress: null,
review: null,
};
if (!projectDir || !existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
return out;
}
const briefPath = join(projectDir, 'brief.md');
if (existsSync(briefPath) && statSync(briefPath).isFile()) out.brief = briefPath;
const planPath = join(projectDir, 'plan.md');
if (existsSync(planPath) && statSync(planPath).isFile()) out.plan = planPath;
const progressPath = join(projectDir, 'progress.json');
if (existsSync(progressPath) && statSync(progressPath).isFile()) out.progress = progressPath;
const reviewPath = join(projectDir, 'review.md');
if (existsSync(reviewPath) && statSync(reviewPath).isFile()) out.review = reviewPath;
const researchDir = join(projectDir, 'research');
if (existsSync(researchDir) && statSync(researchDir).isDirectory()) {
out.research = readdirSync(researchDir)
.filter(f => f.endsWith('.md'))
.sort()
.map(f => join(researchDir, f));
}
const archDir = join(projectDir, 'architecture');
if (existsSync(archDir) && statSync(archDir).isDirectory()) {
const overviewPath = join(archDir, 'overview.md');
const gapsPath = join(archDir, 'gaps.md');
if (existsSync(overviewPath)) out.architecture.overview = overviewPath;
if (existsSync(gapsPath)) out.architecture.gaps = gapsPath;
const all = readdirSync(archDir).filter(f => f.endsWith('.md'));
out.architecture.looseFiles = all
.filter(f => f !== 'overview.md' && f !== 'gaps.md')
.map(f => join(archDir, f));
}
return out;
}
/**
* Validate that artifact set is consistent for a given pipeline phase.
* Phase = 'brief' | 'research' | 'plan' | 'execute' | 'review'.
*/
export function checkPhaseRequirements(artifacts, phase) {
const errors = [];
const warnings = [];
if (phase === 'research' && !artifacts.brief) {
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'research phase requires brief.md' });
}
if (phase === 'plan' && !artifacts.brief) {
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'plan phase requires brief.md' });
}
if (phase === 'execute' && !artifacts.plan) {
errors.push({ code: 'PROJECT_NO_PLAN', message: 'execute phase requires plan.md' });
}
if (phase === 'review') {
if (!artifacts.brief) {
errors.push({ code: 'PROJECT_NO_BRIEF', message: 'review phase requires brief.md' });
}
if (!artifacts.progress) {
warnings.push({
code: 'PROJECT_NO_PROGRESS',
message: 'review phase: progress.json absent — scope detection will fall back to brief.md mtime',
});
}
}
return { valid: errors.length === 0, errors, warnings, parsed: artifacts };
}