diff --git a/COMMANDS.md b/COMMANDS.md index 5e34521..8c99963 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -352,6 +352,14 @@ fable-agent plinius godmode "improve explanation quality" - `--require ` - `--context ` - `--out ` +- `fable5 governance ` + - `--proposer ` + - `--approver ` + - `--quorum ` + - `--tie-break ` + - `--fallback ` + - `--risk ` + - `--out ` - `fable5 spec ` - `--repo ` - `--out ` diff --git a/src/fable5/governance-contract.test.ts b/src/fable5/governance-contract.test.ts new file mode 100644 index 0000000..4e96e08 --- /dev/null +++ b/src/fable5/governance-contract.test.ts @@ -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"); + }); +}); diff --git a/src/fable5/governance-contract.ts b/src/fable5/governance-contract.ts new file mode 100644 index 0000000..aa5ab5d --- /dev/null +++ b/src/fable5/governance-contract.ts @@ -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; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index ab54bd7..8b10376 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -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"; diff --git a/src/index.ts b/src/index.ts index e63323d..edf04c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2269,6 +2269,35 @@ fable if (receipt.status === "rejected") process.exit(1); }); +fable + .command("governance ") + .description("Create a governance contract receipt with proposer, approvers, quorum, tie-break, and fallback") + .requiredOption("--proposer ", "Proposer id, e.g. agent:fable") + .option("--approver ", "Approver id; repeatable", (v, p: string[] = []) => [...p, v], []) + .option("--quorum ", "Required approval count", (v) => Number(v)) + .option("--tie-break ", "Tie-break authority") + .option("--fallback ", "Fallback when quorum/policy fails") + .option("--risk ", "low|review|high", "review") + .option("--out ", "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 ") .description("Generate a six-section anti-hallucination spec with verifier gates")