Files
connectai/src/features/projectChronicle/index.ts
T

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