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:
parent
b654b72905
commit
d4e2d3c36b
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue