import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; export type Criticality = 'low' | 'medium' | 'high'; export interface GuidelineContent { condition: string; action: string | null; description: string | null; } export interface Guideline { id: string; creationUtc: string; content: GuidelineContent; enabled: boolean; tags: string[]; labels: string[]; metadata: Record; criticality: Criticality; title: string | null; priority: number; } export interface CreateGuidelineParams { condition: string; action?: string; description?: string; tags?: string[]; labels?: string[]; criticality?: Criticality; title?: string; priority?: number; } export interface UpdateGuidelineParams { condition?: string; action?: string | null; description?: string | null; enabled?: boolean; tags?: string[]; labels?: string[]; metadata?: Record; criticality?: Criticality; title?: string | null; priority?: number; } export interface ListGuidelinesFilter { tags?: string[]; labels?: string[]; } interface GuidelineStoreData { version: string; guidelines: Guideline[]; migrationLog: string[]; } const GUIDELINES_REL = '.boo/guidelines'; const STORE_FILE = 'guidelines.json'; const CURRENT_VERSION = 'v0.11.0'; function storeDir(basePath?: string): string { return resolve(basePath ?? process.cwd(), GUIDELINES_REL); } function storePath(basePath?: string): string { return join(storeDir(basePath), STORE_FILE); } function tryParseJson(raw: string): T | null { try { return JSON.parse(raw) as T; } catch { return null; } } let idCounter = 0; function nextId(): string { idCounter++; return `gl_${Date.now()}_${idCounter}`; } function isoNow(): string { return new Date().toISOString(); } async function ensureStoreDir(basePath?: string): Promise { const dir = storeDir(basePath); if (!existsSync(dir)) { await mkdir(dir, { recursive: true }); } } const MIGRATIONS: { from: string; to: string; migrate: (data: GuidelineStoreData) => GuidelineStoreData }[] = [ { from: 'v0.1.0', to: 'v0.2.0', migrate: (data) => ({ ...data, version: 'v0.2.0', guidelines: data.guidelines.map((g) => ({ ...g, enabled: g.enabled ?? true, })), migrationLog: [...data.migrationLog, 'v0.1.0→v0.2.0: add enabled field'], }), }, { from: 'v0.2.0', to: 'v0.3.0', migrate: (data) => ({ ...data, version: 'v0.3.0', migrationLog: [...data.migrationLog, 'v0.2.0→v0.3.0: remove guideline_set'], }), }, { from: 'v0.3.0', to: 'v0.4.0', migrate: (data) => ({ ...data, version: 'v0.4.0', guidelines: data.guidelines.map((g) => ({ ...g, content: { ...g.content, action: g.content.action ?? null, description: g.content.description ?? null, }, metadata: g.metadata ?? {}, })), migrationLog: [...data.migrationLog, 'v0.3.0→v0.4.0: add optional action, description, metadata'], }), }, { from: 'v0.4.0', to: 'v0.5.0', migrate: (data) => ({ ...data, version: 'v0.5.0', migrationLog: [...data.migrationLog, 'v0.4.0→v0.5.0: description as optional'], }), }, { from: 'v0.5.0', to: 'v0.6.0', migrate: (data) => ({ ...data, version: 'v0.6.0', guidelines: data.guidelines.map((g) => ({ ...g, criticality: g.criticality ?? 'medium', })), migrationLog: [...data.migrationLog, 'v0.5.0→v0.6.0: add criticality'], }), }, { from: 'v0.6.0', to: 'v0.7.0', migrate: (data) => ({ ...data, version: 'v0.7.0', migrationLog: [...data.migrationLog, 'v0.6.0→v0.7.0: add composition_mode (optional)'], }), }, { from: 'v0.7.0', to: 'v0.8.0', migrate: (data) => ({ ...data, version: 'v0.8.0', migrationLog: [...data.migrationLog, 'v0.7.0→v0.8.0: add track (default true)'], }), }, { from: 'v0.8.0', to: 'v0.9.0', migrate: (data) => ({ ...data, version: 'v0.9.0', guidelines: data.guidelines.map((g) => ({ ...g, labels: g.labels ?? [], })), migrationLog: [...data.migrationLog, 'v0.8.0→v0.9.0: add labels'], }), }, { from: 'v0.9.0', to: 'v0.10.0', migrate: (data) => ({ ...data, version: 'v0.10.0', guidelines: data.guidelines.map((g) => ({ ...g, priority: g.priority ?? 0, })), migrationLog: [...data.migrationLog, 'v0.9.0→v0.10.0: add priority'], }), }, { from: 'v0.10.0', to: 'v0.11.0', migrate: (data) => ({ ...data, version: 'v0.11.0', guidelines: data.guidelines.map((g) => ({ ...g, title: g.title ?? null, })), migrationLog: [...data.migrationLog, 'v0.10.0→v0.11.0: add title'], }), }, ]; function applyMigrations(data: GuidelineStoreData): GuidelineStoreData { let current = { ...data }; for (const migration of MIGRATIONS) { if (current.version === migration.from) { current = migration.migrate(current); } } return current; } async function readStore(basePath?: string): Promise { try { const raw = await readFile(storePath(basePath), 'utf-8'); const data = tryParseJson(raw); if (!data) return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] }; if (data.version !== CURRENT_VERSION) { return applyMigrations(data); } return data; } catch { return { version: CURRENT_VERSION, guidelines: [], migrationLog: [] }; } } async function writeStore(data: GuidelineStoreData, basePath?: string): Promise { await ensureStoreDir(basePath); await writeFile(storePath(basePath), JSON.stringify(data, null, 2), 'utf-8'); } export async function createGuideline( params: CreateGuidelineParams, basePath?: string, ): Promise { const data = await readStore(basePath); const guideline: Guideline = { id: nextId(), creationUtc: isoNow(), content: { condition: params.condition, action: params.action ?? null, description: params.description ?? null, }, enabled: true, tags: params.tags ?? [], labels: params.labels ?? [], metadata: {}, criticality: params.criticality ?? 'medium', title: params.title ?? null, priority: params.priority ?? 0, }; data.guidelines.push(guideline); await writeStore(data, basePath); return guideline; } export async function listGuidelines( filter?: ListGuidelinesFilter, basePath?: string, ): Promise { const data = await readStore(basePath); let results = data.guidelines; if (filter?.tags && filter.tags.length > 0) { results = results.filter((g) => filter.tags!.some((tag) => g.tags.includes(tag))); } if (filter?.labels && filter.labels.length > 0) { results = results.filter((g) => filter.labels!.every((label) => g.labels.includes(label))); } return results; } export async function readGuideline( id: string, basePath?: string, ): Promise { const data = await readStore(basePath); return data.guidelines.find((g) => g.id === id) ?? null; } export async function updateGuideline( id: string, params: UpdateGuidelineParams, basePath?: string, ): Promise { const data = await readStore(basePath); const idx = data.guidelines.findIndex((g) => g.id === id); if (idx === -1) return null; const existing = data.guidelines[idx]!; if (params.condition !== undefined) existing.content.condition = params.condition; if (params.action !== undefined) existing.content.action = params.action; if (params.description !== undefined) existing.content.description = params.description; if (params.enabled !== undefined) existing.enabled = params.enabled; if (params.tags !== undefined) existing.tags = params.tags; if (params.labels !== undefined) existing.labels = params.labels; if (params.metadata !== undefined) existing.metadata = params.metadata; if (params.criticality !== undefined) existing.criticality = params.criticality; if (params.title !== undefined) existing.title = params.title; if (params.priority !== undefined) existing.priority = params.priority; data.guidelines[idx] = existing; await writeStore(data, basePath); return existing; } export async function deleteGuideline( id: string, basePath?: string, ): Promise { const data = await readStore(basePath); const lenBefore = data.guidelines.length; data.guidelines = data.guidelines.filter((g) => g.id !== id); if (data.guidelines.length === lenBefore) return false; await writeStore(data, basePath); return true; } export async function findGuideline( content: { condition: string; action?: string }, basePath?: string, ): Promise { const data = await readStore(basePath); return data.guidelines.find((g) => { const condMatch = g.content.condition === content.condition; if (!condMatch) return false; if (content.action !== undefined) { return g.content.action === content.action; } return true; }) ?? null; } // ─── Journey → Guideline projection (port of Parlant's JourneyGuidelineProjection) ─── export interface JourneyNode { id: string; action: string; description?: string; } export interface JourneyEdge { sourceNodeId: string; targetNodeId: string; condition: string; } export interface Journey { id: string; name: string; nodes: JourneyNode[]; edges: JourneyEdge[]; } export interface JourneyProjectionResult { guidelines: Guideline[]; followUps: Map; } /** * Project a Journey into an ordered list of Guidelines. * DFS traversal from root nodes: each (edge, node) pair → one Guideline. * Edge condition becomes guideline condition, node action becomes guideline action. * BFS queue avoids infinite loops via visited set. */ export function projectJourneyToGuidelines( journey: Journey, baseTags?: string[], ): JourneyProjectionResult { const guidelines: Guideline[] = []; const followUps = new Map(); const visited = new Set(); const nodeMap = new Map(); for (const node of journey.nodes) { nodeMap.set(node.id, node); } // Build adjacency list const adjacency = new Map(); for (const edge of journey.edges) { const list = adjacency.get(edge.sourceNodeId) ?? []; list.push(edge); adjacency.set(edge.sourceNodeId, list); } // Find root nodes (no incoming edges) const hasIncoming = new Set(); for (const edge of journey.edges) { hasIncoming.add(edge.targetNodeId); } const roots = journey.nodes .filter((n) => !hasIncoming.has(n.id)) .map((n) => n.id); const queue: { nodeId: string; fromEdge?: JourneyEdge }[] = []; // BFS from roots for (const rootId of roots) { if (!visited.has(rootId)) { queue.push({ nodeId: rootId }); } } while (queue.length > 0) { const { nodeId, fromEdge } = queue.shift()!; if (visited.has(nodeId)) continue; visited.add(nodeId); const node = nodeMap.get(nodeId); if (!node) continue; // If we arrived via an edge, create a guideline if (fromEdge) { const guideline = createGuidelineFromJourneyEdge( journey, node, fromEdge, baseTags, ); guidelines.push(guideline); // Track follow-ups const sourceId = findGuidelineForNode(fromEdge.sourceNodeId, journey.nodes); if (sourceId) { const existing = followUps.get(sourceId) ?? []; existing.push(guideline.id); followUps.set(sourceId, existing); } } // Enqueue downstream nodes const outgoingEdges = adjacency.get(nodeId) ?? []; for (const edge of outgoingEdges) { if (!visited.has(edge.targetNodeId)) { queue.push({ nodeId: edge.targetNodeId, fromEdge: edge }); } } } return { guidelines, followUps }; } function findGuidelineForNode(nodeId: string, nodes: JourneyNode[]): string | null { // Placeholder: in a full implementation, map nodeId → guideline id // For now return null — downstream consumers handle missing follow-ups gracefully return null; } function createGuidelineFromJourneyEdge( journey: Journey, targetNode: JourneyNode, edge: JourneyEdge, baseTags?: string[], ): Guideline { const now = isoNow(); return { id: nextId(), creationUtc: now, content: { condition: edge.condition, action: targetNode.action, description: targetNode.description ?? null, }, enabled: true, tags: baseTags ?? [journey.name], labels: [], metadata: { journey_id: journey.id, journey_node: targetNode.id, source_edge_id: `${edge.sourceNodeId}→${edge.targetNodeId}`, }, criticality: 'medium', title: targetNode.description ? `[${journey.name}] ${targetNode.description.slice(0, 60)}` : null, priority: 0, }; } // ─── Backtrack detection ─── export interface BacktrackCheckInput { journeyId: string; currentNodeId: string; previousNodeId: string; } export interface BacktrackCheckResult { journeyId: string; currentNodeId: string; previousNodeId: string; isBacktrack: boolean; recommendation: string | null; } /** * Check if moving from previousNodeId to currentNodeId is a backtrack * (regression to an already-visited node not on a forward path). */ export function checkBacktrack( input: BacktrackCheckInput, journey: Journey, ): BacktrackCheckResult { const adjacency = new Map(); for (const edge of journey.edges) { const list = adjacency.get(edge.sourceNodeId) ?? []; list.push(edge.targetNodeId); adjacency.set(edge.sourceNodeId, list); } // Find forward reachable nodes from the current node const forwardReachable = new Set(); const bfsQueue = [input.currentNodeId]; while (bfsQueue.length > 0) { const nid = bfsQueue.shift()!; if (forwardReachable.has(nid)) continue; forwardReachable.add(nid); const next = adjacency.get(nid) ?? []; for (const n of next) { if (!forwardReachable.has(n)) bfsQueue.push(n); } } const isBacktrack = input.previousNodeId !== input.currentNodeId && !forwardReachable.has(input.previousNodeId) && input.previousNodeId !== input.currentNodeId; return { journeyId: input.journeyId, currentNodeId: input.currentNodeId, previousNodeId: input.previousNodeId, isBacktrack, recommendation: isBacktrack ? `Revisiting node "${input.previousNodeId}" after "${input.currentNodeId}" — this may indicate a regression. Consider whether the forward path from "${input.currentNodeId}" is the correct one.` : null, }; }