From cb554427c30e394755b197a45efd760a0250fab7 Mon Sep 17 00:00:00 2001 From: Gregory Fong Date: Sat, 21 Mar 2026 23:42:59 -0700 Subject: [PATCH] feat: project-level PM agent chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a unified project manager agent that humans can talk to from the project detail page. The PM agent gets full project context via ./ctx (spec, tasks, plans, proposals, KPIs, human-tasks) and can create agent or human-assigned tasks as side effects of the conversation. Backend: - CHAT# records under PROJECT# in DynamoDB (same pattern as task COMMENT#) - reply_pending on PROJECT for poller-driven dispatch - src/pm_agent.py: run_pm_reply() with MODEL_FULL, ./ctx, JSON actions - run_task.py --pm-reply, run_poller.py polls list_project_reply_pending() - System messages posted proactively on task terminal, autopilot plan proposed/paused, and daily cycle proposals API: - GET/POST /api/projects/:id/chat endpoints - Project.reply_pending + ProjectChatMessage type Frontend: - Chat thread on ProjectDetail with PM/system/user message styling - "PM is preparing a reply…" indicator while reply_pending - Polls every 3s while busy (existing mechanism) Made-with: Cursor --- docs/dynamo-schema.md | 15 ++ frontend/AGENTS.md | 4 +- frontend/src/lib/api.ts | 17 ++ frontend/src/lib/types.ts | 8 + frontend/src/pages/ProjectDetail.tsx | 129 ++++++++- infra/packages/api/src/lib/dynamo.ts | 80 ++++++ infra/packages/api/src/lib/types.ts | 9 + infra/packages/api/src/routes/projects.ts | 38 +++ run_poller.py | 13 +- run_task.py | 9 + src/AGENTS.md | 3 +- src/autopilot.py | 16 ++ src/objectives.py | 17 ++ src/pipeline.py | 40 +++ src/pm_agent.py | 302 ++++++++++++++++++++++ src/projects_dynamo.py | 102 +++++++- 16 files changed, 793 insertions(+), 9 deletions(-) create mode 100644 src/pm_agent.py diff --git a/docs/dynamo-schema.md b/docs/dynamo-schema.md index c60336f..d147140 100644 --- a/docs/dynamo-schema.md +++ b/docs/dynamo-schema.md @@ -166,6 +166,7 @@ Project metadata. `id` is 8-char hex. | cycle_pause_reason | string? | `time_expired` · `blocked` · `failures` · `manual` | | cycle_feedback | string? | Human notes for next planner pass | | next_check_at | string? | Agent-requested deferral (ISO); empty if none | +| reply_pending | bool? | When true, EC2 poller runs `run_task.py --pm-reply` for project-level PM chat | **GSI:** project-list-index (`proj_status`, `project_updated`). @@ -190,6 +191,20 @@ Human-authored daily directives. --- +### Project CHAT — `pk=PROJECT#{id} sk=CHAT#{iso}` + +Project-level PM / system chat thread (human, `pm-agent`, or `system`). + +| Attribute | Type | Description | +|-----------|------|-------------| +| author | string | `web` / human label · `pm-agent` · `system` | +| body | string | Markdown message | +| created_at | string | ISO 8601 (matches sk suffix) | + +**Access:** `Query pk, sk begins_with "CHAT#"` — `ScanIndexForward=false, Limit=N` then reverse for chronological order. + +--- + ### SNAPSHOT — `pk=PROJECT#{id} sk=SNAPSHOT#{YYYY-MM-DD}` Daily metric readings from the Metrics Lambda. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 8ad0108..c0fe579 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -28,9 +28,9 @@ src/ pages/Stats.tsx — Usage & cost (today / all-time tokens, budget bar, 14-day chart via GET /api/stats and /api/budget) pages/ProjectList.tsx — Project list with status, task progress, last directive, target repo pages/ProjectCreate.tsx — New project form: title, spec (markdown) with optional “Generate from prompt” (Bedrock via API), priority, target repo, autopilot toggle - pages/ProjectDetail.tsx — Spec editor (Generate while editing), progress bar, autopilot Off/Daily/Continuous + plan timeline (continuous), cycle start/stop/review, daily plan approve/regenerate, directive timeline, active tasks; polls 3s while busy + pages/ProjectDetail.tsx — Spec editor (Generate while editing), progress bar, **Project manager** chat (GET/POST `/projects/:id/chat`, polls while `reply_pending`), autopilot Off/Daily/Continuous + plan timeline (continuous), cycle start/stop/review, daily plan approve/regenerate, directive timeline, active tasks; polls 3s while busy pages/Login.tsx — Sign-in page - lib/api.ts — Fetch client; project CRUD; plan APIs (fetchPlans, fetchPlanDetail, approvePlan, regeneratePlan, patchPlanItems); continuous autopilot cycle (startAutopilotCycle, stopAutopilotCycle, reviewAutopilotCycle) + lib/api.ts — Fetch client; project CRUD; `fetchProjectChat` / `postProjectChat`; plan APIs (fetchPlans, fetchPlanDetail, approvePlan, regeneratePlan, patchPlanItems); continuous autopilot cycle (startAutopilotCycle, stopAutopilotCycle, reviewAutopilotCycle) lib/types.ts — TypeScript interfaces matching the /api/* responses; Task includes role: string, project_id, directive_sk; Project (with autopilot boolean), Directive, DailyPlan, PlanItem, ProjectListItem types lib/time.ts — timeAgo utility index.css — Tailwind imports, dark theme CSS vars, .prose-custom styles (colors use CSS var tokens, not hardcoded oklch literals) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e151cb8..2254dd2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -10,6 +10,7 @@ import type { KPI, DailyPlan, PlanItem, + ProjectChatMessage, } from "./types" const BASE = (import.meta.env.VITE_API_BASE_URL ?? "") + "/api" @@ -238,6 +239,22 @@ export async function fetchProjectDetail(id: string) { }>(`/projects/${id}`) } +export async function fetchProjectChat(projectId: string, limit = 50) { + return request<{ messages: ProjectChatMessage[]; reply_pending: boolean }>( + `/projects/${projectId}/chat?limit=${limit}` + ) +} + +export async function postProjectChat(projectId: string, body: string) { + return request<{ message: ProjectChatMessage; reply_pending: boolean }>( + `/projects/${projectId}/chat`, + { + method: "POST", + body: JSON.stringify({ body }), + } + ) +} + export async function createProject(data: { title: string spec?: string diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4f50291..b782bbf 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -89,6 +89,14 @@ export interface Project { cycle_pause_reason?: CyclePauseReason cycle_feedback?: string next_check_at?: string + /** PM project chat: agent reply in flight */ + reply_pending?: boolean +} + +export interface ProjectChatMessage { + author: string + body: string + created_at: string } export interface ProjectListItem extends Project { diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index f7e24d4..1b55a59 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -45,9 +45,19 @@ import { startAutopilotCycle, stopAutopilotCycle, reviewAutopilotCycle, + fetchProjectChat, + postProjectChat, } from "@/lib/api" import type { Snapshot } from "@/lib/api" -import type { Project, Directive, Task, TaskStatus, KPI, DailyPlan } from "@/lib/types" +import type { + Project, + Directive, + Task, + TaskStatus, + KPI, + DailyPlan, + ProjectChatMessage, +} from "@/lib/types" import { timeAgo } from "@/lib/time" const STATUS_BADGE: Record = { @@ -197,6 +207,9 @@ export default function ProjectDetail() { const [cycleHoursDraft, setCycleHoursDraft] = useState("24") const [reviewFeedback, setReviewFeedback] = useState("") const [cycleActionLoading, setCycleActionLoading] = useState(false) + const [pmChatMessages, setPmChatMessages] = useState([]) + const [pmChatBody, setPmChatBody] = useState("") + const [pmChatSending, setPmChatSending] = useState(false) /** When true, polling must not overwrite the spec textarea (draft). */ const specEditingRef = useRef(false) useEffect(() => { @@ -209,9 +222,14 @@ export default function ProjectDetail() { return Promise.all([ fetchProjectDetail(projectId), fetchSnapshots(projectId), + fetchProjectChat(projectId).catch(() => ({ + messages: [] as ProjectChatMessage[], + reply_pending: false, + })), ]) - .then(async ([d, s]) => { + .then(async ([d, s, chat]) => { setProject(d.project) + setPmChatMessages(chat.messages) setDirectives(d.directives) setTasks(d.tasks) setProgress(d.progress) @@ -277,7 +295,8 @@ export default function ProjectDetail() { tasks.some((t) => t.status === "pending" || t.status === "in_progress") || (project && !project.awaiting_next_directive && project.active_directive_sk) || (todayPlan?.status === "approved" && - planTasks.some((t) => t.status === "pending" || t.status === "in_progress")) + planTasks.some((t) => t.status === "pending" || t.status === "in_progress")) || + Boolean(project?.reply_pending) const humanTasksOpen = useMemo( () => @@ -438,6 +457,110 @@ export default function ProjectDetail() { + {/* Project manager chat */} +
+

+ Project manager +

+

+ Chat with the PM agent for status and context. It can use project data and create tasks when + needed. +

+ {pmChatMessages.length === 0 && ( +

No messages yet — say hello below.

+ )} +
+ {pmChatMessages.map((c, i) => { + const isPm = c.author === "pm-agent" + const isSystem = c.author === "system" + return ( +
+
+ {isPm ? ( + + ) : isSystem ? ( + SYS + ) : ( + + )} +
+
+
+ + {isPm ? "PM" : isSystem ? "System" : c.author} + + · + {timeAgo(c.created_at)} +
+
+ {c.body} +
+
+
+ ) + })} +
+ {project.reply_pending && ( +
+ + PM is preparing a reply… +
+ )} +
{ + e.preventDefault() + if (!projectId || !pmChatBody.trim()) return + setPmChatSending(true) + try { + const r = await postProjectChat(projectId, pmChatBody.trim()) + setPmChatMessages((prev) => [...prev, r.message]) + setProject((p) => (p ? { ...p, reply_pending: r.reply_pending } : p)) + setPmChatBody("") + toast.success("Message sent") + await load() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to send") + } finally { + setPmChatSending(false) + } + }} + > +