feat: add learning safety receipts
This commit is contained in:
parent
1135ef4d7c
commit
208d27e2cf
18
COMMANDS.md
18
COMMANDS.md
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
85
src/index.ts
85
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 <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+/)
|
||||
|
|
|
|||
Loading…
Reference in New Issue