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.
748 lines
23 KiB
TypeScript
748 lines
23 KiB
TypeScript
import { mkdir, readFile, writeFile, readdir, rm, appendFile } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
|
|
export const RUNS_REL = '.boo/runs';
|
|
export const DAILY_REL = '.boo/runs/daily';
|
|
export const GUIDELINES_REL = '.boo/guidelines';
|
|
|
|
export interface SessionJson {
|
|
session_id: string;
|
|
task: string;
|
|
start_time: string;
|
|
end_time?: string;
|
|
status: 'in_progress' | 'completed';
|
|
expected_record_types: string[];
|
|
}
|
|
|
|
export interface AuditTrailEntry {
|
|
timestamp: string;
|
|
record_type: string;
|
|
action_type: string;
|
|
tool?: string;
|
|
files?: string[];
|
|
detail?: string;
|
|
input?: string;
|
|
output?: string;
|
|
}
|
|
|
|
export interface IndexEntry {
|
|
id: string;
|
|
task: string;
|
|
status: string;
|
|
record_count: number;
|
|
start_time: string;
|
|
max_anomaly_level?: string;
|
|
}
|
|
|
|
export interface IndexJson {
|
|
entries: IndexEntry[];
|
|
}
|
|
|
|
export interface StartSessionResult {
|
|
sessionId: string;
|
|
contextSummary: {
|
|
recentActivity: IndexEntry[];
|
|
userCorrections: UserCorrectionRecord[];
|
|
unfinishedSessions: SessionJson[];
|
|
};
|
|
}
|
|
|
|
export interface EndSessionResult {
|
|
sessionId: string;
|
|
integrity: IntegrityCheck[];
|
|
correctionCount: number;
|
|
summaryPath: string;
|
|
}
|
|
|
|
export interface IntegrityCheck {
|
|
check: string;
|
|
passed: boolean;
|
|
detail?: string;
|
|
}
|
|
|
|
export interface RecoverResult {
|
|
level: number;
|
|
sessionId?: string;
|
|
task?: string;
|
|
recentActivity: IndexEntry[];
|
|
lastTrailEntries: AuditTrailEntry[];
|
|
userCorrections: UserCorrectionRecord[];
|
|
conclusions: string[];
|
|
dailyAnomalies: string[];
|
|
dailyBacklog: string[];
|
|
fullTrail?: AuditTrailEntry[];
|
|
anomalies?: string[];
|
|
}
|
|
|
|
export interface DailyReport {
|
|
date: string;
|
|
sections: {
|
|
taskOverview: string;
|
|
operationStats: { label: string; count: number }[];
|
|
changes: { time: string; target: string; detail: string }[];
|
|
userFeedback: { feedback: string; resolution: string; persistedTo: string }[];
|
|
anomalyAlerts: string[];
|
|
backlogTracking: string[];
|
|
integritySummary: string;
|
|
};
|
|
path: string;
|
|
}
|
|
|
|
export interface UserCorrectionRecord {
|
|
record_type: 'conversation';
|
|
action_type: 'user_correction';
|
|
priority: 'critical_for_recovery';
|
|
timestamp: string;
|
|
original_claim: string;
|
|
correction: string;
|
|
principle_extracted: string;
|
|
persisted_to: string[];
|
|
}
|
|
|
|
function runsDir(basePath?: string): string {
|
|
return resolve(basePath ?? process.cwd(), RUNS_REL);
|
|
}
|
|
|
|
function dailyDir(basePath?: string): string {
|
|
return resolve(basePath ?? process.cwd(), DAILY_REL);
|
|
}
|
|
|
|
function sessionDir(sessionId: string, basePath?: string): string {
|
|
return join(runsDir(basePath), sessionId);
|
|
}
|
|
|
|
function currentSessionPath(basePath?: string): string {
|
|
return join(runsDir(basePath), '.current_session');
|
|
}
|
|
|
|
function indexJsonPath(basePath?: string): string {
|
|
return join(runsDir(basePath), 'index.json');
|
|
}
|
|
|
|
function auditBufferPath(basePath?: string): string {
|
|
return join(runsDir(basePath), 'audit_buffer.jsonl');
|
|
}
|
|
|
|
function auditPendingPath(basePath?: string): string {
|
|
return join(runsDir(basePath), 'audit_pending.jsonl');
|
|
}
|
|
|
|
function trailPath(sessionId: string, basePath?: string): string {
|
|
return join(sessionDir(sessionId, basePath), 'audit_trail.jsonl');
|
|
}
|
|
|
|
function sessionJsonPath(sessionId: string, basePath?: string): string {
|
|
return join(sessionDir(sessionId, basePath), 'session.json');
|
|
}
|
|
|
|
function summaryPath(sessionId: string, basePath?: string): string {
|
|
return join(sessionDir(sessionId, basePath), 'session_summary.md');
|
|
}
|
|
|
|
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 hh = String(now.getHours()).padStart(2, '0');
|
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
return `adhoc_${y}${m}${d}_${hh}${mm}`;
|
|
}
|
|
|
|
function isoNow(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function isoDate(d?: Date): string {
|
|
const dt = d ?? new Date();
|
|
return `${dt.getFullYear()}${String(dt.getMonth() + 1).padStart(2, '0')}${String(dt.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function isTodayIso(iso: string): boolean {
|
|
return iso.startsWith(new Date().toISOString().slice(0, 10));
|
|
}
|
|
|
|
function tryParseJson<T>(raw: string): T | null {
|
|
try {
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function ensureDir(p: string): Promise<void> {
|
|
if (!existsSync(p)) {
|
|
await mkdir(p, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async function readLines(p: string): Promise<string[]> {
|
|
try {
|
|
const content = await readFile(p, 'utf-8');
|
|
return content.split('\n').filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function readJsonFile<T>(p: string): Promise<T | null> {
|
|
try {
|
|
const raw = await readFile(p, 'utf-8');
|
|
return tryParseJson<T>(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function appendLine(p: string, line: string): Promise<void> {
|
|
return appendFile(p, line + '\n', 'utf-8');
|
|
}
|
|
|
|
async function clearFile(p: string): Promise<void> {
|
|
try {
|
|
await writeFile(p, '', 'utf-8');
|
|
} catch {
|
|
// File may not exist
|
|
}
|
|
}
|
|
|
|
export async function getCurrentSession(basePath?: string): Promise<string | null> {
|
|
try {
|
|
const raw = await readFile(currentSessionPath(basePath), 'utf-8');
|
|
return raw.trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getSessionJson(sessionId: string, basePath?: string): Promise<SessionJson | null> {
|
|
return readJsonFile<SessionJson>(sessionJsonPath(sessionId, basePath));
|
|
}
|
|
|
|
export async function getIndex(basePath?: string): Promise<IndexJson | null> {
|
|
return readJsonFile<IndexJson>(indexJsonPath(basePath));
|
|
}
|
|
|
|
async function writeIndex(entries: IndexEntry[], basePath?: string): Promise<void> {
|
|
await ensureDir(runsDir(basePath));
|
|
await writeFile(indexJsonPath(basePath), JSON.stringify({ entries }, null, 2), 'utf-8');
|
|
}
|
|
|
|
async function appendIndex(sessionId: string, task: string, basePath?: string): Promise<void> {
|
|
const existing = await getIndex(basePath);
|
|
const entry: IndexEntry = {
|
|
id: sessionId,
|
|
task,
|
|
status: 'in_progress',
|
|
record_count: 0,
|
|
start_time: isoNow(),
|
|
};
|
|
const entries = [entry, ...(existing?.entries ?? [])].slice(0, 100);
|
|
await writeIndex(entries, basePath);
|
|
}
|
|
|
|
async function updateIndexStatus(sessionId: string, status: string, basePath?: string): Promise<void> {
|
|
const idx = await getIndex(basePath);
|
|
if (!idx) return;
|
|
for (const e of idx.entries) {
|
|
if (e.id === sessionId) {
|
|
e.status = status;
|
|
}
|
|
}
|
|
await writeIndex(idx.entries, basePath);
|
|
}
|
|
|
|
export async function startSession(task: string, basePath?: string): Promise<StartSessionResult> {
|
|
const sessionId = generateSessionId();
|
|
const sDir = sessionDir(sessionId, basePath);
|
|
await ensureDir(sDir);
|
|
|
|
const session: SessionJson = {
|
|
session_id: sessionId,
|
|
task,
|
|
start_time: isoNow(),
|
|
status: 'in_progress',
|
|
expected_record_types: ['data', 'change', 'conversation'],
|
|
};
|
|
|
|
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
|
await writeFile(currentSessionPath(basePath), sessionId, 'utf-8');
|
|
await appendIndex(sessionId, task, basePath);
|
|
|
|
// L0 context recovery
|
|
const idx = await getIndex(basePath);
|
|
const recentActivity = idx?.entries.slice(0, 5) ?? [];
|
|
|
|
// L2 user correction scan
|
|
const allCorrections = await scanAllTrailsForCorrections(basePath);
|
|
|
|
// Check for unfinished sessions
|
|
const unfinishedSessions = await findUnfinishedSessions(basePath);
|
|
|
|
return {
|
|
sessionId,
|
|
contextSummary: {
|
|
recentActivity,
|
|
userCorrections: allCorrections,
|
|
unfinishedSessions,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function findUnfinishedSessions(basePath?: string): Promise<SessionJson[]> {
|
|
const rDir = runsDir(basePath);
|
|
if (!existsSync(rDir)) return [];
|
|
|
|
const entries = await readdir(rDir, { withFileTypes: true });
|
|
const unfinished: SessionJson[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const sess = await getSessionJson(entry.name, basePath);
|
|
if (sess && sess.status === 'in_progress') {
|
|
unfinished.push(sess);
|
|
}
|
|
}
|
|
|
|
return unfinished;
|
|
}
|
|
|
|
async function scanAllTrailsForCorrections(basePath?: string): Promise<UserCorrectionRecord[]> {
|
|
const rDir = runsDir(basePath);
|
|
if (!existsSync(rDir)) return [];
|
|
|
|
const entries = await readdir(rDir, { withFileTypes: true });
|
|
const corrections: UserCorrectionRecord[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const lines = await readLines(trailPath(entry.name, basePath));
|
|
for (const line of lines) {
|
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
|
if (record?.action_type === 'user_correction') {
|
|
corrections.push(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also scan audit_pending.jsonl
|
|
const pendingLines = await readLines(auditPendingPath(basePath));
|
|
for (const line of pendingLines) {
|
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
|
if (record?.action_type === 'user_correction') {
|
|
corrections.push(record);
|
|
}
|
|
}
|
|
|
|
return corrections;
|
|
}
|
|
|
|
export async function endSession(basePath?: string): Promise<EndSessionResult | null> {
|
|
const sessionId = await getCurrentSession(basePath);
|
|
if (!sessionId) return null;
|
|
|
|
const sDir = sessionDir(sessionId, basePath);
|
|
await ensureDir(sDir);
|
|
|
|
// Collect remaining buffer data
|
|
const bufferLines = await readLines(auditBufferPath(basePath));
|
|
const pendingLines = await readLines(auditPendingPath(basePath));
|
|
const allRemaining = [...bufferLines, ...pendingLines];
|
|
|
|
// Append to audit_trail.jsonl
|
|
const trail = trailPath(sessionId, basePath);
|
|
if (allRemaining.length > 0) {
|
|
await appendFile(trail, allRemaining.join('\n') + '\n', 'utf-8');
|
|
}
|
|
|
|
// Clear buffer files
|
|
await clearFile(auditBufferPath(basePath));
|
|
await clearFile(auditPendingPath(basePath));
|
|
|
|
// Read current trail for stats
|
|
const trailLines = await readLines(trail);
|
|
|
|
// Extract user_correction records
|
|
const corrections: UserCorrectionRecord[] = [];
|
|
for (const line of trailLines) {
|
|
const record = tryParseJson<UserCorrectionRecord>(line);
|
|
if (record?.action_type === 'user_correction') {
|
|
corrections.push(record);
|
|
}
|
|
}
|
|
|
|
// Integrity checks
|
|
const integrity: IntegrityCheck[] = [
|
|
{
|
|
check: 'Audit records exist',
|
|
passed: trailLines.length > 0,
|
|
detail: trailLines.length > 0 ? `${trailLines.length} records` : 'No audit records found',
|
|
},
|
|
{
|
|
check: 'File modifications tracked',
|
|
passed: trailLines.some((l) => {
|
|
const r = tryParseJson<AuditTrailEntry>(l);
|
|
return r && (r.tool === 'Write' || r.tool === 'Edit');
|
|
}),
|
|
detail: 'Checking for Write/Edit tool entries',
|
|
},
|
|
{
|
|
check: 'User corrections persisted',
|
|
passed: corrections.every((c) => (c.persisted_to?.length ?? 0) > 0),
|
|
detail: corrections.length > 0
|
|
? `${corrections.length} corrections found, ${corrections.filter((c) => (c.persisted_to?.length ?? 0) > 0).length} persisted`
|
|
: 'No corrections to persist',
|
|
},
|
|
];
|
|
|
|
// Generate session summary
|
|
const summaryContent = generateSessionSummary(sessionId, trailLines, corrections);
|
|
const summaryFile = summaryPath(sessionId, basePath);
|
|
await writeFile(summaryFile, summaryContent, 'utf-8');
|
|
|
|
// Update session.json
|
|
const session = await getSessionJson(sessionId, basePath);
|
|
if (session) {
|
|
session.status = 'completed';
|
|
session.end_time = isoNow();
|
|
await writeFile(sessionJsonPath(sessionId, basePath), JSON.stringify(session, null, 2), 'utf-8');
|
|
await updateIndexStatus(sessionId, 'completed', basePath);
|
|
}
|
|
|
|
// Update index.json record count
|
|
const idx = await getIndex(basePath);
|
|
if (idx) {
|
|
for (const e of idx.entries) {
|
|
if (e.id === sessionId) {
|
|
e.record_count = trailLines.length;
|
|
e.status = 'completed';
|
|
}
|
|
}
|
|
await writeIndex(idx.entries, basePath);
|
|
}
|
|
|
|
// Clear .current_session
|
|
try {
|
|
await rm(currentSessionPath(basePath));
|
|
} catch {
|
|
// Ok if already gone
|
|
}
|
|
|
|
return {
|
|
sessionId,
|
|
integrity,
|
|
correctionCount: corrections.length,
|
|
summaryPath: summaryFile,
|
|
};
|
|
}
|
|
|
|
function generateSessionSummary(
|
|
sessionId: string,
|
|
trailLines: string[],
|
|
corrections: UserCorrectionRecord[],
|
|
): string {
|
|
const actions: string[] = [];
|
|
const outputs: string[] = [];
|
|
|
|
for (const line of trailLines) {
|
|
const record = tryParseJson<AuditTrailEntry>(line);
|
|
if (record) {
|
|
if (record.action_type) actions.push(record.action_type);
|
|
if (record.output) outputs.push(record.output);
|
|
}
|
|
}
|
|
|
|
return [
|
|
`# Session Summary | ${sessionId}`,
|
|
'',
|
|
`## Time: ${isoNow()}`,
|
|
`## Status: completed`,
|
|
'',
|
|
'## Completed work',
|
|
...actions.map((a) => `- ${a}`),
|
|
'',
|
|
'## Key conclusions',
|
|
...outputs.map((o) => `- ${o}`),
|
|
'',
|
|
'## User corrections',
|
|
...(corrections.length > 0
|
|
? corrections.map((c) => `- ${c.original_claim} → ${c.correction} (${c.principle_extracted})`)
|
|
: ['- None']),
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
export async function recoverSession(
|
|
level: number,
|
|
specificSessionId?: string,
|
|
basePath?: string,
|
|
): Promise<RecoverResult> {
|
|
const result: RecoverResult = { level, recentActivity: [], lastTrailEntries: [], userCorrections: [], conclusions: [], dailyAnomalies: [], dailyBacklog: [] };
|
|
|
|
// L0: index summary
|
|
const idx = await getIndex(basePath);
|
|
result.recentActivity = idx?.entries.slice(0, 5) ?? [];
|
|
|
|
if (level === 0) return result;
|
|
|
|
// L1: current session + last 3 trail entries
|
|
let activeSessionId = specificSessionId ?? await getCurrentSession(basePath);
|
|
if (activeSessionId) {
|
|
result.sessionId = activeSessionId;
|
|
const session = await getSessionJson(activeSessionId, basePath);
|
|
if (session) {
|
|
result.task = session.task;
|
|
}
|
|
|
|
const trailLines = await readLines(trailPath(activeSessionId, basePath));
|
|
result.lastTrailEntries = trailLines.slice(-3).map((l) => {
|
|
const r = tryParseJson<AuditTrailEntry>(l);
|
|
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
|
});
|
|
}
|
|
|
|
if (level === 1) return result;
|
|
|
|
// L2: user corrections + conclusions + daily anomalies
|
|
result.userCorrections = await scanAllTrailsForCorrections(basePath);
|
|
|
|
// Extract conclusions from trail entries
|
|
const allTrailLines = await readLines(trailPath(activeSessionId ?? '', basePath));
|
|
for (const line of allTrailLines) {
|
|
const record = tryParseJson<AuditTrailEntry>(line);
|
|
if (record?.output) {
|
|
result.conclusions.push(record.output);
|
|
}
|
|
}
|
|
|
|
// Read daily reports for anomalies + backlog
|
|
const dDir = dailyDir(basePath);
|
|
if (existsSync(dDir)) {
|
|
const dailyFiles = (await readdir(dDir)).filter((f) => f.endsWith('_daily.md')).sort().reverse();
|
|
if (dailyFiles.length > 0) {
|
|
const latest = await readFile(join(dDir, dailyFiles[0]!), 'utf-8');
|
|
const anomalies = latest.match(/## (?:四|4).*?[\s\S]*?(?=##|$)/);
|
|
if (anomalies) result.dailyAnomalies.push(anomalies[0]);
|
|
const backlog = latest.match(/## (?:六|6).*?[\s\S]*?(?=##|$)/);
|
|
if (backlog) result.dailyBacklog.push(backlog[0]);
|
|
}
|
|
}
|
|
|
|
if (level === 2) return result;
|
|
|
|
// L3: full trail + pending
|
|
if (level >= 3) {
|
|
if (activeSessionId) {
|
|
const fullLines = await readLines(trailPath(activeSessionId, basePath));
|
|
result.fullTrail = fullLines.map((l) => {
|
|
const r = tryParseJson<AuditTrailEntry>(l);
|
|
return r ?? { timestamp: '', record_type: '', action_type: '', input: l, output: l };
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function generateDailyReport(
|
|
targetDate?: string,
|
|
review?: boolean,
|
|
basePath?: string,
|
|
): Promise<DailyReport> {
|
|
const date = targetDate ?? isoDate();
|
|
const idx = await getIndex(basePath);
|
|
const rDir = runsDir(basePath);
|
|
|
|
const todayEntries = (idx?.entries ?? []).filter((e) => e.start_time.startsWith(date.slice(0, 4) + '-' + date.slice(4, 6) + '-' + date.slice(6, 8)));
|
|
|
|
let totalWriteEdit = 0;
|
|
let totalBash = 0;
|
|
let totalAuditBlocks = 0;
|
|
const changes: { time: string; target: string; detail: string }[] = [];
|
|
const feedback: { feedback: string; resolution: string; persistedTo: string }[] = [];
|
|
const anomalies: string[] = [];
|
|
|
|
for (const entry of todayEntries) {
|
|
const lines = await readLines(trailPath(entry.id, basePath));
|
|
for (const line of lines) {
|
|
const record = tryParseJson<AuditTrailEntry>(line);
|
|
if (!record) continue;
|
|
if (record.tool === 'Write' || record.tool === 'Edit') totalWriteEdit++;
|
|
if (record.tool === 'Bash') totalBash++;
|
|
if (record.action_type === 'audit_block') totalAuditBlocks++;
|
|
if (record.tool && (record.tool === 'Write' || record.tool === 'Edit') && record.files) {
|
|
changes.push({ time: record.timestamp, target: record.files.join(', '), detail: record.detail ?? '' });
|
|
}
|
|
if (record.action_type === 'user_correction') {
|
|
const uc = record as unknown as UserCorrectionRecord;
|
|
feedback.push({ feedback: uc.original_claim, resolution: uc.correction, persistedTo: (uc.persisted_to ?? []).join(', ') });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for anomalies.json
|
|
if (existsSync(rDir)) {
|
|
const sessionDirs = await readdir(rDir, { withFileTypes: true });
|
|
for (const d of sessionDirs) {
|
|
if (!d.isDirectory()) continue;
|
|
const anomPath = join(rDir, d.name, 'anomalies.json');
|
|
if (existsSync(anomPath)) {
|
|
const anomContent = await readFile(anomPath, 'utf-8');
|
|
anomalies.push(`[${d.name}] ${anomContent.slice(0, 200)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read previous day backlog
|
|
const prevDate = isoDate(new Date(Date.now() - 86400000));
|
|
let backlog: string[] = [];
|
|
const prevDailyPath = join(dailyDir(basePath), `${prevDate}_daily.md`);
|
|
if (existsSync(prevDailyPath)) {
|
|
const prevContent = await readFile(prevDailyPath, 'utf-8');
|
|
const m = prevContent.match(/## (?:六|6|明日待办)[\s\S]*?(?=##|$)/);
|
|
if (m) backlog = m[0].split('\n').filter((l) => l.trim().startsWith('-')).map((l) => l.replace(/^-\s*/, ''));
|
|
}
|
|
|
|
const reportPath = join(dailyDir(basePath), `${date}_daily.md`);
|
|
await ensureDir(dailyDir(basePath));
|
|
|
|
const sections = {
|
|
taskOverview: todayEntries.length > 0
|
|
? todayEntries.map((e) => `| ${e.id} | ${e.task} | ${e.status} | ${e.record_count} |`).join('\n')
|
|
: 'No activity',
|
|
operationStats: [
|
|
{ label: 'Write/Edit operations', count: totalWriteEdit },
|
|
{ label: 'Bash executions', count: totalBash },
|
|
{ label: 'Audit blocks', count: totalAuditBlocks },
|
|
],
|
|
changes,
|
|
userFeedback: feedback,
|
|
anomalyAlerts: anomalies,
|
|
backlogTracking: backlog,
|
|
integritySummary: [
|
|
`| All sessions have audit records | ${todayEntries.every((e) => e.record_count > 0) ? '✅' : '⚠️'} |`,
|
|
`| Audit blocks persisted | ${totalAuditBlocks > 0 ? '✅' : '⚠️'} |`,
|
|
`| User corrections persisted | ${feedback.every((f) => f.persistedTo.length > 0) ? '✅' : '⚠️'} |`,
|
|
].join('\n'),
|
|
};
|
|
|
|
const reportContent = generateDailyReportContent(date, sections);
|
|
await writeFile(reportPath, reportContent, 'utf-8');
|
|
|
|
// If review mode, also generate morning review
|
|
if (review) {
|
|
const reviewPath = join(dailyDir(basePath), `${date}_morning_review.md`);
|
|
const reviewContent = generateMorningReview(sections, date);
|
|
await writeFile(reviewPath, reviewContent, 'utf-8');
|
|
}
|
|
|
|
return { date, sections, path: reportPath };
|
|
}
|
|
|
|
function generateDailyReportContent(date: string, sections: DailyReport['sections']): string {
|
|
return [
|
|
`# Work Report | ${date}`,
|
|
'',
|
|
`> Auto-generated: ${isoNow()}`,
|
|
`> Data source: .boo/runs/index.json + session audit_trail`,
|
|
`> Coverage: ${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)} 00:00 — 23:59`,
|
|
'',
|
|
'---',
|
|
'',
|
|
'## I. Task Overview',
|
|
'',
|
|
'| Session ID | Task | Status | Records |',
|
|
'|-----------|------|--------|---------|',
|
|
sections.taskOverview,
|
|
'',
|
|
'---',
|
|
'',
|
|
'## II. Operation Stats',
|
|
'',
|
|
'| Metric | Count |',
|
|
'|--------|-------|',
|
|
...sections.operationStats.map((s) => `| ${s.label} | ${s.count} |`),
|
|
'',
|
|
'---',
|
|
'',
|
|
'## III. Change Records',
|
|
'',
|
|
...(sections.changes.length > 0
|
|
? ['| Time | Target | Detail |', '|------|--------|--------|', ...sections.changes.map((c) => `| ${c.time} | ${c.target} | ${c.detail} |`)]
|
|
: ['No changes recorded today.']),
|
|
'',
|
|
'---',
|
|
'',
|
|
'## IV. User Feedback & Corrections',
|
|
'',
|
|
...(sections.userFeedback.length > 0
|
|
? ['| Feedback | Resolution | Persisted To |', '|---------|------------|--------------|', ...sections.userFeedback.map((f) => `| ${f.feedback} | ${f.resolution} | ${f.persistedTo} |`)]
|
|
: ['None.']),
|
|
'',
|
|
'---',
|
|
'',
|
|
'## V. Anomaly Alerts',
|
|
'',
|
|
...(sections.anomalyAlerts.length > 0 ? sections.anomalyAlerts.map((a) => `- ${a}`) : ['None.']),
|
|
'',
|
|
'---',
|
|
'',
|
|
'## VI. Backlog Tracking',
|
|
'',
|
|
...(sections.backlogTracking.length > 0 ? sections.backlogTracking.map((b) => `- ${b}`) : ['None.']),
|
|
'',
|
|
'---',
|
|
'',
|
|
'## VII. Integrity Summary',
|
|
'',
|
|
'| Check | Result |',
|
|
'|-------|--------|',
|
|
sections.integritySummary,
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function generateMorningReview(sections: DailyReport['sections'], date: string): string {
|
|
const anomalies = sections.anomalyAlerts;
|
|
const hasUnhandledAnomalies = anomalies.some((a) => !a.includes('resolved'));
|
|
const hasUnpersistedFeedback = sections.userFeedback.some((f) => !f.persistedTo);
|
|
const hasIncompleteBacklog = sections.backlogTracking.length > 0;
|
|
|
|
return [
|
|
`# Morning Self-Review | ${date}`,
|
|
'',
|
|
`> Generated: ${isoNow()}`,
|
|
'',
|
|
'## Self-Correction Check',
|
|
'',
|
|
`- Unresolved anomalies: ${hasUnhandledAnomalies ? '⚠️ Yes — needs attention' : '✅ None'}`,
|
|
`- Unpersisted user feedback: ${hasUnpersistedFeedback ? '⚠️ Needs documentation' : '✅ All persisted'}`,
|
|
`- Outstanding backlog: ${hasIncompleteBacklog ? '⚠️ Carry-over items' : '✅ Clean slate'}`,
|
|
'',
|
|
'## Today\'s Recommended Priorities',
|
|
'',
|
|
...(sections.backlogTracking.length > 0
|
|
? sections.backlogTracking.map((b) => `- [ ] ${b} (carry-over)`)
|
|
: []),
|
|
'- [ ] Review yesterday\'s user feedback and persist any remaining corrections',
|
|
'- [ ] Continue highest-priority task from session overview',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
export async function ensureBooDirs(basePath?: string): Promise<void> {
|
|
await ensureDir(runsDir(basePath));
|
|
await ensureDir(dailyDir(basePath));
|
|
}
|
|
|
|
export async function writeAuditBuffer(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
|
await ensureDir(runsDir(basePath));
|
|
await appendLine(auditBufferPath(basePath), JSON.stringify(entry));
|
|
}
|
|
|
|
export async function writeAuditPending(entry: AuditTrailEntry, basePath?: string): Promise<void> {
|
|
await ensureDir(runsDir(basePath));
|
|
await appendLine(auditPendingPath(basePath), JSON.stringify(entry));
|
|
}
|