feat: add interrupt-gate, flat-ledger, repo-mapper

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.
This commit is contained in:
artale 2026-07-04 17:07:48 +02:00
parent b654b72905
commit d4e2d3c36b
4 changed files with 227 additions and 0 deletions

73
src/fable5/flat-ledger.ts Normal file
View File

@ -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, "step">): 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);
}
}

View File

@ -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";

View File

@ -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<string> {
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<InterruptDecision> {
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<boolean> {
const decision = await humanInterruptGate({
prompt: `[harness] ${label} — approve? (a=allow / d=deny / q=quit): `,
...opts,
});
return decision === "allow";
}

95
src/fable5/repo-mapper.ts Normal file
View File

@ -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));
}
}