feat: add learning safety receipts

This commit is contained in:
artale 2026-07-01 14:30:26 +02:00
parent 1135ef4d7c
commit 208d27e2cf
5 changed files with 270 additions and 0 deletions

View File

@ -368,6 +368,24 @@ fable-agent plinius godmode "improve explanation quality"
- `--source <source>`
- `--threshold <n>`
- `--output <path>`
- `fable5 recovery-receipt`
- `--incident <text>`
- `--live-signal <text>`
- `--action-taken <text>`
- `--decision <decision>`
- `--stale-signal <text>`
- `--root-cause <text>`
- `--before <paths>`
- `--after <paths>`
- `--output <path>`
- `fable5 context-eval`
- `--task <text>`
- `--checks <json>`
- `--output <path>`
- `fable5 sleep-time-policy`
- `--output <path>`
- `fable5 context-repo-snapshot <root>`
- `--output <path>`
- `fable5 receipt-health`
- `--root <path>`
- `--window-minutes <n>`

View File

@ -96,6 +96,9 @@ export type { ModelAccessProbe, ModelAccessReceipt, ModelAccessReceiptOptions, M
export { createSkillHealthReceipt, writeSkillHealthReceipt } from "./skill-health.js";
export type { SkillHealthEntry, SkillHealthOptions, SkillHealthReceipt } from "./skill-health.js";
export { createContextEvalReceipt, createContextRepoSnapshotReceipt, createRecoveryReceipt, createSleepTimePolicyReceipt, writeLearningReceipt } from "./learning-receipts.js";
export type { ContextEvalReceipt, ContextRepoSnapshotReceipt, LearningDecision, RecoveryReceipt, SleepTimePolicyReceipt } from "./learning-receipts.js";
export { createPromptFingerprintReceipt, writePromptFingerprintReceipt } from "./prompt-fingerprint.js";
export type { PromptFingerprintFinding, PromptFingerprintReceipt } from "./prompt-fingerprint.js";

View File

@ -0,0 +1,69 @@
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 { createContextEvalReceipt, createContextRepoSnapshotReceipt, createRecoveryReceipt, createSleepTimePolicyReceipt, writeLearningReceipt } from "./learning-receipts.js";
describe("learning receipts", () => {
it("records recovery without deploy authority", () => {
const receipt = createRecoveryReceipt({
now: new Date("2026-07-01T00:00:00.000Z"),
incident: "stale host log disagreed with canary",
staleSignal: "host log said healthy",
liveSignal: "container canary failed",
rootCause: "container drift",
actionTaken: "refreshed skill_health.py and verified canary recovered",
receiptsBefore: [".fable/rsi/before.json"],
receiptsAfter: [".fable/rsi/after.json"],
decision: "ready",
});
expect(receipt).toMatchObject({
schema: "fable.recovery.receipt.v1",
createdAt: "2026-07-01T00:00:00.000Z",
decision: "ready",
deployAttempted: false,
});
});
it("blocks context evals when deploy authority evidence fails", () => {
const receipt = createContextEvalReceipt({
task: "deploy candidate",
checks: [
{ check: "live receipt loaded", passed: true },
{ check: "deploy authority cites 8099 gate", passed: false, evidence: "stale 8098 memory" },
],
});
expect(receipt).toMatchObject({ schema: "fable.context_eval.receipt.v1", decision: "blocked", deployAttempted: false });
});
it("keeps sleep-time compute read-only", () => {
const receipt = createSleepTimePolicyReceipt(new Date("2026-07-01T00:00:00.000Z"));
expect(receipt.decision).toBe("read-only");
expect(receipt.blocked).toContain("deploy");
expect(receipt.deployAttempted).toBe(false);
});
it("snapshots context repos without executing files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "context-repo-"));
fs.mkdirSync(path.join(dir, "notes"));
fs.writeFileSync(path.join(dir, "notes", "memory.md"), "live receipts win\n");
const receipt = createContextRepoSnapshotReceipt(dir, new Date("2026-07-01T00:00:00.000Z"));
expect(receipt).toMatchObject({ schema: "fable.context_repo.snapshot.v1", decision: "snapshot-only", deployAttempted: false });
expect(receipt.files).toEqual([{ path: "notes/memory.md", bytes: 18 }]);
fs.rmSync(dir, { recursive: true, force: true });
});
it("writes learning receipts", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "learning-receipts-"));
const file = path.join(dir, "receipt.json");
writeLearningReceipt(file, createSleepTimePolicyReceipt());
expect(JSON.parse(fs.readFileSync(file, "utf-8"))).toMatchObject({ schema: "fable.sleep_time.policy.v1" });
fs.rmSync(dir, { recursive: true, force: true });
});
});

View File

@ -0,0 +1,95 @@
import * as fs from "node:fs";
import * as path from "node:path";
export type LearningDecision = "ready" | "review" | "blocked";
export interface RecoveryReceipt {
schema: "fable.recovery.receipt.v1";
createdAt: string;
incident: string;
staleSignal?: string;
liveSignal: string;
rootCause?: string;
actionTaken: string;
receiptsBefore: string[];
receiptsAfter: string[];
decision: LearningDecision;
deployAttempted: false;
}
export interface ContextEvalReceipt {
schema: "fable.context_eval.receipt.v1";
createdAt: string;
task: string;
checks: Array<{ check: string; passed: boolean; evidence?: string }>;
decision: LearningDecision;
deployAttempted: false;
}
export interface SleepTimePolicyReceipt {
schema: "fable.sleep_time.policy.v1";
createdAt: string;
allowed: string[];
blocked: string[];
decision: "read-only";
deployAttempted: false;
}
export interface ContextRepoSnapshotReceipt {
schema: "fable.context_repo.snapshot.v1";
createdAt: string;
root: string;
files: Array<{ path: string; bytes: number }>;
decision: "snapshot-only";
deployAttempted: false;
}
export function createRecoveryReceipt(opts: Omit<RecoveryReceipt, "schema" | "createdAt" | "deployAttempted"> & { now?: Date }): RecoveryReceipt {
return { schema: "fable.recovery.receipt.v1", createdAt: (opts.now ?? new Date()).toISOString(), ...withoutNow(opts), deployAttempted: false };
}
export function createContextEvalReceipt(opts: Omit<ContextEvalReceipt, "schema" | "createdAt" | "decision" | "deployAttempted"> & { now?: Date }): ContextEvalReceipt {
const failed = opts.checks.filter((check) => !check.passed);
return {
schema: "fable.context_eval.receipt.v1",
createdAt: (opts.now ?? new Date()).toISOString(),
task: opts.task,
checks: opts.checks,
decision: failed.length === 0 ? "ready" : failed.some((check) => /deploy|authority|receipt/i.test(check.check)) ? "blocked" : "review",
deployAttempted: false,
};
}
export function createSleepTimePolicyReceipt(now = new Date()): SleepTimePolicyReceipt {
return {
schema: "fable.sleep_time.policy.v1",
createdAt: now.toISOString(),
allowed: ["receipt refresh", "stale-memory detection", "read-only Forgejo summarization", "skill-health checks", "prompt-fingerprint scans"],
blocked: ["deploy", "factory mutation", "web UI scraping", "cancel/rerun/trigger CI", "auto-patch without explicit approval"],
decision: "read-only",
deployAttempted: false,
};
}
export function createContextRepoSnapshotReceipt(root: string, now = new Date()): ContextRepoSnapshotReceipt {
const files = fs.existsSync(root)
? fs.readdirSync(root, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => {
const relative = path.join(entry.parentPath ? path.relative(root, entry.parentPath) : "", entry.name).replace(/\\/g, "/");
return { path: relative, bytes: fs.statSync(path.join(root, relative)).size };
})
: [];
return { schema: "fable.context_repo.snapshot.v1", createdAt: now.toISOString(), root, files, decision: "snapshot-only", deployAttempted: false };
}
export function writeLearningReceipt(file: string, receipt: RecoveryReceipt | ContextEvalReceipt | SleepTimePolicyReceipt | ContextRepoSnapshotReceipt): string {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
return file;
}
function withoutNow<T extends { now?: Date }>(opts: T): Omit<T, "now"> {
const { now: _now, ...rest } = opts;
return rest;
}

View File

@ -2191,6 +2191,87 @@ fable
if (receipt.decision === "blocked") process.exit(1);
});
fable
.command("recovery-receipt")
.description("Write a recovery receipt for stale context or failed-state repair")
.requiredOption("--incident <text>", "Incident summary")
.requiredOption("--live-signal <text>", "Fresh/live evidence signal")
.requiredOption("--action-taken <text>", "Action taken")
.requiredOption("--decision <decision>", "ready|review|blocked")
.option("--stale-signal <text>", "Stale or misleading signal")
.option("--root-cause <text>", "Root cause")
.option("--before <paths>", "Comma-separated before receipt paths", "")
.option("--after <paths>", "Comma-separated after receipt paths", "")
.option("--output <path>", "Receipt output path", path.join(".fable", "learning", "recovery-live.json"))
.action(async (opts: { incident: string; liveSignal: string; actionTaken: string; decision: string; staleSignal?: string; rootCause?: string; before?: string; after?: string; output?: string }) => {
const { createRecoveryReceipt, writeLearningReceipt } = await import("./fable5/learning-receipts.js");
const receipt = createRecoveryReceipt({
incident: opts.incident,
staleSignal: opts.staleSignal,
liveSignal: opts.liveSignal,
rootCause: opts.rootCause,
actionTaken: opts.actionTaken,
receiptsBefore: splitCsv(opts.before),
receiptsAfter: splitCsv(opts.after),
decision: opts.decision as never,
});
const out = opts.output ?? path.join(".fable", "learning", "recovery-live.json");
writeLearningReceipt(out, receipt);
console.log(JSON.stringify(receipt, null, 2));
console.log(`
Receipt: ${out}
`);
if (receipt.decision === "blocked") process.exit(1);
});
fable
.command("context-eval")
.description("Evaluate whether a task used current receipt-backed context")
.requiredOption("--task <text>", "Task under evaluation")
.requiredOption("--checks <json>", "JSON array of {check,passed,evidence?}")
.option("--output <path>", "Receipt output path", path.join(".fable", "learning", "context-eval-live.json"))
.action(async (opts: { task: string; checks: string; output?: string }) => {
const { createContextEvalReceipt, writeLearningReceipt } = await import("./fable5/learning-receipts.js");
const receipt = createContextEvalReceipt({ task: opts.task, checks: JSON.parse(opts.checks) });
const out = opts.output ?? path.join(".fable", "learning", "context-eval-live.json");
writeLearningReceipt(out, receipt);
console.log(JSON.stringify(receipt, null, 2));
console.log(`
Receipt: ${out}
`);
if (receipt.decision === "blocked") process.exit(1);
});
fable
.command("sleep-time-policy")
.description("Write the read-only sleep-time compute policy receipt")
.option("--output <path>", "Receipt output path", path.join(".fable", "learning", "sleep-time-policy-live.json"))
.action(async (opts: { output?: string }) => {
const { createSleepTimePolicyReceipt, writeLearningReceipt } = await import("./fable5/learning-receipts.js");
const receipt = createSleepTimePolicyReceipt();
const out = opts.output ?? path.join(".fable", "learning", "sleep-time-policy-live.json");
writeLearningReceipt(out, receipt);
console.log(JSON.stringify(receipt, null, 2));
console.log(`
Receipt: ${out}
`);
});
fable
.command("context-repo-snapshot <root>")
.description("Write a snapshot-only context repository receipt")
.option("--output <path>", "Receipt output path", path.join(".fable", "learning", "context-repo-snapshot-live.json"))
.action(async (root: string, opts: { output?: string }) => {
const { createContextRepoSnapshotReceipt, writeLearningReceipt } = await import("./fable5/learning-receipts.js");
const receipt = createContextRepoSnapshotReceipt(root);
const out = opts.output ?? path.join(".fable", "learning", "context-repo-snapshot-live.json");
writeLearningReceipt(out, receipt);
console.log(JSON.stringify(receipt, null, 2));
console.log(`
Receipt: ${out}
`);
});
fable
.command("receipt-health")
.description("Check receipt consumer health over a recent time window")
@ -3679,6 +3760,10 @@ security
No prompt-injection markers found in ${files.length} file(s)`);
});
function splitCsv(value?: string): string[] {
return (value ?? "").split(",").map((part) => part.trim()).filter(Boolean);
}
function splitClaims(content: string): string[] {
return content
.split(/[.!?]\s+/)