import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { BOARD_PATH, BOARD_SCHEMA_VERSION, boardStateForInitialization, boardStateSource, emptyBoardState, } from "./board-state.ts"; import { resolveGovernedPath, SystemDocumentError } from "./project-root.ts"; import { parseSystemConfig, SCHEMA_VERSION, systemConfigForInitialization, } from "./system-config.ts"; export type InitializationInspection = Readonly<{ root: string; prefix?: string; title?: string; currentPhase?: string; cores: readonly Readonly<{ path: string; state: "missing" | "preserve" }>[]; creates: readonly string[]; rewrites: readonly string[]; }>; export type InitializationResult = Readonly<{ root: string; prefix: string; changed: readonly string[]; pending: readonly string[]; }>; const DIRECTORIES = [".system"] as const; const COLLECTION_DIRECTORIES = [ ".system/specs", ".system/phases", ".system/plans", ".system/issues", ".system/research", ] as const; const CORES = [ ".system/SYSTEM.md", ".system/MISSION.md", ".system/RULES.md", ] as const; const CONFIG = ".system/config.json"; const CORE_CONTENT: Readonly> = { ".system/SYSTEM.md": "# SYSTEM\n\nStable project identity, domain truths, architecture invariants, and non-negotiable conventions.\nKeep durable; do not put current tasks here.\n", ".system/MISSION.md": "# MISSION\n\nEnduring high-level project objective and why it matters.\nUpdate only when the end goal changes; current operating priorities belong in a phase.\n", ".system/RULES.md": "# RULES\n\nConcrete operating constraints: commands, safety, style, boundaries, and do/don't rules.\nKeep executable; avoid goals and rationale unless needed.\n", }; const target = (root: string, relative: string) => resolveGovernedPath(root, relative); const kind = (file: string) => { const stats = lstatSync(file, { throwIfNoEntry: false }); if (!stats) return "missing" as const; if (stats.isSymbolicLink()) return "link" as const; if (stats.isDirectory()) return "directory" as const; if (stats.isFile()) return "file" as const; return "other" as const; }; const requireKind = ( root: string, relative: string, expected: "directory" | "file", ) => { const actual = kind(target(root, relative)); if (actual !== "missing" && actual !== expected) throw new SystemDocumentError( "init.unsafe-path", `${relative} must be a regular ${expected}; found ${actual}`, ); return actual; }; const projectTitle = (root: string) => path.basename(root) || "Project"; const validatePrefix = (prefix: string) => { parseSystemConfig( JSON.stringify({ prefix, schemaVersion: SCHEMA_VERSION, }), ); return prefix; }; const configSource = (prefix: string, title: string, currentPhase?: string) => `${JSON.stringify( { prefix, schemaVersion: SCHEMA_VERSION, title, ...(currentPhase ? { currentPhase } : {}), }, null, "\t", )}\n`; export function inspectInitialization( cwd: string, requestedPrefix?: string, ): InitializationInspection { const root = path.resolve(cwd); for (const relative of DIRECTORIES) requireKind(root, relative, "directory"); for (const relative of COLLECTION_DIRECTORIES) requireKind(root, relative, "directory"); const configKind = requireKind(root, CONFIG, "file"); const boardKind = requireKind(root, BOARD_PATH, "file"); const requested = requestedPrefix ? validatePrefix(requestedPrefix) : undefined; const inspected = configKind === "file" ? systemConfigForInitialization( readFileSync(target(root, CONFIG), "utf8"), ) : undefined; const config = inspected?.config; if (config && requested && config.prefix !== requested) throw new SystemDocumentError( "init.prefix-conflict", `Existing prefix ${config.prefix} does not match requested ${requested}`, ); const prefix = config?.prefix ?? requested; const board = boardKind === "file" && prefix ? boardStateForInitialization( readFileSync(target(root, BOARD_PATH), "utf8"), prefix, ) : undefined; const creates: string[] = []; for (const relative of DIRECTORIES) if (kind(target(root, relative)) === "missing") creates.push(relative); if (configKind === "missing") creates.push(CONFIG); if (boardKind === "missing") creates.push(BOARD_PATH); const rewrites = [ ...(inspected?.sourceSchemaVersion === undefined ? [] : [ `${CONFIG} schemaVersion ${inspected.sourceSchemaVersion} -> ${SCHEMA_VERSION}`, ]), ...(board?.sourceSchemaVersion === undefined ? [] : [ `${BOARD_PATH} schemaVersion ${board.sourceSchemaVersion} -> ${BOARD_SCHEMA_VERSION}`, ]), ]; return { root, prefix, ...(config?.title ? { title: config.title } : {}), ...(config?.currentPhase ? { currentPhase: config.currentPhase } : {}), cores: CORES.map((relative) => ({ path: relative, state: kind(target(root, relative)) === "missing" ? "missing" : "preserve", })), creates, rewrites, }; } export function applyInitialization( cwd: string, requestedPrefix?: string, approveRewrite = false, ): InitializationResult { const inspection = inspectInitialization(cwd, requestedPrefix); if (!inspection.prefix) throw new SystemDocumentError( "init.prefix-required", "An approved project prefix is required before initialization", ); const changed: string[] = []; for (const relative of DIRECTORIES) { if (kind(target(inspection.root, relative)) !== "missing") continue; mkdirSync(target(inspection.root, relative)); changed.push(relative); } if ( kind(target(inspection.root, CONFIG)) === "missing" || (approveRewrite && inspection.rewrites.some((item) => item.startsWith(`${CONFIG} `))) ) { writeFileSync( target(inspection.root, CONFIG), configSource( inspection.prefix, inspection.title ?? projectTitle(inspection.root), inspection.currentPhase, ), kind(target(inspection.root, CONFIG)) === "missing" ? { encoding: "utf8", flag: "wx" } : "utf8", ); changed.push(CONFIG); } if ( kind(target(inspection.root, BOARD_PATH)) === "missing" || (approveRewrite && inspection.rewrites.some((item) => item.startsWith(`${BOARD_PATH} `))) ) { const current = kind(target(inspection.root, BOARD_PATH)) === "file" ? boardStateForInitialization( readFileSync(target(inspection.root, BOARD_PATH), "utf8"), inspection.prefix, ).state : emptyBoardState(); writeFileSync( target(inspection.root, BOARD_PATH), boardStateSource(current), kind(target(inspection.root, BOARD_PATH)) === "missing" ? { encoding: "utf8", flag: "wx" } : "utf8", ); changed.push(BOARD_PATH); } for (const core of inspection.cores) { if (core.state !== "missing") continue; writeFileSync( target(inspection.root, core.path), CORE_CONTENT[core.path as keyof typeof CORE_CONTENT], { encoding: "utf8", flag: "wx", }, ); changed.push(core.path); } return { root: inspection.root, prefix: inspection.prefix, changed, pending: approveRewrite ? [] : inspection.rewrites, }; }