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>`
|
- `--require <label:evidence>`
|
||||||
- `--context <text>`
|
- `--context <text>`
|
||||||
- `--out <path>`
|
- `--out <path>`
|
||||||
|
- `fable5 governance <task>`
|
||||||
|
- `--proposer <id>`
|
||||||
|
- `--approver <id>`
|
||||||
|
- `--quorum <n>`
|
||||||
|
- `--tie-break <id>`
|
||||||
|
- `--fallback <text>`
|
||||||
|
- `--risk <risk>`
|
||||||
|
- `--out <path>`
|
||||||
- `fable5 spec <task>`
|
- `fable5 spec <task>`
|
||||||
- `--repo <path>`
|
- `--repo <path>`
|
||||||
- `--out <file>`
|
- `--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 { createPlanReceipt, writePlanReceipt } from "./plan-receipt.js";
|
||||||
export type { PlanPhase, PlanReceipt, PlanReceiptOptions } 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 { createContextWorkspaceReceipt, writeContextWorkspaceReceipt } from "./context-workspace-receipt.js";
|
||||||
export type { ContextLayer, ContextStageContract, ContextWorkspaceReceipt, ContextWorkspaceReceiptOptions } 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);
|
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
|
fable
|
||||||
.command("spec <task>")
|
.command("spec <task>")
|
||||||
.description("Generate a six-section anti-hallucination spec with verifier gates")
|
.description("Generate a six-section anti-hallucination spec with verifier gates")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue