200 lines
6.9 KiB
TypeScript
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";
|
|
}
|
|
}
|