feat: link plans to context workspaces

This commit is contained in:
artale 2026-06-28 15:42:05 +02:00
parent 6a54bccbb0
commit ecca80f584
2 changed files with 42 additions and 0 deletions

View File

@ -44,6 +44,31 @@ describe("plan receipt", () => {
expect(receipt.reasons).toEqual([]); expect(receipt.reasons).toEqual([]);
}); });
it("can cite a ready context workspace receipt", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "plan-context-"));
const workspace = path.join(dir, "context-workspace.json");
fs.writeFileSync(workspace, `${JSON.stringify({ schema: "fable.context_workspace.receipt.v1", decision: "ready" })}
`);
const receipt = createPlanReceipt({ task: "Add feature", contextWorkspaceReceipt: workspace, phases: [phase] });
expect(receipt.decision).toBe("ready");
expect(receipt.contextWorkspaceReceipt).toBe(workspace);
});
it("blocks plans that cite blocked context workspaces", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "plan-context-blocked-"));
const workspace = path.join(dir, "context-workspace.json");
fs.writeFileSync(workspace, `${JSON.stringify({ schema: "fable.context_workspace.receipt.v1", decision: "blocked" })}
`);
const receipt = createPlanReceipt({ task: "Add feature", contextWorkspaceReceipt: workspace, phases: [phase] });
expect(receipt.decision).toBe("blocked");
expect(receipt.reasons).toContain("context workspace receipt is blocked");
});
it("writes a JSON receipt", () => { it("writes a JSON receipt", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "plan-receipt-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "plan-receipt-"));
const file = path.join(dir, "receipt.json"); const file = path.join(dir, "receipt.json");

View File

@ -13,6 +13,7 @@ export interface PlanReceiptOptions {
requirements?: string[]; requirements?: string[];
questions?: string[]; questions?: string[];
phases: PlanPhase[]; phases: PlanPhase[];
contextWorkspaceReceipt?: string;
source?: string; source?: string;
now?: Date; now?: Date;
} }
@ -25,6 +26,7 @@ export interface PlanReceipt {
requirements: string[]; requirements: string[];
questions: string[]; questions: string[];
phases: PlanPhase[]; phases: PlanPhase[];
contextWorkspaceReceipt?: string;
decision: "ready" | "needs_clarification" | "blocked"; decision: "ready" | "needs_clarification" | "blocked";
reasons: string[]; reasons: string[];
} }
@ -37,6 +39,8 @@ export function createPlanReceipt(opts: PlanReceiptOptions): PlanReceipt {
if (opts.phases.length === 0) reasons.push("missing phases"); if (opts.phases.length === 0) reasons.push("missing phases");
if (opts.phases.some((phase) => phase.acceptanceCriteria.length === 0)) reasons.push("each phase needs acceptance criteria"); if (opts.phases.some((phase) => phase.acceptanceCriteria.length === 0)) reasons.push("each phase needs acceptance criteria");
if (opts.phases.some((phase) => phase.verification.length === 0)) reasons.push("each phase needs verification steps"); if (opts.phases.some((phase) => phase.verification.length === 0)) reasons.push("each phase needs verification steps");
const workspaceReason = opts.contextWorkspaceReceipt ? invalidContextWorkspaceReceiptReason(opts.contextWorkspaceReceipt) : undefined;
if (workspaceReason) reasons.push(workspaceReason);
return { return {
schema: "fable.plan.receipt.v1", schema: "fable.plan.receipt.v1",
@ -46,6 +50,7 @@ export function createPlanReceipt(opts: PlanReceiptOptions): PlanReceipt {
requirements, requirements,
questions, questions,
phases: opts.phases, phases: opts.phases,
contextWorkspaceReceipt: opts.contextWorkspaceReceipt,
decision: reasons.length ? "blocked" : questions.length ? "needs_clarification" : "ready", decision: reasons.length ? "blocked" : questions.length ? "needs_clarification" : "ready",
reasons, reasons,
}; };
@ -56,3 +61,15 @@ export function writePlanReceipt(file: string, receipt: PlanReceipt): string {
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
return file; return file;
} }
function invalidContextWorkspaceReceiptReason(file: string): string | undefined {
if (!fs.existsSync(file)) return "context workspace receipt missing";
try {
const value = JSON.parse(fs.readFileSync(file, "utf-8")) as { schema?: unknown; decision?: unknown };
if (value.schema !== "fable.context_workspace.receipt.v1") return "context workspace receipt schema invalid";
if (value.decision === "blocked") return "context workspace receipt is blocked";
return undefined;
} catch {
return "context workspace receipt JSON invalid";
}
}