import { createHash } from "node:crypto"; import { lstat, readFile, realpath } from "node:fs/promises"; import { basename, isAbsolute, relative, resolve, sep } from "node:path"; import type { MaintenanceMode } from "./types.ts"; export const MAINTENANCE_LEDGER_TYPE = "pi-agents-md:maintenance"; const MAX_FINGERPRINT_BYTES = 1024 * 1024; type LedgerStatus = | "prompted" | "settled-pending" | "review" | "scanned" | "applied" | "blocked"; export interface SourceMutation { readonly root: string; readonly path: string; readonly fingerprint: string; } export interface MaintenanceLedgerEntry { readonly version: 1; readonly fingerprint: string; readonly path: string; readonly scopes: readonly string[]; readonly mode: MaintenanceMode; readonly status: LedgerStatus; readonly retryCount: number; readonly planId?: string; readonly reason?: string; } export interface CueRegistration { readonly entry: MaintenanceLedgerEntry; readonly cue: string; } export class MaintenanceLedger { private readonly entries = new Map(); private readonly fingerprintsThisRun = new Set(); private readonly pathsThisRun = new Set(); private cuesThisRun = 0; beginRun(): void { this.cuesThisRun = 0; this.fingerprintsThisRun.clear(); this.pathsThisRun.clear(); } restore(entries: readonly unknown[]): void { this.entries.clear(); this.fingerprintsThisRun.clear(); this.pathsThisRun.clear(); this.cuesThisRun = 0; for (const candidate of entries) { const entry = parseSessionLedgerEntry(candidate); if (!entry) continue; this.entries.delete(entry.fingerprint); this.entries.set(entry.fingerprint, entry); } } registerCue( mutation: SourceMutation, scopes: readonly string[], mode: Exclude, maxCuesPerAgentRun: number, maxAffectedFiles: number, ): CueRegistration | undefined { if ( maxCuesPerAgentRun <= 0 || maxAffectedFiles <= 0 || this.cuesThisRun >= maxCuesPerAgentRun || (!this.pathsThisRun.has(mutation.path) && this.pathsThisRun.size >= maxAffectedFiles) || this.fingerprintsThisRun.has(mutation.fingerprint) || this.entries.has(mutation.fingerprint) ) { return undefined; } const entry = freezeEntry({ version: 1, fingerprint: mutation.fingerprint, path: mutation.path, scopes: uniqueScopes(scopes), mode, status: mode === "settled" ? "settled-pending" : mode === "review" ? "review" : "prompted", retryCount: 0, }); this.entries.set(entry.fingerprint, entry); this.fingerprintsThisRun.add(entry.fingerprint); this.pathsThisRun.add(entry.path); this.cuesThisRun += 1; return { entry, cue: formatCue(entry.scopes), }; } update( fingerprint: string, status: LedgerStatus, details: Partial< Pick > = {}, ): MaintenanceLedgerEntry | undefined { const previous = this.entries.get(fingerprint); if (!previous) return undefined; const entry = freezeEntry({ ...previous, ...details, status }); this.entries.delete(fingerprint); this.entries.set(fingerprint, entry); return entry; } getActive(): MaintenanceLedgerEntry | undefined { return [...this.entries.values()] .reverse() .find( (entry) => entry.status === "prompted" || entry.status === "settled-pending" || entry.status === "scanned", ); } getPendingSettled(): MaintenanceLedgerEntry | undefined { return [...this.entries.values()] .reverse() .find((entry) => entry.status === "settled-pending"); } get(fingerprint: string): MaintenanceLedgerEntry | undefined { return this.entries.get(fingerprint); } } export async function fingerprintSourceMutation( rootPath: string, targetInput: string, ): Promise { let root: string; let target: string; try { root = await realpath(rootPath); target = await realpath(resolve(rootPath, targetInput)); } catch { return undefined; } if (!isContained(root, target) || isInstructionPath(target)) return undefined; try { const info = await lstat(target); if (info.isSymbolicLink() || !info.isFile()) return undefined; const fingerprintInput = info.size <= MAX_FINGERPRINT_BYTES ? await readFile(target) : `${info.size}:${info.mtimeMs}`; const path = toPortablePath(relative(root, target)); return Object.freeze({ root, path, fingerprint: sha256(`${path}\u0000${sha256(fingerprintInput)}`), }); } catch { return undefined; } } export function deriveAffectedScopes( changedPath: string, selectedScopes: readonly string[], ): readonly string[] { const directory = scopeForPath(changedPath); const candidates = selectedScopes.filter((scope) => isScopeAncestor(scope, directory), ); const deepest = Math.max(...candidates.map(scopeDepth), 0); const affected = candidates.filter((scope) => scopeDepth(scope) === deepest); return Object.freeze(affected.length > 0 ? affected : ["."]); } export function isInstructionPath(path: string): boolean { return /^agents(?:\.override)?\.md$/i.test(basename(path)); } export function formatCue(scopes: readonly string[]): string { return [ "[AGENTS.md maintenance]", `Changed scope: ${scopes.join(", ")}`, "Before completing this task, synchronize only affected managed AGENTS.md blocks. Use agents_scan_changed, then agents_apply_managed. Write concise, practical Markdown that reflects the changed scope. Do not edit source files or unowned AGENTS.md text.", ].join("\n"); } function parseSessionLedgerEntry( value: unknown, ): MaintenanceLedgerEntry | undefined { if ( !isRecord(value) || value.type !== "custom" || value.customType !== MAINTENANCE_LEDGER_TYPE ) { return undefined; } return parseLedgerData(value.data); } function parseLedgerData(value: unknown): MaintenanceLedgerEntry | undefined { if (!isRecord(value) || value.version !== 1) return undefined; if ( typeof value.fingerprint !== "string" || typeof value.path !== "string" || !Array.isArray(value.scopes) || !value.scopes.every( (scope) => typeof scope === "string" && isSafeScope(scope), ) || !isMaintenanceMode(value.mode) || !isLedgerStatus(value.status) ) { return undefined; } if (value.planId !== undefined && typeof value.planId !== "string") return undefined; if (value.reason !== undefined && typeof value.reason !== "string") return undefined; if (value.retryCount !== undefined && !isNonNegativeInteger(value.retryCount)) return undefined; return freezeEntry({ version: 1, fingerprint: value.fingerprint, path: value.path, scopes: value.scopes, mode: value.mode, status: value.status, retryCount: value.retryCount ?? 0, planId: value.planId, reason: value.reason, }); } function freezeEntry(entry: MaintenanceLedgerEntry): MaintenanceLedgerEntry { return Object.freeze({ ...entry, scopes: Object.freeze([...entry.scopes]) }); } function isMaintenanceMode(value: unknown): value is MaintenanceMode { return ( value === "prompt" || value === "settled" || value === "review" || value === "off" ); } function isLedgerStatus(value: unknown): value is LedgerStatus { return ( value === "prompted" || value === "settled-pending" || value === "review" || value === "scanned" || value === "applied" || value === "blocked" ); } function uniqueScopes(scopes: readonly string[]): readonly string[] { const values = [...new Set(scopes)].filter(isSafeScope).sort(compareScopes); return Object.freeze(values.length > 0 ? values : ["."]); } function scopeForPath(path: string): string { const portable = toPortablePath(path); const index = portable.lastIndexOf("/"); return index === -1 ? "." : portable.slice(0, index); } function scopeDepth(scope: string): number { return scope === "." ? 0 : scope.split("/").length; } function compareScopes(left: string, right: string): number { return scopeDepth(left) - scopeDepth(right) || left.localeCompare(right); } function isScopeAncestor(scope: string, target: string): boolean { return scope === "." || scope === target || target.startsWith(`${scope}/`); } function isSafeScope(scope: string): boolean { if (scope === ".") return true; if (scope.length === 0 || scope.startsWith("/") || scope.includes("//")) return false; return scope .split("/") .every((part) => part.length > 0 && part !== "." && part !== ".."); } function isContained(root: string, target: string): boolean { const child = relative(root, target); return ( child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`)) ); } function toPortablePath(path: string): string { return path.split(sep).join("/"); } function sha256(value: string | Uint8Array): string { return createHash("sha256").update(value).digest("hex"); } function isNonNegativeInteger(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }