Implements audit-harness-inspired session lifecycle: audit session creation/end/recover/report-daily with JSONL buffer and graded context recovery (L0-L4). Guideline service for behavioral compliance rules (condition/action model with criticality). Correction service for persistent user correction tracking across agent sessions. 8 supporting skills: audit-start/end/report-daily/recover + command variants for slash-command integration.
561 lines
15 KiB
TypeScript
561 lines
15 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<T>(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<void> {
|
|
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<GuidelineStoreData> {
|
|
try {
|
|
const raw = await readFile(storePath(basePath), 'utf-8');
|
|
const data = tryParseJson<GuidelineStoreData>(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<void> {
|
|
await ensureStoreDir(basePath);
|
|
await writeFile(storePath(basePath), JSON.stringify(data, null, 2), 'utf-8');
|
|
}
|
|
|
|
export async function createGuideline(
|
|
params: CreateGuidelineParams,
|
|
basePath?: string,
|
|
): Promise<Guideline> {
|
|
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<Guideline[]> {
|
|
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<Guideline | null> {
|
|
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<Guideline | null> {
|
|
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<boolean> {
|
|
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<Guideline | null> {
|
|
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<string, string[]>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, string[]>();
|
|
const visited = new Set<string>();
|
|
const nodeMap = new Map<string, JourneyNode>();
|
|
|
|
for (const node of journey.nodes) {
|
|
nodeMap.set(node.id, node);
|
|
}
|
|
|
|
// Build adjacency list
|
|
const adjacency = new Map<string, JourneyEdge[]>();
|
|
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<string>();
|
|
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<string, string[]>();
|
|
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<string>();
|
|
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,
|
|
};
|
|
}
|