import { readFile, writeFile } from "node:fs/promises"; import { resolve, basename } from "node:path"; import type { StoryStatus, ValidationResult, SDDConfig, ChangeRequest, Bug, CompactMode, CompactResult, PreflightResult } from "./types.js"; import { ProjectNotInitializedError } from "./errors.js"; import { parseAllStoryFiles } from "./parser/story-parser.js"; import { generatePrompt } from "./prompt/prompt-generator.js"; import { generateDraftEnrichmentPrompt } from "./prompt/draft-prompt-generator.js"; import { validate } from "./validate/validator.js"; import { initProject } from "./scaffold/init.js"; import { isSDDProject, readConfig, writeConfig } from "./config/config-manager.js"; import { parseAllCRFiles } from "./parser/cr-parser.js"; import { parseAllBugFiles } from "./parser/bug-parser.js"; import type { ProjectInfo } from "./scaffold/templates.js"; import { listSupportedAdapters, syncSkillAdapters, type SyncAdaptersOptions, type SyncAdaptersResult, } from "./scaffold/skill-adapters.js"; import { pushToRemote, pullFromRemote, pullCRsFromRemote, pullBugsFromRemote, getRemoteStatus, resetRemoteProject, } from "./remote/sync-engine.js"; import type { PushResult, PullResult, PullEntitiesResult, RemoteStatusResult, RemoteResetResult } from "./remote/types.js"; export class SDD { private root: string; constructor(options: { root: string }) { this.root = options.root; } async init(info?: ProjectInfo): Promise { return initProject(this.root, info); } async syncAdapters(options?: SyncAdaptersOptions): Promise { this.ensureInitialized(); return syncSkillAdapters(this.root, options); } supportedAdapters(): string[] { return listSupportedAdapters(); } async config(): Promise { this.ensureInitialized(); return readConfig(this.root); } async status(): Promise { this.ensureInitialized(); const files = await parseAllStoryFiles(this.root); return { files: files.map((f) => ({ relativePath: f.relativePath, status: f.frontmatter.status, version: f.frontmatter.version, lastModified: f.frontmatter["last-modified"], })), }; } async pending(): Promise { this.ensureInitialized(); const files = await parseAllStoryFiles(this.root); return files.filter((f) => f.frontmatter.status !== "synced" && f.frontmatter.status !== "draft"); } async sync(): Promise { const pending = await this.pending(); return generatePrompt(pending, this.root); } async validate(): Promise { this.ensureInitialized(); const files = await parseAllStoryFiles(this.root); return validate(files); } async markSynced(paths?: string[]): Promise { this.ensureInitialized(); const files = await parseAllStoryFiles(this.root); const marked: string[] = []; for (const file of files) { const { status } = file.frontmatter; if (status === "synced") continue; if (paths && paths.length > 0 && !paths.includes(file.relativePath)) continue; const absPath = resolve(this.root, file.relativePath); if (status === "deleted") { // File marked for deletion — remove it const { unlink } = await import("node:fs/promises"); await unlink(absPath); marked.push(`${file.relativePath} (removed)`); } else { // new or changed → synced const content = await readFile(absPath, "utf-8"); const updated = content.replace(/^status:\s*(new|changed)/m, "status: synced"); await writeFile(absPath, updated, "utf-8"); marked.push(file.relativePath); } } return marked; } async changeRequests(): Promise { this.ensureInitialized(); return parseAllCRFiles(this.root); } async pendingChangeRequests(): Promise { const all = await this.changeRequests(); return all.filter((cr) => cr.frontmatter.status === "pending"); } async markCRApplied(paths?: string[]): Promise { this.ensureInitialized(); const all = await this.changeRequests(); const marked: string[] = []; for (const cr of all) { if (cr.frontmatter.status !== "pending") continue; if (paths && paths.length > 0 && !paths.includes(cr.relativePath)) continue; const absPath = resolve(this.root, cr.relativePath); const content = await readFile(absPath, "utf-8"); const updated = content.replace(/^status:\s*pending/m, "status: applied"); await writeFile(absPath, updated, "utf-8"); marked.push(cr.relativePath); } return marked; } async bugs(): Promise { this.ensureInitialized(); return parseAllBugFiles(this.root); } async openBugs(): Promise { const all = await this.bugs(); return all.filter((b) => b.frontmatter.status === "open"); } async markBugResolved(paths?: string[]): Promise { this.ensureInitialized(); const all = await this.bugs(); const marked: string[] = []; for (const bug of all) { if (bug.frontmatter.status !== "open") continue; if (paths && paths.length > 0 && !paths.includes(bug.relativePath)) continue; const absPath = resolve(this.root, bug.relativePath); const content = await readFile(absPath, "utf-8"); const updated = content.replace(/^status:\s*open/m, "status: resolved"); await writeFile(absPath, updated, "utf-8"); marked.push(bug.relativePath); } return marked; } // ── Compact ───────────────────────────────────────────────────────── async compact(options?: { mode?: CompactMode; dryRun?: boolean }): Promise { this.ensureInitialized(); const mode: CompactMode = options?.mode ?? "archive"; const dryRun = options?.dryRun ?? false; const { rename, rm, mkdir } = await import("node:fs/promises"); const result: CompactResult = { mode, dryRun, changeRequests: [], bugs: [] }; const processEntity = async ( relativePath: string, archiveDir: string, ): Promise => { const srcAbs = resolve(this.root, relativePath); const archiveAbs = resolve(this.root, archiveDir); const destAbs = resolve(archiveAbs, basename(relativePath)); if (dryRun) return; if (mode === "purge") { await rm(srcAbs); } else { await mkdir(archiveAbs, { recursive: true }); await rename(srcAbs, destAbs); } }; // CR applied → change-requests/archive/ const crs = await this.changeRequests(); for (const cr of crs) { if (cr.frontmatter.status !== "applied") continue; result.changeRequests.push(cr.relativePath); await processEntity(cr.relativePath, "change-requests/archive"); } // Bugs resolved → bugs/archive/ const bugs = await this.bugs(); for (const bug of bugs) { if (bug.frontmatter.status !== "resolved") continue; result.bugs.push(bug.relativePath); await processEntity(bug.relativePath, "bugs/archive"); } return result; } // ── Preflight ─────────────────────────────────────────────────────── async preflight(): Promise { this.ensureInitialized(); const [validation, files, drafts, pendingCRs, openBugs] = await Promise.all([ this.validate(), parseAllStoryFiles(this.root), this.drafts(), this.pendingChangeRequests(), this.openBugs(), ]); const transientDocs = files.filter( (f) => f.frontmatter.status === "new" || f.frontmatter.status === "changed" || f.frontmatter.status === "deleted", ); const clean = validation.valid && transientDocs.length === 0 && drafts.docs.length === 0 && drafts.crs.length === 0 && drafts.bugs.length === 0 && pendingCRs.length === 0 && openBugs.length === 0; return { validation, transientDocs, drafts, pendingCRs, openBugs, clean }; } // ── Drafts ─────────────────────────────────────────────────────────── async drafts(): Promise<{ docs: import("./types.js").StoryFile[]; crs: ChangeRequest[]; bugs: Bug[] }> { this.ensureInitialized(); const [files, crs, bugs] = await Promise.all([ parseAllStoryFiles(this.root), this.changeRequests(), this.bugs(), ]); return { docs: files.filter((f) => f.frontmatter.status === "draft"), crs: crs.filter((cr) => cr.frontmatter.status === "draft"), bugs: bugs.filter((b) => b.frontmatter.status === "draft"), }; } async draftEnrichmentPrompt(): Promise { this.ensureInitialized(); const drafts = await this.drafts(); return generateDraftEnrichmentPrompt(drafts); } async markDraftsEnriched(paths?: string[]): Promise { this.ensureInitialized(); const marked: string[] = []; // Story files: draft → new const files = await parseAllStoryFiles(this.root); for (const file of files) { if (file.frontmatter.status !== "draft") continue; if (paths && paths.length > 0 && !paths.includes(file.relativePath)) continue; const absPath = resolve(this.root, file.relativePath); const content = await readFile(absPath, "utf-8"); const updated = content.replace(/^status:\s*draft/m, "status: new"); await writeFile(absPath, updated, "utf-8"); marked.push(file.relativePath); } // CRs: draft → pending const crs = await this.changeRequests(); for (const cr of crs) { if (cr.frontmatter.status !== "draft") continue; if (paths && paths.length > 0 && !paths.includes(cr.relativePath)) continue; const absPath = resolve(this.root, cr.relativePath); const content = await readFile(absPath, "utf-8"); const updated = content.replace(/^status:\s*draft/m, "status: pending"); await writeFile(absPath, updated, "utf-8"); marked.push(cr.relativePath); } // Bugs: draft → open const allBugs = await this.bugs(); for (const bug of allBugs) { if (bug.frontmatter.status !== "draft") continue; if (paths && paths.length > 0 && !paths.includes(bug.relativePath)) continue; const absPath = resolve(this.root, bug.relativePath); const content = await readFile(absPath, "utf-8"); const updated = content.replace(/^status:\s*draft/m, "status: open"); await writeFile(absPath, updated, "utf-8"); marked.push(bug.relativePath); } return marked; } // ── Remote sync ────────────────────────────────────────────────────── async remoteStatus(timeout?: number): Promise { this.ensureInitialized(); return getRemoteStatus(this.root, timeout); } async push(paths?: string[], options?: { all?: boolean; timeout?: number }): Promise { this.ensureInitialized(); return pushToRemote(this.root, { paths, all: options?.all, timeout: options?.timeout }); } async pull(timeout?: number): Promise { this.ensureInitialized(); return pullFromRemote(this.root, timeout); } async pullCRs(timeout?: number): Promise { this.ensureInitialized(); return pullCRsFromRemote(this.root, timeout); } async pullBugs(timeout?: number): Promise { this.ensureInitialized(); return pullBugsFromRemote(this.root, timeout); } async remoteReset(confirmSlug: string, timeout?: number): Promise { this.ensureInitialized(); return resetRemoteProject(this.root, confirmSlug, timeout); } private ensureInitialized(): void { if (!isSDDProject(this.root)) { throw new ProjectNotInitializedError(this.root); } } }