feat: add governance contract receipts

This commit is contained in:
artale 2026-06-28 18:26:50 +02:00
parent f106051e84
commit beb80490c8
5 changed files with 147 additions and 0 deletions

View File

@ -352,6 +352,14 @@ fable-agent plinius godmode "improve explanation quality"
- `--require <label:evidence>`
- `--context <text>`
- `--out <path>`
- `fable5 governance <task>`
- `--proposer <id>`
- `--approver <id>`
- `--quorum <n>`
- `--tie-break <id>`
- `--fallback <text>`
- `--risk <risk>`
- `--out <path>`
- `fable5 spec <task>`
- `--repo <path>`
- `--out <file>`

View File

@ -0,0 +1,41 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vitest";
import { createGovernanceContract, writeGovernanceContract } from "./governance-contract.js";
const now = new Date("2026-06-28T00:00:00.000Z");
describe("governance contract", () => {
it("marks a complete quorum contract ready", () => {
const receipt = createGovernanceContract({
task: "label Forgejo issue",
proposer: "agent:fable",
approvers: ["human:art", "human:sassi"],
quorum: 2,
tieBreak: "human:art",
fallback: "stop-and-request-human-review",
now,
});
expect(receipt.schema).toBe("fable.governance.contract.v1");
expect(receipt.decision).toBe("ready");
expect(receipt.reasons).toEqual([]);
});
it("requires independent approvers for quorum", () => {
const receipt = createGovernanceContract({ task: "deploy", proposer: "agent:fable", approvers: ["agent:fable"], quorum: 1, risk: "high", now });
expect(receipt.decision).toBe("blocked");
expect(receipt.reasons).toContain("proposer cannot approve own task");
});
it("writes a receipt", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "governance-"));
const file = path.join(dir, "contract.json");
writeGovernanceContract(file, createGovernanceContract({ task: "triage", proposer: "agent:fable", approvers: ["human:art"], quorum: 1, now }));
expect(fs.readFileSync(file, "utf-8")).toContain("fable.governance.contract.v1");
});
});

View File

@ -0,0 +1,66 @@
import * as fs from "node:fs";
import * as path from "node:path";
export type GovernanceDecision = "ready" | "needs_approval" | "blocked";
export interface GovernanceContractOptions {
task: string;
proposer: string;
approvers?: string[];
quorum?: number;
tieBreak?: string;
fallback?: string;
risk?: "low" | "review" | "high";
now?: Date;
}
export interface GovernanceContractReceipt {
schema: "fable.governance.contract.v1";
createdAt: string;
task: string;
proposer: string;
approvers: string[];
quorum: number;
tieBreak: string;
fallback: string;
risk: "low" | "review" | "high";
decision: GovernanceDecision;
reasons: string[];
}
export function createGovernanceContract(opts: GovernanceContractOptions): GovernanceContractReceipt {
const approvers = [...new Set(opts.approvers ?? [])].filter(Boolean);
const quorum = opts.quorum ?? (opts.risk === "low" ? 1 : 2);
const tieBreak = opts.tieBreak ?? "human-owner";
const fallback = opts.fallback ?? "stop-and-request-human-review";
const risk = opts.risk ?? "review";
const reasons: string[] = [];
if (!opts.task.trim()) reasons.push("missing task");
if (!opts.proposer.trim()) reasons.push("missing proposer");
if (approvers.includes(opts.proposer)) reasons.push("proposer cannot approve own task");
if (quorum < 1) reasons.push("quorum must be at least 1");
if (approvers.length < quorum) reasons.push("not enough approvers for quorum");
if (!tieBreak.trim()) reasons.push("missing tie-break");
if (!fallback.trim()) reasons.push("missing fallback");
return {
schema: "fable.governance.contract.v1",
createdAt: (opts.now ?? new Date()).toISOString(),
task: opts.task,
proposer: opts.proposer,
approvers,
quorum,
tieBreak,
fallback,
risk,
decision: reasons.length ? (risk === "high" ? "blocked" : "needs_approval") : "ready",
reasons,
};
}
export function writeGovernanceContract(file: string, receipt: GovernanceContractReceipt): string {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
return file;
}

View File

@ -96,6 +96,9 @@ export type { ModelAccessProbe, ModelAccessReceipt, ModelAccessReceiptOptions, M
export { createPlanReceipt, writePlanReceipt } from "./plan-receipt.js";
export type { PlanPhase, PlanReceipt, PlanReceiptOptions } from "./plan-receipt.js";
export { createGovernanceContract, writeGovernanceContract } from "./governance-contract.js";
export type { GovernanceContractOptions, GovernanceContractReceipt, GovernanceDecision } from "./governance-contract.js";
export { createContextWorkspaceReceipt, writeContextWorkspaceReceipt } from "./context-workspace-receipt.js";
export type { ContextLayer, ContextStageContract, ContextWorkspaceReceipt, ContextWorkspaceReceiptOptions } from "./context-workspace-receipt.js";

View File

@ -2269,6 +2269,35 @@ fable
if (receipt.status === "rejected") process.exit(1);
});
fable
.command("governance <task>")
.description("Create a governance contract receipt with proposer, approvers, quorum, tie-break, and fallback")
.requiredOption("--proposer <id>", "Proposer id, e.g. agent:fable")
.option("--approver <id>", "Approver id; repeatable", (v, p: string[] = []) => [...p, v], [])
.option("--quorum <n>", "Required approval count", (v) => Number(v))
.option("--tie-break <id>", "Tie-break authority")
.option("--fallback <text>", "Fallback when quorum/policy fails")
.option("--risk <risk>", "low|review|high", "review")
.option("--out <path>", "Receipt output path")
.action(async (task: string, opts: { proposer: string; approver?: string[]; quorum?: number; tieBreak?: string; fallback?: string; risk?: "low" | "review" | "high"; out?: string }) => {
const { createGovernanceContract, writeGovernanceContract } = await import("./fable5/governance-contract.js");
const receipt = createGovernanceContract({
task,
proposer: opts.proposer,
approvers: opts.approver,
quorum: opts.quorum,
tieBreak: opts.tieBreak,
fallback: opts.fallback,
risk: opts.risk,
});
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const out = opts.out ?? path.join(".fable", "governance", stamp + ".json");
writeGovernanceContract(out, receipt);
console.log(JSON.stringify(receipt, null, 2));
console.log("\n Receipt: " + out + "\n");
if (receipt.decision === "blocked") process.exit(1);
});
fable
.command("spec <task>")
.description("Generate a six-section anti-hallucination spec with verifier gates")