190 lines
7.3 KiB
TypeScript
190 lines
7.3 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { MarkdownFileWriter } from './markdownFileWriter';
|
|
import {
|
|
BugRecord,
|
|
ChronicleRecordEntry,
|
|
ChronicleWriteResult,
|
|
DecisionRecord,
|
|
DevelopmentLog,
|
|
DiscussionRecord,
|
|
PlanningDocument,
|
|
ProjectProfile,
|
|
RetrospectiveRecord
|
|
} from './types';
|
|
import {
|
|
renderBugRecord,
|
|
renderDecisionRecord,
|
|
renderDevelopmentLog,
|
|
renderDiscussionRecord,
|
|
renderPlanningDocument,
|
|
renderProjectProfile,
|
|
renderProjectReadme,
|
|
renderRetrospective,
|
|
renderTimelineSeed
|
|
} from './templates';
|
|
|
|
export * from './types';
|
|
export * from './guardPrompt';
|
|
|
|
const sectionDirs = ['planning', 'discussions', 'decisions', 'development', 'bugs', 'retrospectives'];
|
|
|
|
export class ProjectChronicleManager {
|
|
private readonly writer = new MarkdownFileWriter();
|
|
|
|
public ensureProject(profile: ProjectProfile): void {
|
|
if (!profile.recordRoot || !profile.recordRoot.trim()) {
|
|
throw new Error('Record root is required before writing chronicle documents.');
|
|
}
|
|
|
|
this.writer.ensureDir(profile.recordRoot);
|
|
for (const dir of sectionDirs) {
|
|
this.writer.ensureDir(path.join(profile.recordRoot, dir));
|
|
}
|
|
|
|
this.writeSeedFile(path.join(profile.recordRoot, 'README.md'), renderProjectReadme(profile));
|
|
this.writeSeedFile(path.join(profile.recordRoot, 'project-profile.md'), renderProjectProfile(profile));
|
|
this.writeSeedFile(path.join(profile.recordRoot, 'timeline.md'), renderTimelineSeed(profile));
|
|
this.writeProjectConfig(profile);
|
|
}
|
|
|
|
public writePlanning(profile: ProjectProfile, doc: PlanningDocument): ChronicleWriteResult {
|
|
this.ensureProject(profile);
|
|
const date = this.datePart(doc.createdAt);
|
|
const fileName = `${date}_${this.slug(doc.featureName)}.md`;
|
|
return this.write(profile, 'planning', fileName, renderPlanningDocument(doc));
|
|
}
|
|
|
|
public writeDiscussion(profile: ProjectProfile, record: DiscussionRecord): ChronicleWriteResult {
|
|
this.ensureProject(profile);
|
|
const date = this.datePart(record.createdAt);
|
|
const fileName = `${date}_${this.slug(record.title)}.md`;
|
|
return this.write(profile, 'discussions', fileName, renderDiscussionRecord(record));
|
|
}
|
|
|
|
public writeDecision(profile: ProjectProfile, record: DecisionRecord, adrNumber: number): ChronicleWriteResult {
|
|
this.ensureProject(profile);
|
|
const padded = String(adrNumber).padStart(4, '0');
|
|
const fileName = `ADR-${padded}-${this.slug(record.title)}.md`;
|
|
return this.write(profile, 'decisions', fileName, renderDecisionRecord(record));
|
|
}
|
|
|
|
public writeDevelopmentLog(profile: ProjectProfile, log: DevelopmentLog): ChronicleWriteResult {
|
|
this.ensureProject(profile);
|
|
const date = this.datePart(log.createdAt);
|
|
const fileName = `${date}_${this.slug(log.featureName)}_implementation.md`;
|
|
return this.write(profile, 'development', fileName, renderDevelopmentLog(log));
|
|
}
|
|
|
|
public writeBug(profile: ProjectProfile, record: BugRecord, bugNumber: number): ChronicleWriteResult {
|
|
this.ensureProject(profile);
|
|
const padded = String(bugNumber).padStart(4, '0');
|
|
const fileName = `BUG-${padded}-${this.slug(record.title)}.md`;
|
|
return this.write(profile, 'bugs', fileName, renderBugRecord(record));
|
|
}
|
|
|
|
public writeRetrospective(profile: ProjectProfile, record: RetrospectiveRecord): ChronicleWriteResult {
|
|
this.ensureProject(profile);
|
|
const date = this.datePart(record.createdAt);
|
|
const fileName = `${date}_${this.slug(record.title)}.md`;
|
|
return this.write(profile, 'retrospectives', fileName, renderRetrospective(record));
|
|
}
|
|
|
|
public appendTimeline(profile: ProjectProfile, lines: string[], createdAt: string = new Date().toISOString()): void {
|
|
this.ensureProject(profile);
|
|
const timelinePath = path.join(profile.recordRoot, 'timeline.md');
|
|
const markdown = [
|
|
'',
|
|
`## ${this.datePart(createdAt)}`,
|
|
...lines.map(line => `- ${line}`)
|
|
].join('\n');
|
|
this.writer.appendMarkdown(timelinePath, markdown);
|
|
}
|
|
|
|
public nextAdrNumber(profile: ProjectProfile): number {
|
|
return this.nextNumber(path.join(profile.recordRoot, 'decisions'), /^ADR-(\d+)/);
|
|
}
|
|
|
|
public nextBugNumber(profile: ProjectProfile): number {
|
|
return this.nextNumber(path.join(profile.recordRoot, 'bugs'), /^BUG-(\d+)/);
|
|
}
|
|
|
|
public listRecords(profile: ProjectProfile): ChronicleRecordEntry[] {
|
|
this.ensureProject(profile);
|
|
const records: ChronicleRecordEntry[] = [];
|
|
|
|
for (const section of sectionDirs) {
|
|
const sectionPath = path.join(profile.recordRoot, section);
|
|
if (!fs.existsSync(sectionPath)) continue;
|
|
|
|
for (const fileName of fs.readdirSync(sectionPath)) {
|
|
if (!fileName.endsWith('.md')) continue;
|
|
const filePath = path.join(sectionPath, fileName);
|
|
const stat = fs.statSync(filePath);
|
|
records.push({
|
|
section,
|
|
fileName,
|
|
filePath,
|
|
relativePath: path.relative(profile.recordRoot, filePath),
|
|
updatedAt: stat.mtimeMs
|
|
});
|
|
}
|
|
}
|
|
|
|
return records.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
}
|
|
|
|
private write(profile: ProjectProfile, section: string, fileName: string, markdown: string): ChronicleWriteResult {
|
|
const filePath = this.writer.writeMarkdown(path.join(profile.recordRoot, section, fileName), markdown);
|
|
return {
|
|
filePath,
|
|
relativePath: path.relative(profile.recordRoot, filePath)
|
|
};
|
|
}
|
|
|
|
private writeSeedFile(filePath: string, content: string): void {
|
|
if (!fs.existsSync(filePath)) {
|
|
this.writer.writeMarkdown(filePath, content);
|
|
}
|
|
}
|
|
|
|
private writeProjectConfig(profile: ProjectProfile): void {
|
|
const configPath = path.join(profile.recordRoot, 'chronicle.config.json');
|
|
const config = {
|
|
projectId: profile.projectId,
|
|
projectName: profile.projectName,
|
|
projectRoot: profile.projectRoot || '',
|
|
recordRoot: profile.recordRoot,
|
|
description: profile.description || '',
|
|
corePurpose: profile.corePurpose || '',
|
|
detailLevel: profile.detailLevel,
|
|
createdAt: profile.createdAt,
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
private nextNumber(dir: string, pattern: RegExp): number {
|
|
if (!fs.existsSync(dir)) return 1;
|
|
const max = fs.readdirSync(dir).reduce((current, fileName) => {
|
|
const match = fileName.match(pattern);
|
|
if (!match) return current;
|
|
return Math.max(current, Number(match[1]) || 0);
|
|
}, 0);
|
|
return max + 1;
|
|
}
|
|
|
|
private slug(value: string): string {
|
|
const slug = value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9가-힣]+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
.slice(0, 60);
|
|
return slug || 'record';
|
|
}
|
|
|
|
private datePart(iso?: string): string {
|
|
return (iso || new Date().toISOString()).slice(0, 10);
|
|
}
|
|
}
|