feat: add research map receipts
This commit is contained in:
parent
93c9697dbb
commit
f97674ae26
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue