diff --git a/src/fable5/context-workspace-receipt.test.ts b/src/fable5/context-workspace-receipt.test.ts new file mode 100644 index 0000000..aa1744f --- /dev/null +++ b/src/fable5/context-workspace-receipt.test.ts @@ -0,0 +1,57 @@ +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 { createContextWorkspaceReceipt, writeContextWorkspaceReceipt } from "./context-workspace-receipt.js"; + +const stage = { + name: "Research", + folder: "stages/01_research", + job: "Collect source material", + inputs: [ + { layer: "layer0_identity" as const, path: "CLAUDE.md" }, + { layer: "layer1_routing" as const, path: "CONTEXT.md" }, + { layer: "layer2_stage" as const, path: "stages/01_research/CONTEXT.md" }, + { layer: "layer3_reference" as const, path: "references/style.md" }, + { layer: "layer4_working" as const, path: "input/brief.md" }, + ], + outputs: ["stages/01_research/output/research.md"], +}; + +describe("context workspace receipt", () => { + it("records ICM-style stage contracts as editable filesystem context", () => { + const receipt = createContextWorkspaceReceipt({ + workspace: "workspace", + stages: [stage], + now: new Date("2026-06-28T00:00:00.000Z"), + }); + + expect(receipt.schema).toBe("fable.context_workspace.receipt.v1"); + expect(receipt.source).toContain("2603.16021"); + expect(receipt.decision).toBe("ready"); + expect(receipt.principles).toContain("plain text as interface"); + expect(receipt.stages[0].inputs.map((input) => input.layer)).toContain("layer3_reference"); + }); + + it("blocks vague stages without explicit inputs and outputs", () => { + const receipt = createContextWorkspaceReceipt({ + workspace: "workspace", + stages: [{ name: "x", folder: "", job: "", inputs: [], outputs: [] }], + }); + + expect(receipt.decision).toBe("blocked"); + expect(receipt.reasons).toContain("each stage needs a folder"); + expect(receipt.reasons).toContain("each stage needs one job"); + expect(receipt.reasons).toContain("each stage needs explicit inputs"); + expect(receipt.reasons).toContain("each stage needs explicit outputs"); + }); + + it("writes a JSON receipt", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "context-workspace-")); + const file = path.join(dir, "receipt.json"); + + writeContextWorkspaceReceipt(file, createContextWorkspaceReceipt({ workspace: "workspace", stages: [stage] })); + + expect(fs.readFileSync(file, "utf-8")).toContain("fable.context_workspace.receipt.v1"); + }); +}); diff --git a/src/fable5/context-workspace-receipt.ts b/src/fable5/context-workspace-receipt.ts new file mode 100644 index 0000000..4b988e5 --- /dev/null +++ b/src/fable5/context-workspace-receipt.ts @@ -0,0 +1,57 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export type ContextLayer = "layer0_identity" | "layer1_routing" | "layer2_stage" | "layer3_reference" | "layer4_working"; + +export interface ContextStageContract { + name: string; + folder: string; + job: string; + inputs: Array<{ layer: ContextLayer; path: string }>; + outputs: string[]; +} + +export interface ContextWorkspaceReceiptOptions { + workspace: string; + source?: string; + stages: ContextStageContract[]; + now?: Date; +} + +export interface ContextWorkspaceReceipt { + schema: "fable.context_workspace.receipt.v1"; + createdAt: string; + source: string; + workspace: string; + stages: ContextStageContract[]; + principles: string[]; + decision: "ready" | "blocked"; + reasons: string[]; +} + +export function createContextWorkspaceReceipt(opts: ContextWorkspaceReceiptOptions): ContextWorkspaceReceipt { + const reasons: string[] = []; + if (!opts.workspace.trim()) reasons.push("missing workspace"); + if (opts.stages.length === 0) reasons.push("missing stages"); + if (opts.stages.some((stage) => !stage.folder.trim())) reasons.push("each stage needs a folder"); + if (opts.stages.some((stage) => !stage.job.trim())) reasons.push("each stage needs one job"); + if (opts.stages.some((stage) => stage.inputs.length === 0)) reasons.push("each stage needs explicit inputs"); + if (opts.stages.some((stage) => stage.outputs.length === 0)) reasons.push("each stage needs explicit outputs"); + + return { + schema: "fable.context_workspace.receipt.v1", + createdAt: (opts.now ?? new Date()).toISOString(), + source: opts.source ?? "ICM paper: https://arxiv.org/abs/2603.16021", + workspace: opts.workspace, + stages: opts.stages, + principles: ["one stage, one job", "plain text as interface", "layered context loading", "outputs are edit surfaces"], + decision: reasons.length ? "blocked" : "ready", + reasons, + }; +} + +export function writeContextWorkspaceReceipt(file: string, receipt: ContextWorkspaceReceipt): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); + return file; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 47eb4a4..26d21ad 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -96,6 +96,9 @@ export type { ModelAccessProbe, ModelAccessReceipt, ModelAccessReceiptOptions, M export { createPlanReceipt, writePlanReceipt } from "./plan-receipt.js"; export type { PlanPhase, PlanReceipt, PlanReceiptOptions } from "./plan-receipt.js"; +export { createContextWorkspaceReceipt, writeContextWorkspaceReceipt } from "./context-workspace-receipt.js"; +export type { ContextLayer, ContextStageContract, ContextWorkspaceReceipt, ContextWorkspaceReceiptOptions } from "./context-workspace-receipt.js"; + export { auditGoalCompletion, writeGoalAuditReceipt } from "./goal-audit.js"; export type { GoalAuditCriterion, GoalAuditReceipt } from "./goal-audit.js";