feat: add governance contract receipts
This commit is contained in:
parent
f106051e84
commit
beb80490c8
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
29
src/index.ts
29
src/index.ts
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue