Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/tools/createDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import {
projectIdSchema,
} from "./docShared.js";
import type { Tool } from "./types.js";
import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js";

const InputSchema = z
.object({
projectId: projectIdSchema,
type: docTypeSchema,
title: z.string().min(1).max(200),
parentId: docIdSchema.optional(),
planningRunId: planningRunIdSchema,
})
.strict();

Expand Down Expand Up @@ -55,6 +57,13 @@ export const createDoc: Tool<typeof InputSchema> = {
// create a task-specific spec, but returning the approved plan keeps every
// contribution in the shared workspace and makes retries idempotent.
const projectData = project.data() as Record<string, unknown>;
const runDecision = planningRunDecision(
(projectData.workflow ?? null) as Record<string, unknown> | null,
args.planningRunId,
);
if (runDecision !== "allow" && args.type === "plan") {
return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(runDecision) };
}
const workflow = (projectData.workflow ?? null) as
| { phase?: string; planId?: string }
| null;
Expand Down
7 changes: 5 additions & 2 deletions src/tools/createTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,15 @@ export const createTask: Tool<typeof InputSchema> = {
};
}
const workflow = project.data()?.workflow as { phase?: string } | undefined;
if (workflow?.phase === "planning" || workflow?.phase === "awaiting_approval") {
if (
workflow?.phase &&
["planning", "awaiting_approval", "approved", "running", "pm_review", "complete"].includes(workflow.phase)
) {
return {
ok: false,
errorClass: "FORBIDDEN",
message:
"This project plan has not been approved. Add planTask blocks and call proposePlan; createTask is enabled after user approval.",
"This project uses an approval-controlled plan. Draft with planTask blocks during planning; after approval the server materializes the immutable task set. Do not call createTask directly.",
};
}

Expand Down
26 changes: 26 additions & 0 deletions src/tools/planningRun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from "zod";

export const planningRunIdSchema = z.string().uuid().optional();

export type PlanningRunDecision = "allow" | "missing" | "stale";

/** A planning token binds every draft mutation to the single PM turn that
* atomically claimed the project. Legacy workflows without a token remain
* usable while deployments roll forward. */
export function planningRunDecision(
workflow: Record<string, unknown> | null | undefined,
supplied: string | undefined,
): PlanningRunDecision {
if (String(workflow?.phase ?? "") !== "planning") return "allow";
const expected =
typeof workflow?.planningRunId === "string" ? workflow.planningRunId : "";
if (!expected) return "allow";
if (!supplied) return "missing";
return supplied === expected ? "allow" : "stale";
}

export function planningRunError(decision: Exclude<PlanningRunDecision, "allow">): string {
return decision === "missing"
? "This planning run requires its planningRunId. Use the token from the PM assignment."
: "This planning turn is stale. Stop without changing the plan; a newer PM turn owns it.";
}
21 changes: 21 additions & 0 deletions src/tools/proposePlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import { docIdSchema, ensureDoc, projectIdSchema } from "./docShared.js";
import { logActivity } from "../activityEvents.js";
import { postProjectChat } from "../projectChat.js";
import type { Tool } from "./types.js";
import { db } from "../firestore.js";
import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js";

const InputSchema = z
.object({
projectId: projectIdSchema,
docId: docIdSchema.optional(),
planningRunId: planningRunIdSchema,
})
.strict();

Expand Down Expand Up @@ -50,6 +53,22 @@ export const proposePlan: Tool<typeof InputSchema> = {
"Mark a plan doc as proposed (ready for human approval) and notify the team in that doc's discussion. Call this once you've laid out the task groups and draft tasks. The plan only becomes real board tasks after a human approves it in the app. Targets the active plan doc unless docId is given.",
input: InputSchema,
async run({ args, ctx }) {
const initialProject = await db()
.collection("wallets")
.doc(ctx.wallet)
.collection("projects")
.doc(args.projectId)
.get();
if (!initialProject.exists) {
return { ok: false, errorClass: "NOT_FOUND", message: `No project "${args.projectId}" for this wallet.` };
}
const initialDecision = planningRunDecision(
(initialProject.data()?.workflow ?? null) as Record<string, unknown> | null,
args.planningRunId,
);
if (initialDecision !== "allow") {
return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(initialDecision) };
}
const refs = await ensureDoc(ctx.wallet, args.projectId, {
docId: args.docId,
});
Expand Down Expand Up @@ -99,6 +118,8 @@ export const proposePlan: Tool<typeof InputSchema> = {
if (!freshProject.exists || !freshDoc.exists) throw new Error("plan not found");
const projectData = freshProject.data() as Record<string, unknown>;
const workflow = (projectData.workflow ?? {}) as Record<string, unknown>;
const runDecision = planningRunDecision(workflow, args.planningRunId);
if (runDecision !== "allow") throw new Error(planningRunError(runDecision));
workflowConvId =
typeof workflow.convId === "string"
? workflow.convId
Expand Down
9 changes: 8 additions & 1 deletion src/tools/upsertPlanGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
touchDocForEdit,
} from "./docShared.js";
import type { Tool } from "./types.js";
import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js";

const InputSchema = z
.object({
Expand All @@ -30,6 +31,7 @@ const InputSchema = z
groupId: blockIdSchema.optional(),
title: z.string().min(1).max(200),
order: z.number().int().min(0).max(100000).optional(),
planningRunId: planningRunIdSchema,
})
.strict();

Expand Down Expand Up @@ -63,7 +65,12 @@ export const upsertPlanGroup: Tool<typeof InputSchema> = {
}

const project = await docRef.parent.parent!.get();
const phase = String((project.data()?.workflow as { phase?: unknown } | undefined)?.phase ?? "");
const workflow = (project.data()?.workflow ?? null) as Record<string, unknown> | null;
const runDecision = planningRunDecision(workflow, args.planningRunId);
if (runDecision !== "allow") {
return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(runDecision) };
}
const phase = String(workflow?.phase ?? "");
if (phase === "awaiting_approval") {
return {
ok: false,
Expand Down
11 changes: 8 additions & 3 deletions src/tools/upsertPlanTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
touchDocForEdit,
} from "./docShared.js";
import type { Tool } from "./types.js";
import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js";

const InputSchema = z
.object({
Expand All @@ -38,6 +39,7 @@ const InputSchema = z
acceptance: z.string().max(2000).optional(),
/** Other planTask block ids this depends on. */
deps: z.array(blockIdSchema).max(50).optional(),
planningRunId: planningRunIdSchema,
})
.strict();

Expand Down Expand Up @@ -71,9 +73,12 @@ export const upsertPlanTask: Tool<typeof InputSchema> = {
}

const projectSnapshot = await docRef.parent.parent!.get();
const phase = String(
(projectSnapshot.data()?.workflow as { phase?: unknown } | undefined)?.phase ?? "",
);
const workflow = (projectSnapshot.data()?.workflow ?? null) as Record<string, unknown> | null;
const runDecision = planningRunDecision(workflow, args.planningRunId);
if (runDecision !== "allow") {
return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(runDecision) };
}
const phase = String(workflow?.phase ?? "");
if (phase === "awaiting_approval") {
return {
ok: false,
Expand Down
8 changes: 8 additions & 0 deletions tests/plan-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ describe("plan-doc input schemas", () => {
).toBe(false);
});

it("accepts a planningRunId on every PM plan mutation", () => {
const planningRunId = "b4ed66d0-c1ef-4ae1-9524-54dca0bb571b";
expect(findTool("createDoc")!.input.safeParse({ projectId: "p1", type: "plan", title: "Plan", planningRunId }).success).toBe(true);
expect(findTool("upsertPlanGroup")!.input.safeParse({ projectId: "p1", title: "Sprint", planningRunId }).success).toBe(true);
expect(findTool("upsertPlanTask")!.input.safeParse({ projectId: "p1", groupId: "g1", title: "Work", planningRunId }).success).toBe(true);
expect(findTool("proposePlan")!.input.safeParse({ projectId: "p1", planningRunId }).success).toBe(true);
});

it("rejects malformed projectId", () => {
const t = findTool("readDoc")!;
expect(t.input.safeParse({ projectId: "bad id!" }).success).toBe(false);
Expand Down
18 changes: 18 additions & 0 deletions tests/proposal-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ process.env.JWT_SHARED_SECRET = "a".repeat(40);

const { planProposalDecision } = await import("../src/tools/proposePlan.js");
const { normalizedPlanLabel } = await import("../src/tools/docShared.js");
const { planningRunDecision } = await import("../src/tools/planningRun.js");

describe("plan proposal policy", () => {
it("makes a repeated proposal for the same pending revision idempotent", () => {
Expand All @@ -32,6 +33,23 @@ describe("plan proposal policy", () => {
});
});

describe("planning run ownership", () => {
const workflow = {
phase: "planning",
planningRunId: "b4ed66d0-c1ef-4ae1-9524-54dca0bb571b",
};

it("requires and validates the token while planning", () => {
expect(planningRunDecision(workflow, undefined)).toBe("missing");
expect(planningRunDecision(workflow, "7f9eca90-4f27-49b2-8d40-9dcf81883984")).toBe("stale");
expect(planningRunDecision(workflow, workflow.planningRunId)).toBe("allow");
});

it("does not affect non-planning document work", () => {
expect(planningRunDecision({ phase: "running", planningRunId: workflow.planningRunId }, undefined)).toBe("allow");
});
});

describe("normalizedPlanLabel", () => {
it("collapses cosmetic differences used by retrying PM calls", () => {
expect(normalizedPlanLabel(" Final Synthesis ")).toBe("final synthesis");
Expand Down
Loading