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); } }