import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { getAgentPath } from "../../src/shared/agent-paths"; import { diagnostic, type DiagnosticMessage } from "../../src/shared/diagnostics"; const INCLUDE_RE = /^\s*@include:\s*(.+?)\s*$/gm; type RuleLayerName = "global" | "project"; interface RuleLayer { name: RuleLayerName; baseDir: string; rootPath: string; } export interface LoadedRuleLayer { name: RuleLayerName; rootPath: string; files: string[]; text: string; } export interface LoadPromptRulesResult { prompt: string | null; layers: LoadedRuleLayer[]; diagnostics: DiagnosticMessage[]; includeCount: number; } function parseRuleFile(raw: string): { body: string; includes: string[] } { const includes: string[] = []; const body = raw.replace(INCLUDE_RE, (_match, includePath: string) => { includes.push(includePath); return ""; }); return { body: body.trimEnd(), includes }; } function safeResolveInclude(baseDir: string, currentPath: string, includePath: string): string | null { const resolved = resolve(dirname(currentPath), includePath); if (!resolved.startsWith(baseDir + "/") && !resolved.startsWith(baseDir + "\\") && resolved !== baseDir) { return null; } return resolved; } function uniqueStrings(values: string[]): string[] { return Array.from(new Set(values)); } function expandRuleFile(filePath: string, baseDir: string, stack = new Set()): { text: string; files: string[]; diagnostics: DiagnosticMessage[]; } { if (stack.has(filePath)) { return { text: "", files: [], diagnostics: [diagnostic("warning", `prompt-rules: skipped \"${filePath}\" — include cycle detected`)], }; } const nextStack = new Set(stack); nextStack.add(filePath); const raw = readFileSync(filePath, "utf8"); const { body, includes } = parseRuleFile(raw); const parts: string[] = []; const files = [filePath]; const diagnostics: DiagnosticMessage[] = []; if (body.trim()) { parts.push(body.trimEnd()); } for (const includePath of includes) { const resolved = safeResolveInclude(baseDir, filePath, includePath); if (!resolved) { diagnostics.push(diagnostic("warning", `prompt-rules: skipped \"${includePath}\" — path escapes rules dir`)); continue; } if (!existsSync(resolved)) { diagnostics.push(diagnostic("warning", `prompt-rules: skipped \"${includePath}\" — file not found`)); continue; } const expanded = expandRuleFile(resolved, baseDir, nextStack); diagnostics.push(...expanded.diagnostics); files.push(...expanded.files); if (expanded.text) { parts.push(expanded.text); } } return { text: parts.join("\n\n").trim(), files: uniqueStrings(files), diagnostics, }; } function getRuleLayers(cwd: string): RuleLayer[] { const globalRoot = getAgentPath("rules", "SOUL.md"); const projectRoot = join(cwd, ".pi", "rules", "SOUL.md"); const layers: RuleLayer[] = []; if (existsSync(globalRoot)) { layers.push({ name: "global", baseDir: getAgentPath("rules"), rootPath: globalRoot }); } if (existsSync(projectRoot)) { layers.push({ name: "project", baseDir: join(cwd, ".pi", "rules"), rootPath: projectRoot }); } return layers; } export function loadPromptRules(cwd: string): LoadPromptRulesResult { const layers = getRuleLayers(cwd); if (layers.length === 0) { return { prompt: null, layers: [], diagnostics: [], includeCount: 0 }; } const loadedLayers: LoadedRuleLayer[] = []; const diagnostics: DiagnosticMessage[] = []; let includeCount = 0; for (const layer of layers) { const expanded = expandRuleFile(layer.rootPath, layer.baseDir); diagnostics.push(...expanded.diagnostics); includeCount += Math.max(0, expanded.files.length - 1); loadedLayers.push({ name: layer.name, rootPath: layer.rootPath, files: expanded.files, text: expanded.text, }); } const prompt = loadedLayers .map((layer) => layer.text) .filter(Boolean) .join("\n\n") .trim(); return { prompt: prompt || null, layers: loadedLayers, diagnostics, includeCount, }; }