From d4e2d3c36bbeb2273d8558bbbdb024d5a93485e3 Mon Sep 17 00:00:00 2001 From: artale Date: Sat, 4 Jul 2026 17:07:48 +0200 Subject: [PATCH] feat: add interrupt-gate, flat-ledger, repo-mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three core harness components from the Dario blueprint: 1. interrupt-gate: stdin TTY gate for human-in-the-loop approval before agent actions touch live branches. 2. flat-ledger: JSONL state file outside LLM context — agent reads current state at every turn instead of remembering it. 3. repo-mapper: auto-generates minified repo tree map for system prompt injection before any code generation. Blueprint: hardware interrupt, state materialization, absolute environment mapping. --- src/fable5/flat-ledger.ts | 73 +++++++++++++++++++++++++++ src/fable5/index.ts | 6 +++ src/fable5/interrupt-gate.ts | 53 ++++++++++++++++++++ src/fable5/repo-mapper.ts | 95 ++++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 src/fable5/flat-ledger.ts create mode 100644 src/fable5/interrupt-gate.ts create mode 100644 src/fable5/repo-mapper.ts diff --git a/src/fable5/flat-ledger.ts b/src/fable5/flat-ledger.ts new file mode 100644 index 0000000..033647f --- /dev/null +++ b/src/fable5/flat-ledger.ts @@ -0,0 +1,73 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +const LEDGER_DIR = path.join(os.homedir(), ".fable-agent", "ledger"); + +export interface LedgerEntry { + step: number; + timestamp: string; + agent: string; + action: string; + target: string; + result: "ok" | "fail" | "skip"; + outputHash?: string; + worktree?: string; + durationMs: number; +} + +export class FlatLedger { + private filePath: string; + private entries: LedgerEntry[] = []; + + constructor(sessionName: string) { + fs.mkdirSync(LEDGER_DIR, { recursive: true }); + this.filePath = path.join(LEDGER_DIR, `${sessionName.replace(/[^a-zA-Z0-9._-]/g, "-")}.jsonl`); + if (fs.existsSync(this.filePath)) { + const raw = fs.readFileSync(this.filePath, "utf-8").trim(); + if (raw) { + this.entries = raw.split("\n").filter(Boolean).map((l) => JSON.parse(l)); + } + } + } + + append(entry: Omit): LedgerEntry { + const full: LedgerEntry = { step: this.entries.length + 1, ...entry }; + this.entries.push(full); + fs.appendFileSync(this.filePath, JSON.stringify(full) + "\n", "utf-8"); + return full; + } + + readState(): LedgerEntry[] { + return [...this.entries]; + } + + lastStep(): LedgerEntry | null { + return this.entries.length > 0 ? this.entries[this.entries.length - 1] : null; + } + + lastActionResult(): "ok" | "fail" | "skip" | null { + const last = this.lastStep(); + return last ? last.result : null; + } + + summary(): string { + const total = this.entries.length; + const ok = this.entries.filter((e) => e.result === "ok").length; + const fail = this.entries.filter((e) => e.result === "fail").length; + const skip = this.entries.filter((e) => e.result === "skip").length; + return `[ledger] ${total} steps — ${ok} ok, ${fail} fail, ${skip} skip`; + } + + clear(): void { + this.entries = []; + fs.writeFileSync(this.filePath, "", "utf-8"); + } + + static sessionNameFromTask(task: string): string { + return task + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .slice(0, 40); + } +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 802bb6d..38d9d39 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -47,6 +47,12 @@ export type { GateReceiptOptions, GateReceiptValidation, ZteReceipt, ZteSpec, Zt export { runVerifyGate, verifyAgentTask, verifyAndClean } from "./verify-gate.js"; export type { VerifyGateSpec, VerifyGateResult } from "./verify-gate.js"; +export { humanInterruptGate, requireApproval } from "./interrupt-gate.js"; +export type { InterruptDecision, InterruptGateOptions } from "./interrupt-gate.js"; +export { FlatLedger } from "./flat-ledger.js"; +export type { LedgerEntry } from "./flat-ledger.js"; +export { buildRepoMap, repoTreeString, minifiedTree, gitLsTree } from "./repo-mapper.js"; +export type { RepoMap, RepoNode } from "./repo-mapper.js"; export { formatCapabilityReport, loadFactoryCapabilities, parseFactoryCapabilities, probeFactoryCapabilities } from "./factory-capabilities.js"; export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService } from "./factory-capabilities.js"; diff --git a/src/fable5/interrupt-gate.ts b/src/fable5/interrupt-gate.ts new file mode 100644 index 0000000..1314a76 --- /dev/null +++ b/src/fable5/interrupt-gate.ts @@ -0,0 +1,53 @@ +import { createInterface } from "node:readline"; + +export type InterruptDecision = "allow" | "deny" | "skip"; + +export interface InterruptGateOptions { + prompt?: string; + timeoutMs?: number; + defaultValue?: InterruptDecision; +} + +const DEFAULT_PROMPT = "[harness] Approve this agent action? (a=allow / d=deny / s=skip / q=quit): "; + +function readStdinLine(prompt: string, timeoutMs: number): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + const timer = setTimeout(() => { + rl.close(); + resolve(""); + }, timeoutMs); + rl.question(prompt, (answer) => { + clearTimeout(timer); + rl.close(); + resolve(answer.trim().toLowerCase()); + }); + }); +} + +export async function humanInterruptGate(opts: InterruptGateOptions = {}): Promise { + const prompt = opts.prompt ?? DEFAULT_PROMPT; + const timeoutMs = opts.timeoutMs ?? 30_000; + const defaultValue = opts.defaultValue ?? "allow"; + + if (!process.stdin.isTTY) return defaultValue; + + const answer = await readStdinLine(prompt, timeoutMs); + + if (answer === "" || answer === "a") return "allow"; + if (answer === "d") return "deny"; + if (answer === "s") return "skip"; + if (answer === "q") { + console.error("[harness] Quit requested. Exiting."); + process.exit(0); + } + return defaultValue; +} + +export async function requireApproval(label: string, opts: InterruptGateOptions = {}): Promise { + const decision = await humanInterruptGate({ + prompt: `[harness] ${label} — approve? (a=allow / d=deny / q=quit): `, + ...opts, + }); + return decision === "allow"; +} diff --git a/src/fable5/repo-mapper.ts b/src/fable5/repo-mapper.ts new file mode 100644 index 0000000..fb0a7b7 --- /dev/null +++ b/src/fable5/repo-mapper.ts @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export interface RepoMap { + root: string; + tree: RepoNode[]; + fileCount: number; + dirCount: number; + totalBytes: number; +} + +export interface RepoNode { + path: string; + type: "file" | "dir"; + size: number; + depth: number; +} + +export function buildRepoMap(root: string, maxDepth = 4, excludeDirs = ["node_modules", ".git", "dist", ".pi"]): RepoMap { + const tree: RepoNode[] = []; + let fileCount = 0; + let dirCount = 0; + let totalBytes = 0; + + function walk(dir: string, depth: number) { + if (depth > maxDepth) return; + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return; + } + for (const entry of entries.sort()) { + const full = path.join(dir, entry); + let stat: fs.Stats; + try { + stat = fs.statSync(full); + } catch { + continue; + } + const rel = path.relative(root, full).replace(/\\/g, "/"); + if (stat.isDirectory()) { + if (excludeDirs.includes(entry)) continue; + dirCount++; + tree.push({ path: rel + "/", type: "dir", size: 0, depth }); + walk(full, depth + 1); + } else { + fileCount++; + totalBytes += stat.size; + tree.push({ path: rel, type: "file", size: stat.size, depth }); + } + } + } + + if (!fs.existsSync(root)) { + return { root, tree: [], fileCount: 0, dirCount: 0, totalBytes: 0 }; + } + walk(root, 0); + return { root, tree, fileCount, dirCount, totalBytes }; +} + +export function repoTreeString(map: RepoMap): string { + const lines: string[] = []; + for (const node of map.tree) { + const indent = " ".repeat(node.depth); + const icon = node.type === "dir" ? "📁" : "📄"; + const size = node.type === "file" ? ` (${node.size}B)` : ""; + lines.push(`${indent}${icon} ${node.path}${size}`); + } + lines.push(`\n${map.fileCount} files, ${map.dirCount} dirs, ${(map.totalBytes / 1024).toFixed(1)} KB`); + return lines.join("\n"); +} + +export function minifiedTree(map: RepoMap): string { + const parts = map.tree + .filter((n) => n.type === "file") + .map((n) => n.path); + const top = map.tree + .filter((n) => n.type === "dir" && n.depth === 0) + .map((n) => n.path); + return `[${map.fileCount}f ${map.dirCount}d ${(map.totalBytes / 1024).toFixed(0)}KB] ${top.join(" ")} files: ${parts.slice(0, 30).join(" ")}${parts.length > 30 ? ` +${parts.length - 30} more` : ""}`; +} + +export function gitLsTree(root: string): string { + try { + return execFileSync("git", ["ls-tree", "-r", "--name-only", "HEAD"], { + cwd: root, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + return repoTreeString(buildRepoMap(root, 6)); + } +}