feat: add context workspace receipts

This commit is contained in:
artale 2026-06-28 12:58:37 +02:00
parent b905b98856
commit 6a54bccbb0
3 changed files with 117 additions and 0 deletions

View File

@ -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");
});
});

View File

@ -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;
}

View File

@ -96,6 +96,9 @@ export type { ModelAccessProbe, ModelAccessReceipt, ModelAccessReceiptOptions, M
export { createPlanReceipt, writePlanReceipt } from "./plan-receipt.js"; export { createPlanReceipt, writePlanReceipt } from "./plan-receipt.js";
export type { PlanPhase, PlanReceipt, PlanReceiptOptions } 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 { auditGoalCompletion, writeGoalAuditReceipt } from "./goal-audit.js";
export type { GoalAuditCriterion, GoalAuditReceipt } from "./goal-audit.js"; export type { GoalAuditCriterion, GoalAuditReceipt } from "./goal-audit.js";