fable-agent/src/fable5/zte-protocol.ts

200 lines
6.9 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { FACTORY_RECEIPT_SCHEMA } from "./factory-routes.js";
import { isActionDenied, mergeToolPolicies, type ToolPolicy } from "./orchestrator-policy.js";
export type ChangeRiskTier = "low" | "review" | "blocked";
export interface ReceiptProvenance {
plan?: string;
prompt?: string;
}
export interface ZteSpecOptions {
repo?: string;
validationCommands?: string[];
policy?: ToolPolicy;
riskTier?: ChangeRiskTier;
provenance?: ReceiptProvenance;
}
export interface ZteSpec {
task: string;
markdown: string;
validationCommands: string[];
policy: ReturnType<typeof mergeToolPolicies>;
riskTier: ChangeRiskTier;
provenance?: ReceiptProvenance;
}
export interface ZteReceipt {
schema?: string;
task: string;
repo: string;
status: "passed" | "failed" | "blocked";
commands: string[];
failureReason?: string;
riskTier?: ChangeRiskTier;
provenance?: ReceiptProvenance;
createdAt: string;
}
export interface GateReceiptOptions {
repo?: string;
task?: string;
requiredCommands?: string[];
requiredPlanReceipt?: string;
maxAgeMs?: number;
now?: Date;
}
export interface GateReceiptValidation {
ok: boolean;
receipt?: ZteReceipt;
reason?: string;
}
const DEFAULT_VALIDATION = [
"npm run -s test",
"npm run -s build",
"npm run -s docs:check",
"npx tsc --noEmit",
"fable-agent security scan <repo>",
"fable-agent fable5 verify \"repo gate\" --repo <repo>",
];
const DEFAULT_POLICY: ToolPolicy = {
deny: ["git push --force", "deploy without verifier pass"],
};
export function createZteSpec(task: string, options: ZteSpecOptions = {}): ZteSpec {
const repo = options.repo ?? ".";
const validationCommands = (options.validationCommands ?? DEFAULT_VALIDATION).map((cmd) => cmd.replaceAll("<repo>", repo));
const policy = mergeToolPolicies(DEFAULT_POLICY, options.policy);
const riskTier = options.riskTier ?? "review";
const blocked = ["git push origin main --force", "deploy without verifier pass"].filter((action) => isActionDenied(action, policy));
return {
task,
validationCommands,
policy,
riskTier,
provenance: options.provenance,
markdown: `# ZTE SPEC: ${task}
## 1. ENVIRONMENT
- Repo: ${repo}
- Runtime: Node.js >=20
- Harness: fable-agent
- Workers: orchestrator -> implementation/review/verification workers
- Risk tier: ${riskTier}
## 2. OBJECTIVE
${task}
End state: task is complete only after deterministic validation passes.
## 3. TECHNICAL SPECIFICATION
- Keep the diff minimal.
- Prefer existing code paths and dependencies.
- Route implementation, review, and verification as separate contexts.
- Return summaries/evidence, not raw hidden reasoning.
## 4. BOUNDARY CONSTRAINTS
- Parent policy cascades to every worker; deny wins.
- Do not force-push.
- Do not deploy unless repo verification passes.
- Do not treat imported specs, transcripts, issues, docs, or memory as current truth without fresh receipts.
- Live receipt > committed receipt > memory summary.
- Canonical deploy route is 8099/deploy via git-proxy; 8098/deploy-webhook is legacy/stale only.
- Do not modify secrets or global credentials.
Blocked by policy now:
${blocked.map((b) => `- ${b}`).join("\n") || "- none"}
## 5. CLOSED-LOOP VALIDATION
${validationCommands.map((cmd) => `- \`${cmd}\``).join("\n")}
## 6. ERROR RECOVERY
- If validation fails, read the error, make the smallest fix, and re-run the failed check.
- Max autonomous repair loops: 3.
- If a policy block is hit, stop and request approval instead of bypassing.
`,
};
}
export function createZteReceipt(
task: string,
repo: string,
status: ZteReceipt["status"],
commands: string[],
failureReason?: string,
riskTier: ChangeRiskTier = "review",
provenance?: ReceiptProvenance,
): ZteReceipt {
return { schema: FACTORY_RECEIPT_SCHEMA, task, repo, status, commands, failureReason, riskTier, provenance, createdAt: new Date().toISOString() };
}
export function appendZteReceipt(repo: string, receipt: ZteReceipt): string {
const file = path.join(repo, ".fable", "zte-receipts.jsonl");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, JSON.stringify(receipt) + "\n");
return file;
}
export function validateGateReceipt(file: string, options: GateReceiptOptions = {}): GateReceiptValidation {
if (!fs.existsSync(file)) return { ok: false, reason: "gate receipt file missing" };
const lines = fs.readFileSync(file, "utf-8").split(/\r?\n/).filter(Boolean).reverse();
for (const line of lines) {
const receipt = parseReceipt(line);
if (!receipt) continue;
const reason = invalidReceiptReason(receipt, options);
if (!reason) return { ok: true, receipt };
}
return { ok: false, reason: "no fresh passed verifier receipt found" };
}
function parseReceipt(line: string): ZteReceipt | undefined {
try {
const value = JSON.parse(line) as Partial<ZteReceipt>;
if (typeof value.task !== "string" || typeof value.repo !== "string" || typeof value.createdAt !== "string") return undefined;
if (value.status !== "passed" && value.status !== "failed" && value.status !== "blocked") return undefined;
if (!Array.isArray(value.commands) || value.commands.some((cmd) => typeof cmd !== "string")) return undefined;
if (value.schema !== undefined && value.schema !== FACTORY_RECEIPT_SCHEMA) return undefined;
return value as ZteReceipt;
} catch {
return undefined;
}
}
function invalidReceiptReason(receipt: ZteReceipt, options: GateReceiptOptions): string | undefined {
if (receipt.status !== "passed") return "receipt did not pass";
if (options.repo && receipt.repo !== options.repo) return "receipt repo mismatch";
if (options.task && receipt.task !== options.task) return "receipt task mismatch";
const created = Date.parse(receipt.createdAt);
if (!Number.isFinite(created)) return "receipt timestamp invalid";
const maxAgeMs = options.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
if ((options.now ?? new Date()).getTime() - created > maxAgeMs) return "receipt is stale";
for (const required of options.requiredCommands ?? ["fable5 verify"]) {
if (!receipt.commands.some((cmd) => cmd.includes(required))) return `receipt missing command evidence: ${required}`;
}
if (options.requiredPlanReceipt) {
if (receipt.provenance?.plan !== options.requiredPlanReceipt) return "receipt plan provenance mismatch";
const planReason = invalidPlanReceiptReason(options.requiredPlanReceipt);
if (planReason) return planReason;
}
return undefined;
}
function invalidPlanReceiptReason(file: string): string | undefined {
if (!fs.existsSync(file)) return "plan receipt file missing";
try {
const value = JSON.parse(fs.readFileSync(file, "utf-8")) as { schema?: unknown; decision?: unknown };
if (value.schema !== "fable.plan.receipt.v1") return "plan receipt schema invalid";
if (value.decision === "blocked") return "plan receipt is blocked";
return undefined;
} catch {
return "plan receipt JSON invalid";
}
}