From f97674ae26b344c971f74c7051d2556edd3b2fbf Mon Sep 17 00:00:00 2001 From: artale Date: Wed, 1 Jul 2026 18:46:32 +0200 Subject: [PATCH] feat: add research map receipts --- src/fable5/index.ts | 3 + src/fable5/research-map-receipt.test.ts | 90 +++++++++++++++++++++++++ src/fable5/research-map-receipt.ts | 61 +++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/fable5/research-map-receipt.test.ts create mode 100644 src/fable5/research-map-receipt.ts diff --git a/src/fable5/index.ts b/src/fable5/index.ts index ecdbc1f..b527500 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -75,6 +75,9 @@ export type { FalsificationCase, FalsificationReceipt } from "./falsification-re export { createExternalRsiEvidenceReceipt, writeExternalEvidenceReceipt } from "./external-evidence.js"; export type { ExternalEvidenceReceipt } from "./external-evidence.js"; +export { createResearchMapReceipt, writeResearchMapReceipt } from "./research-map-receipt.js"; +export type { ResearchMapOptions, ResearchMapReceipt } from "./research-map-receipt.js"; + export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js"; export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js"; diff --git a/src/fable5/research-map-receipt.test.ts b/src/fable5/research-map-receipt.test.ts new file mode 100644 index 0000000..c28c2e5 --- /dev/null +++ b/src/fable5/research-map-receipt.test.ts @@ -0,0 +1,90 @@ +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 { createResearchMapReceipt, writeResearchMapReceipt } from "./research-map-receipt.js"; + +const phase = { + name: "Add receipt primitive", + goal: "Map research into a bounded receipt and tests", + acceptanceCriteria: ["receipt is typed", "forbidden implementation scope is blocked"], + verification: ["npm run -s test -- src/fable5/research-map-receipt.test.ts"], +}; + +describe("research map receipts", () => { + it("creates ready bounded research plans", () => { + const receipt = createResearchMapReceipt({ + now: new Date("2026-07-01T00:00:00.000Z"), + taskLabel: "ast-context", + task: "Map AST context research into Fable receipt primitives", + constraints: ["advisory-only", "no production mutation"], + existingPrimitives: ["plan-receipt", "context-workspace-receipt", "factory-deploy", "rsi-reconcile"], + recommendedPlan: [phase], + skipped: ["deploy changes", "dashboard", "vendored tools", "new model architecture"], + }); + + expect(receipt).toMatchObject({ + schema: "fable.research_map.receipt.v1", + decision: "ready", + reasons: [], + deployAttempted: false, + }); + expect(receipt.skipped).toContain("deploy changes"); + expect(receipt.existingPrimitives).toContain("plan-receipt"); + }); + + it("blocks missing task metadata and phases", () => { + const receipt = createResearchMapReceipt({ taskLabel: "", task: "", recommendedPlan: [] }); + + expect(receipt.decision).toBe("blocked"); + expect(receipt.reasons).toContain("missing task label"); + expect(receipt.reasons).toContain("missing task"); + expect(receipt.reasons).toContain("missing recommended phases"); + }); + + it("requires acceptance criteria and verification", () => { + const receipt = createResearchMapReceipt({ + taskLabel: "bad", + task: "Bad map", + recommendedPlan: [{ name: "x", goal: "y", acceptanceCriteria: [], verification: [] }], + }); + + expect(receipt.decision).toBe("blocked"); + expect(receipt.reasons).toContain("each phase needs acceptance criteria"); + expect(receipt.reasons).toContain("each phase needs verification steps"); + }); + + it("blocks forbidden implementation scope in recommended phases", () => { + const receipt = createResearchMapReceipt({ + taskLabel: "overbuild", + task: "Map research safely", + recommendedPlan: [{ ...phase, goal: "Build a new model architecture and dashboard" }], + }); + + expect(receipt.decision).toBe("blocked"); + expect(receipt.reasons[0]).toContain("forbidden scope"); + expect(receipt.reasons[0]).toContain("dashboard"); + expect(receipt.reasons[0]).toContain("new model architecture"); + }); + + it("does not block forbidden terms when they are explicitly skipped", () => { + const receipt = createResearchMapReceipt({ + taskLabel: "bounded", + task: "Map research safely", + recommendedPlan: [phase], + skipped: ["deploy", "dashboard", "vendor", "new model architecture"], + }); + + expect(receipt.decision).toBe("ready"); + }); + + it("writes receipts", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "research-map-")); + const file = path.join(dir, "receipt.json"); + + writeResearchMapReceipt(file, createResearchMapReceipt({ taskLabel: "x", task: "y", recommendedPlan: [phase] })); + + expect(JSON.parse(fs.readFileSync(file, "utf-8"))).toMatchObject({ schema: "fable.research_map.receipt.v1" }); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/src/fable5/research-map-receipt.ts b/src/fable5/research-map-receipt.ts new file mode 100644 index 0000000..b600aff --- /dev/null +++ b/src/fable5/research-map-receipt.ts @@ -0,0 +1,61 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { PlanPhase } from "./plan-receipt.js"; + +export interface ResearchMapReceipt { + schema: "fable.research_map.receipt.v1"; + createdAt: string; + taskLabel: string; + task: string; + constraints: string[]; + existingPrimitives: string[]; + recommendedPlan: PlanPhase[]; + skipped: string[]; + decision: "ready" | "blocked"; + reasons: string[]; + deployAttempted: false; +} + +export interface ResearchMapOptions { + taskLabel: string; + task: string; + constraints?: string[]; + existingPrimitives?: string[]; + recommendedPlan: PlanPhase[]; + skipped?: string[]; + now?: Date; +} + +const FORBIDDEN_SCOPE = ["deploy", "dashboard", "vendor", "vendoring", "new model architecture"]; + +export function createResearchMapReceipt(opts: ResearchMapOptions): ResearchMapReceipt { + const reasons: string[] = []; + if (!opts.taskLabel.trim()) reasons.push("missing task label"); + if (!opts.task.trim()) reasons.push("missing task"); + if (opts.recommendedPlan.length === 0) reasons.push("missing recommended phases"); + if (opts.recommendedPlan.some((phase) => phase.acceptanceCriteria.length === 0)) reasons.push("each phase needs acceptance criteria"); + if (opts.recommendedPlan.some((phase) => phase.verification.length === 0)) reasons.push("each phase needs verification steps"); + const searchable = [opts.task, opts.taskLabel, ...opts.recommendedPlan.flatMap((phase) => [phase.name, phase.goal])].join("\n").toLowerCase(); + const forbidden = FORBIDDEN_SCOPE.filter((term) => searchable.includes(term)); + if (forbidden.length > 0) reasons.push(`forbidden scope: ${forbidden.join(", ")}`); + + return { + schema: "fable.research_map.receipt.v1", + createdAt: (opts.now ?? new Date()).toISOString(), + taskLabel: opts.taskLabel, + task: opts.task, + constraints: opts.constraints ?? [], + existingPrimitives: opts.existingPrimitives ?? [], + recommendedPlan: opts.recommendedPlan, + skipped: opts.skipped ?? [], + decision: reasons.length === 0 ? "ready" : "blocked", + reasons, + deployAttempted: false, + }; +} + +export function writeResearchMapReceipt(file: string, receipt: ResearchMapReceipt): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); + return file; +}