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.
237 lines
7.1 KiB
TypeScript
237 lines
7.1 KiB
TypeScript
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import {
|
|
ensureRunsDir,
|
|
readCurrentSession,
|
|
writeCurrentSession,
|
|
clearCurrentSession,
|
|
updateIndexEntry,
|
|
findInProgressSessions,
|
|
readIndex,
|
|
type IndexEntry,
|
|
} from './runs-dir.js';
|
|
|
|
export interface SessionJson {
|
|
session_id: string;
|
|
task: string;
|
|
start_time: string;
|
|
end_time?: string;
|
|
status: 'in_progress' | 'completed';
|
|
expected_record_types?: string[];
|
|
total_records?: number;
|
|
}
|
|
|
|
export function generateSessionId(): string {
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
const d = String(now.getDate()).padStart(2, '0');
|
|
const h = String(now.getHours()).padStart(2, '0');
|
|
const min = String(now.getMinutes()).padStart(2, '0');
|
|
return `adhoc_${y}${m}${d}_${h}${min}`;
|
|
}
|
|
|
|
export function isoNow(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
export function createSession(
|
|
task: string,
|
|
sessionId?: string,
|
|
projectRoot?: string,
|
|
): string {
|
|
const sid = sessionId || generateSessionId();
|
|
const runsDir = ensureRunsDir(projectRoot);
|
|
const sessionDir = join(runsDir, sid);
|
|
mkdirSync(sessionDir, { recursive: true });
|
|
|
|
const session: SessionJson = {
|
|
session_id: sid,
|
|
task,
|
|
start_time: isoNow(),
|
|
status: 'in_progress',
|
|
expected_record_types: ['data', 'change', 'conversation'],
|
|
};
|
|
|
|
writeFileSync(join(sessionDir, 'session.json'), JSON.stringify(session, null, 2), 'utf-8');
|
|
writeCurrentSession(sid, projectRoot);
|
|
|
|
updateIndexEntry({
|
|
id: sid,
|
|
type: 'adhoc',
|
|
status: 'in_progress',
|
|
task,
|
|
created: session.start_time,
|
|
last_updated: session.start_time,
|
|
}, projectRoot);
|
|
|
|
return sid;
|
|
}
|
|
|
|
export function getSessionDir(sessionId: string, projectRoot?: string): string {
|
|
return join(ensureRunsDir(projectRoot), sessionId);
|
|
}
|
|
|
|
export function getActiveSession(projectRoot?: string): SessionJson | null {
|
|
const sid = readCurrentSession(projectRoot);
|
|
if (!sid) return null;
|
|
return readSession(sid, projectRoot);
|
|
}
|
|
|
|
export function readSession(sessionId: string, projectRoot?: string): SessionJson | null {
|
|
const path = join(getSessionDir(sessionId, projectRoot), 'session.json');
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf-8')) as SessionJson;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function updateSession(
|
|
sessionId: string,
|
|
updates: Partial<SessionJson>,
|
|
projectRoot?: string,
|
|
): void {
|
|
const session = readSession(sessionId, projectRoot) || { session_id: sessionId, task: '', start_time: isoNow(), status: 'in_progress' as const };
|
|
Object.assign(session, updates);
|
|
writeFileSync(
|
|
join(getSessionDir(sessionId, projectRoot), 'session.json'),
|
|
JSON.stringify(session, null, 2),
|
|
'utf-8',
|
|
);
|
|
}
|
|
|
|
export function endSession(sessionId: string, projectRoot?: string): void {
|
|
updateSession(sessionId, { status: 'completed', end_time: isoNow() }, projectRoot);
|
|
updateIndexEntry({ id: sessionId, type: 'adhoc', status: 'completed', last_updated: isoNow() }, projectRoot);
|
|
clearCurrentSession(projectRoot);
|
|
}
|
|
|
|
export function appendToTrail(sessionId: string, records: Record<string, unknown>[], projectRoot?: string): void {
|
|
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
|
const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
appendFileSync(trailPath, lines, 'utf-8');
|
|
}
|
|
|
|
export function readTrail(sessionId: string, projectRoot?: string): Record<string, unknown>[] {
|
|
const trailPath = join(getSessionDir(sessionId, projectRoot), 'audit_trail.jsonl');
|
|
try {
|
|
const content = readFileSync(trailPath, 'utf-8').trim();
|
|
if (!content) return [];
|
|
return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export interface RecoverySummary {
|
|
sessionId: string;
|
|
task: string;
|
|
recentActivity: IndexEntry[];
|
|
userCorrections: Record<string, unknown>[];
|
|
unresolvedIssues: string[];
|
|
recommendedPriorities: string[];
|
|
level: number;
|
|
}
|
|
|
|
export function recoverContext(
|
|
sessionId: string,
|
|
level: number,
|
|
projectRoot?: string,
|
|
): RecoverySummary {
|
|
const session = readSession(sessionId, projectRoot);
|
|
const idx = readIndex(projectRoot);
|
|
const recentActivity = idx.entries.slice(-5);
|
|
const trail = readTrail(sessionId, projectRoot);
|
|
const userCorrections = trail.filter(r => r['action_type'] === 'user_correction');
|
|
|
|
const summary: RecoverySummary = {
|
|
sessionId,
|
|
task: session?.task || '(unknown)',
|
|
recentActivity,
|
|
userCorrections,
|
|
unresolvedIssues: [],
|
|
recommendedPriorities: [],
|
|
level,
|
|
};
|
|
|
|
if (level >= 1) {
|
|
const last = trail.slice(-3);
|
|
if (last.length > 0) {
|
|
summary.recommendedPriorities.push(`Last action: ${JSON.stringify(last[last.length - 1]?.['action'] || 'none')}`);
|
|
}
|
|
}
|
|
|
|
if (level >= 3) {
|
|
summary.recommendedPriorities.push(`Full trail: ${trail.length} records`);
|
|
}
|
|
|
|
let checkCount = 0;
|
|
for (const entry of recentActivity) {
|
|
if (entry.status === 'in_progress' && entry.id !== sessionId) {
|
|
summary.unresolvedIssues.push(`Unfinished session: ${entry.id} (${entry.task || 'no task'})`);
|
|
checkCount++;
|
|
if (checkCount >= 3) break;
|
|
}
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
export function checkUnfinishedSessions(projectRoot?: string): IndexEntry[] {
|
|
return findInProgressSessions(projectRoot);
|
|
}
|
|
|
|
export function generateSessionSummary(sessionId: string, projectRoot?: string): string {
|
|
const session = readSession(sessionId, projectRoot);
|
|
const trail = readTrail(sessionId, projectRoot);
|
|
const corrections = trail.filter(r => r['action_type'] === 'user_correction');
|
|
const changes = trail.filter(r => r['action'] === 'edit_file' || r['action'] === 'create_file' || r['action'] === 'delete_file');
|
|
|
|
const lines: string[] = [
|
|
`# Session Summary | ${sessionId}`,
|
|
'',
|
|
`## Task: ${session?.task || '(unknown)'}`,
|
|
`## Time: ${session?.start_time || '?'} → ${session?.end_time || 'in_progress'}`,
|
|
`## Status: ${session?.status || 'unknown'}`,
|
|
'',
|
|
'## Completed Work',
|
|
];
|
|
|
|
for (const r of trail) {
|
|
if (r['action']) {
|
|
lines.push(`- ${r['action']}: ${r['detail'] || r['reason'] || '(no detail)'}`);
|
|
}
|
|
}
|
|
|
|
if (corrections.length > 0) {
|
|
lines.push('', '## User Corrections');
|
|
for (const c of corrections) {
|
|
lines.push(`- Original: ${c['original_claim']}`);
|
|
lines.push(` Correction: ${c['correction']}`);
|
|
if (c['principle_extracted']) {
|
|
lines.push(` Principle: ${c['principle_extracted']}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (changes.length > 0) {
|
|
lines.push('', '## Files Changed');
|
|
const fileSet = new Set<string>();
|
|
for (const c of changes) {
|
|
const files = c['files'];
|
|
if (Array.isArray(files)) {
|
|
for (const f of files) fileSet.add(String(f));
|
|
}
|
|
}
|
|
for (const f of fileSet) lines.push(`- ${f}`);
|
|
}
|
|
|
|
lines.push('', '## Stats');
|
|
lines.push(`- Total records: ${trail.length}`);
|
|
lines.push(`- Corrections: ${corrections.length}`);
|
|
lines.push(`- File changes: ${changes.length}`);
|
|
|
|
return lines.join('\n');
|
|
}
|