// Low-level state file operations with in-memory cache. No tool dependency. import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs" import { join, dirname } from "path" import type { ProjectState, LogEntry, AgenteamConfig, DebugLogEntry, LogSource } from "./types.ts" import { EMPTY_STATE, DEFAULT_CONFIG } from "./types.ts" const LOG_CAP = 10 const _stateCache = new Map() function findProjectRoot(hint: string): string | null { if (hint !== "/" && existsSync(join(hint, "agenteam.config.json"))) return hint const cwd = process.cwd() if (cwd !== "/" && existsSync(join(cwd, "agenteam.config.json"))) return cwd for (const dir of [hint, cwd]) { if (dir === "/") continue let cur = dir for (let i = 0; i < 20; i++) { if (existsSync(join(cur, "agenteam.config.json"))) return cur const parent = dirname(cur) if (parent === cur) break cur = parent } } return null } export function resolveWorktree(worktree: string): string { const found = findProjectRoot(worktree) if (found) return found return worktree === "/" ? process.cwd() : worktree } function statePath(worktree: string): string { return join(resolveWorktree(worktree), ".agenteam", "state.json") } function configPath(worktree: string): string { return join(resolveWorktree(worktree), "agenteam.config.json") } export function readState(worktree: string): ProjectState { const wt = resolveWorktree(worktree) // Cache hit — return shallow copy to prevent direct mutation const cached = _stateCache.get(wt) if (cached) return JSON.parse(JSON.stringify(cached)) // Cache miss — read from disk (only on first access) const p = statePath(wt) let state: ProjectState if (!existsSync(p)) { state = { ...EMPTY_STATE } } else { try { state = JSON.parse(readFileSync(p, "utf-8")) as ProjectState } catch { state = { ...EMPTY_STATE } } } _stateCache.set(wt, state) return { ...state } } export function writeState(worktree: string, state: ProjectState): void { const wt = resolveWorktree(worktree) // Update cache immediately (sync) _stateCache.set(wt, JSON.parse(JSON.stringify(state))) // Write to disk synchronously — deferred writes cause cache-disk desync const p = statePath(wt) const dir = dirname(p) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) try { writeFileSync(p, JSON.stringify(state, null, 2)) } catch (err) { _stateCache.delete(wt) appendDebugLog(wt, { ts: Date.now(), source: "system", event: "state.write_failed", payload: String(err), }) } } function appendToLogFile(worktree: string, entry: LogEntry): void { const p = join(resolveWorktree(worktree), ".agenteam", "log.jsonl") const dir = dirname(p) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) appendFileSync(p, JSON.stringify(entry) + "\n") } export function appendLog( worktree: string, state: ProjectState, entry: Omit, ): ProjectState { const fullEntry = { ts: Date.now(), ...entry } appendToLogFile(worktree, fullEntry) const full = [...state.log, fullEntry] return { ...state, log: full.length > LOG_CAP ? full.slice(-LOG_CAP) : full, } } export function appendDebugLog(worktree: string, entry: DebugLogEntry): void { const p = join(resolveWorktree(worktree), ".agenteam", "debug.jsonl") const dir = dirname(p) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) appendFileSync(p, JSON.stringify(entry) + "\n") } export function debugLog(source: LogSource, event: string, payload?: unknown): void { const entry: DebugLogEntry = { ts: Date.now(), source, event, payload, } appendDebugLog(process.cwd(), entry) } const _configCache = new Map() export function loadConfig(worktree: string): AgenteamConfig { const wt = resolveWorktree(worktree) if (_configCache.has(wt)) return _configCache.get(wt)! const p = configPath(wt) let result: AgenteamConfig if (!existsSync(p)) { result = DEFAULT_CONFIG } else { try { const raw = JSON.parse(readFileSync(p, "utf-8")) result = { models: { ...DEFAULT_CONFIG.models, ...raw.models }, limits: { ...DEFAULT_CONFIG.limits, ...raw.limits }, triage: { ...DEFAULT_CONFIG.triage, ...raw.triage }, review: { ...DEFAULT_CONFIG.review, ...raw.review }, adversary_model: raw.adversary_model ?? DEFAULT_CONFIG.adversary_model, memory: { ...DEFAULT_CONFIG.memory, ...raw.memory }, } } catch { result = DEFAULT_CONFIG } } _configCache.set(wt, result) return result }