From 208d27e2cfc821c3170c043a1a072e5023601cb2 Mon Sep 17 00:00:00 2001 From: artale Date: Wed, 1 Jul 2026 14:30:26 +0200 Subject: [PATCH] feat: add learning safety receipts --- COMMANDS.md | 18 ++++++ src/fable5/index.ts | 3 + src/fable5/learning-receipts.test.ts | 69 ++++++++++++++++++++ src/fable5/learning-receipts.ts | 95 ++++++++++++++++++++++++++++ src/index.ts | 85 +++++++++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 src/fable5/learning-receipts.test.ts create mode 100644 src/fable5/learning-receipts.ts diff --git a/COMMANDS.md b/COMMANDS.md index 9aedb00..35435ed 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -368,6 +368,24 @@ fable-agent plinius godmode "improve explanation quality" - `--source ` - `--threshold ` - `--output ` +- `fable5 recovery-receipt` + - `--incident ` + - `--live-signal ` + - `--action-taken ` + - `--decision ` + - `--stale-signal ` + - `--root-cause ` + - `--before ` + - `--after ` + - `--output ` +- `fable5 context-eval` + - `--task ` + - `--checks ` + - `--output ` +- `fable5 sleep-time-policy` + - `--output ` +- `fable5 context-repo-snapshot ` + - `--output ` - `fable5 receipt-health` - `--root ` - `--window-minutes ` diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 6b1a74b..796cd03 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -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"; diff --git a/src/fable5/learning-receipts.test.ts b/src/fable5/learning-receipts.test.ts new file mode 100644 index 0000000..481c245 --- /dev/null +++ b/src/fable5/learning-receipts.test.ts @@ -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 }); + }); +}); diff --git a/src/fable5/learning-receipts.ts b/src/fable5/learning-receipts.ts new file mode 100644 index 0000000..4d5f2e0 --- /dev/null +++ b/src/fable5/learning-receipts.ts @@ -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 & { now?: Date }): RecoveryReceipt { + return { schema: "fable.recovery.receipt.v1", createdAt: (opts.now ?? new Date()).toISOString(), ...withoutNow(opts), deployAttempted: false }; +} + +export function createContextEvalReceipt(opts: Omit & { 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(opts: T): Omit { + const { now: _now, ...rest } = opts; + return rest; +} diff --git a/src/index.ts b/src/index.ts index 006c22d..306d755 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 ", "Incident summary") + .requiredOption("--live-signal ", "Fresh/live evidence signal") + .requiredOption("--action-taken ", "Action taken") + .requiredOption("--decision ", "ready|review|blocked") + .option("--stale-signal ", "Stale or misleading signal") + .option("--root-cause ", "Root cause") + .option("--before ", "Comma-separated before receipt paths", "") + .option("--after ", "Comma-separated after receipt paths", "") + .option("--output ", "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 ", "Task under evaluation") + .requiredOption("--checks ", "JSON array of {check,passed,evidence?}") + .option("--output ", "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 ", "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 ") + .description("Write a snapshot-only context repository receipt") + .option("--output ", "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+/)