diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 34c964a..73b55cc 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -37,6 +37,10 @@ services: # Audit toggle (production should always be true). AUDIT_ENABLED: ${AUDIT_ENABLED:-true} + # PerkOS-Chat service ingress used for project workflow updates. + PERKOS_CHAT_INTERNAL_URL: ${PERKOS_CHAT_INTERNAL_URL:-http://perkos-chat:6070} + CHAT_INTERNAL_API_KEY: ${CHAT_INTERNAL_API_KEY} + healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1"] interval: 30s diff --git a/src/tools/createDoc.ts b/src/tools/createDoc.ts index 37046eb..dfe124b 100644 --- a/src/tools/createDoc.ts +++ b/src/tools/createDoc.ts @@ -34,7 +34,7 @@ export const createDoc: Tool = { kind: "action", role: "user", description: - "Create a new doc in the project's docs workspace (type: note | plan | spec). Your new doc lands in the 'PM Drafts' section until a human promotes it. Returns the new docId — use it with readDoc / upsertPlanGroup / upsertPlanTask / postDocMessage.", + "Create a new doc in the project's docs workspace (type: note | plan | spec). Plan creation is idempotent: an existing active, unmaterialized plan is reused so out-of-order plan tool calls stay in one document. A new plan becomes the project's active plan. Returns the docId — use it with readDoc / upsertPlanGroup / upsertPlanTask / postDocMessage.", input: InputSchema, async run({ args, ctx }) { const projectRef = db() @@ -51,8 +51,40 @@ export const createDoc: Tool = { }; } + if (args.type === "plan") { + const activePlanId = project.data()?.activePlanId; + if (typeof activePlanId === "string" && activePlanId.length > 0) { + const activePlanRef = projectRef.collection("docs").doc(activePlanId); + const activePlan = await activePlanRef.get(); + if (activePlan.exists && activePlan.data()?.status !== "materialized") { + const currentTitle = activePlan.data()?.title; + await activePlanRef.set( + { + ...(typeof currentTitle === "string" && currentTitle.trim().length > 0 + ? {} + : { title: args.title }), + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true }, + ); + return { + ok: true, + data: { + docId: activePlanId, + type: "plan", + draft: activePlan.data()?.draft === true, + activePlan: true, + reused: true, + }, + }; + } + } + } + const docRef = projectRef.collection("docs").doc(); - await docRef.set({ + const now = FieldValue.serverTimestamp(); + const batch = projectRef.firestore.batch(); + batch.set(docRef, { type: args.type, title: args.title, status: args.type === "plan" ? "draft" : null, @@ -61,13 +93,30 @@ export const createDoc: Tool = { order: await nextDocOrder(projectRef), createdBy: ctx.convId ?? "agent", revision: 0, - createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), + createdAt: now, + updatedAt: now, }); + // A PM commonly creates a plan and then omits docId on subsequent tool + // calls because plan tools advertise the active plan as their default. + // Point the project at the new plan atomically so groups, tasks, reads, and + // proposePlan cannot silently drift into a second empty document. + if (args.type === "plan") { + batch.set( + projectRef, + { activePlanId: docRef.id, updatedAt: now }, + { merge: true }, + ); + } + await batch.commit(); return { ok: true, - data: { docId: docRef.id, type: args.type, draft: true }, + data: { + docId: docRef.id, + type: args.type, + draft: true, + activePlan: args.type === "plan", + }, }; }, };