diff --git a/apps/server/src/mcp/toolkits/board/handlers.ts b/apps/server/src/mcp/toolkits/board/handlers.ts index 9c8ee0bd79ed..f312d3b295e9 100644 --- a/apps/server/src/mcp/toolkits/board/handlers.ts +++ b/apps/server/src/mcp/toolkits/board/handlers.ts @@ -180,6 +180,8 @@ const dispatchUpsert = Effect.fn("BoardToolkit.dispatchUpsert")(function* (input readonly notes?: string | null | undefined; readonly brief?: ProjectBoardBrief | null | undefined; readonly area?: string | null | undefined; + readonly externalRefs?: ReadonlyArray | undefined; + readonly relatedItemIds?: ReadonlyArray | undefined; readonly sourceThreadId: McpInvocationContext.McpInvocationScope["threadId"]; readonly linkTurnId?: TurnId | null | undefined; }) { @@ -195,6 +197,8 @@ const dispatchUpsert = Effect.fn("BoardToolkit.dispatchUpsert")(function* (input ...(input.notes !== undefined ? { notes: input.notes } : {}), ...(input.brief !== undefined ? { brief: input.brief } : {}), ...(input.area !== undefined ? { area: input.area } : {}), + ...(input.externalRefs !== undefined ? { externalRefs: input.externalRefs } : {}), + ...(input.relatedItemIds !== undefined ? { relatedItemIds: input.relatedItemIds } : {}), source: "agent", sourceThreadId: input.sourceThreadId, ...(input.linkTurnId ? { linkTurnId: input.linkTurnId } : {}), @@ -283,6 +287,8 @@ const handlers = { readonly notes?: string | null | undefined; readonly brief?: ProjectBoardBrief | null | undefined; readonly area?: string | null | undefined; + readonly externalRefs?: ReadonlyArray | undefined; + readonly relatedItemIds?: ReadonlyArray | undefined; }) => Effect.gen(function* () { const scope = yield* requireBoardScope(); @@ -297,6 +303,8 @@ const handlers = { ...(input.notes !== undefined ? { notes: input.notes } : {}), ...(input.brief !== undefined ? { brief: input.brief } : {}), ...(input.area !== undefined ? { area: input.area } : {}), + ...(input.externalRefs !== undefined ? { externalRefs: input.externalRefs } : {}), + ...(input.relatedItemIds !== undefined ? { relatedItemIds: input.relatedItemIds } : {}), sourceThreadId: scope.threadId, linkTurnId, }); diff --git a/apps/server/src/mcp/toolkits/board/tools.ts b/apps/server/src/mcp/toolkits/board/tools.ts index b9b5231fa82a..847c6d4daa54 100644 --- a/apps/server/src/mcp/toolkits/board/tools.ts +++ b/apps/server/src/mcp/toolkits/board/tools.ts @@ -110,6 +110,10 @@ export const BoardUpsertTool = Tool.make("board_upsert", { notes: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), brief: Schema.optional(Schema.NullOr(ProjectBoardBrief)), area: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // Replaces the full list when provided. External URLs (GitHub issue/PR, doc, etc). + externalRefs: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + // Replaces the full list when provided. Other board item ids this card relates to. + relatedItemIds: Schema.optional(Schema.Array(ProjectBoardItemId)), }), success: BoardMutateResult, failure: BoardToolError, diff --git a/apps/server/src/orchestration/decider.projectBoard.test.ts b/apps/server/src/orchestration/decider.projectBoard.test.ts index efd67985ad33..d02d347c4098 100644 --- a/apps/server/src/orchestration/decider.projectBoard.test.ts +++ b/apps/server/src/orchestration/decider.projectBoard.test.ts @@ -200,6 +200,84 @@ it.layer(NodeServices.layer)("decider project board", (it) => { }), ); + it.effect( + "preserves externalRefs/relatedItemIds across an omitted update, replaces on provide, and drops self-references", + () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const projectId = asProjectId("project-board-refs"); + let readModel = yield* projectEvent( + createEmptyReadModel(now), + createProjectEvent({ sequence: 1, projectId, now }), + ); + + const created = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-refs-create"), + projectId, + itemId: asItemId("item-1"), + title: "Tracked card", + status: "backlog", + externalRefs: ["https://github.com/example/repo/issues/1"], + relatedItemIds: [asItemId("item-1"), asItemId("item-2")], + }, + readModel, + }); + const createdEvent = Array.isArray(created) ? created[0] : created; + if (!createdEvent || createdEvent.type !== "project.board-item-upserted") { + throw new Error("expected create event"); + } + // self-id is filtered out of relatedItemIds. + expect(createdEvent.payload.item.relatedItemIds).toEqual([asItemId("item-2")]); + expect(createdEvent.payload.item.externalRefs).toEqual([ + "https://github.com/example/repo/issues/1", + ]); + readModel = yield* projectEvent(readModel, createdEvent); + + const statusOnly = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-refs-status"), + projectId, + itemId: asItemId("item-1"), + title: "Tracked card", + status: "ready", + }, + readModel, + }); + const statusOnlyEvent = Array.isArray(statusOnly) ? statusOnly[0] : statusOnly; + if (!statusOnlyEvent || statusOnlyEvent.type !== "project.board-item-upserted") { + throw new Error("expected status-only update event"); + } + expect(statusOnlyEvent.payload.item.externalRefs).toEqual([ + "https://github.com/example/repo/issues/1", + ]); + expect(statusOnlyEvent.payload.item.relatedItemIds).toEqual([asItemId("item-2")]); + readModel = yield* projectEvent(readModel, statusOnlyEvent); + + const cleared = yield* decideOrchestrationCommand({ + command: { + type: "project.board.item.upsert", + commandId: CommandId.make("cmd-board-refs-clear"), + projectId, + itemId: asItemId("item-1"), + title: "Tracked card", + status: "ready", + externalRefs: [], + relatedItemIds: [], + }, + readModel, + }); + const clearedEvent = Array.isArray(cleared) ? cleared[0] : cleared; + if (!clearedEvent || clearedEvent.type !== "project.board-item-upserted") { + throw new Error("expected clear event"); + } + expect(clearedEvent.payload.item.externalRefs).toEqual([]); + expect(clearedEvent.payload.item.relatedItemIds).toEqual([]); + }), + ); + it.effect("archives and restores a board item without changing its status", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.projectBoard.ts b/apps/server/src/orchestration/decider.projectBoard.ts index e636557cd8a9..c92891c79841 100644 --- a/apps/server/src/orchestration/decider.projectBoard.ts +++ b/apps/server/src/orchestration/decider.projectBoard.ts @@ -17,7 +17,11 @@ import { type OrchestrationReadModel, type ProjectBoardItem, } from "@t3tools/contracts"; -import { mergeProjectBoardLinkedTurnIds } from "@t3tools/shared/projectBoard"; +import { + mergeProjectBoardExternalRefs, + mergeProjectBoardLinkedTurnIds, + mergeProjectBoardRelatedItemIds, +} from "@t3tools/shared/projectBoard"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; @@ -83,6 +87,7 @@ export const decideProjectBoardCommand = Effect.fn("decideProjectBoardCommand")( notes: command.notes === undefined ? (existing?.notes ?? null) : command.notes, brief: command.brief === undefined ? (existing?.brief ?? null) : command.brief, latestHandoff: existing?.latestHandoff ?? null, + handoffHistory: existing?.handoffHistory ?? [], source: command.source ?? existing?.source ?? "user", sourceThreadId: command.sourceThreadId === undefined @@ -94,6 +99,17 @@ export const decideProjectBoardCommand = Effect.fn("decideProjectBoardCommand")( ...(command.linkTurnId !== undefined ? { linkTurnId: command.linkTurnId } : {}), }), area: command.area === undefined ? (existing?.area ?? null) : command.area, + externalRefs: mergeProjectBoardExternalRefs({ + existing: existing?.externalRefs, + ...(command.externalRefs !== undefined ? { externalRefs: command.externalRefs } : {}), + }), + relatedItemIds: mergeProjectBoardRelatedItemIds({ + existing: existing?.relatedItemIds, + ...(command.relatedItemIds !== undefined + ? { relatedItemIds: command.relatedItemIds } + : {}), + selfId: command.itemId, + }), archivedAt: existing?.archivedAt ?? null, createdAt: existing?.createdAt ?? occurredAt, updatedAt: occurredAt, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 77d8abe66730..da491f7274ea 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -8,6 +8,9 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; +// Additive import: no-manual-effect-runtime-in-tests caps this file's Effect.runPromise +// count at its existing baseline, so new tests use it.effect instead. +import { it as itEffect } from "@effect/vitest"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -123,6 +126,89 @@ describe("orchestration projector", () => { }); }); + itEffect.effect("accumulates handoffHistory newest-first and caps it", () => + Effect.gen(function* () { + const createdAt = "2026-08-13T10:00:00.000Z"; + let model = createEmptyReadModel(createdAt); + for (const event of [ + makeEvent({ + sequence: 1, + type: "project.created", + aggregateKind: "project", + aggregateId: "project-board", + occurredAt: createdAt, + commandId: "cmd-project", + payload: { + projectId: "project-board", + title: "Board", + workspaceRoot: "/tmp/board", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }), + makeEvent({ + sequence: 2, + type: "project.board-item-upserted", + aggregateKind: "project", + aggregateId: "project-board", + occurredAt: createdAt, + commandId: "cmd-upsert", + payload: { + projectId: "project-board", + item: { + id: "item-1", + title: "Handoff task", + status: "inProgress", + source: "user", + createdAt, + updatedAt: createdAt, + }, + updatedAt: createdAt, + }, + }), + ]) { + model = yield* projectEvent(model, event); + } + + const handoffEvent = (sequence: number, summary: string) => + makeEvent({ + sequence, + type: "project.board-item-handoff-appended", + aggregateKind: "project", + aggregateId: "project-board", + occurredAt: createdAt, + commandId: `cmd-handoff-${sequence}`, + payload: { + projectId: "project-board", + itemId: "item-1", + itemTitle: "Handoff task", + handoff: { + id: `handoff-${sequence}`, + sourceThreadId: "thread-1", + summary, + decisions: [], + nextStep: "keep going", + createdAt, + }, + updatedAt: createdAt, + }, + }); + + for (let sequence = 3; sequence <= 13; sequence += 1) { + model = yield* projectEvent(model, handoffEvent(sequence, `handoff ${sequence}`)); + } + + const item = model.projects[0]?.boardItems?.[0]; + expect(item?.latestHandoff?.summary).toBe("handoff 13"); + // 11 handoffs appended (sequence 3..13), capped at 10, newest first. + expect(item?.handoffHistory).toHaveLength(10); + expect(item?.handoffHistory?.[0]?.summary).toBe("handoff 13"); + expect(item?.handoffHistory?.[9]?.summary).toBe("handoff 4"); + }), + ); + it("applies thread.created events", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ad91297dd1b9..3e2170d357bd 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -5,6 +5,7 @@ import { OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { pushProjectBoardHandoffHistory } from "@t3tools/shared/projectBoard"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -350,7 +351,15 @@ export function projectEvent( ...project, boardItems: (project.boardItems ?? []).map((item) => item.id === payload.itemId - ? { ...item, latestHandoff: payload.handoff, updatedAt: payload.updatedAt } + ? { + ...item, + latestHandoff: payload.handoff, + handoffHistory: pushProjectBoardHandoffHistory({ + existing: item.handoffHistory, + handoff: payload.handoff, + }), + updatedAt: payload.updatedAt, + } : item, ), updatedAt: payload.updatedAt, diff --git a/apps/web/src/components/ProjectBoardPanel.logic.ts b/apps/web/src/components/ProjectBoardPanel.logic.ts index da8528eabb6e..26ce83f831d4 100644 --- a/apps/web/src/components/ProjectBoardPanel.logic.ts +++ b/apps/web/src/components/ProjectBoardPanel.logic.ts @@ -1,6 +1,7 @@ import type { EnvironmentId, ProjectBoardItem, + ProjectBoardItemId, ProjectBoardItemStatus, ThreadId, } from "@t3tools/contracts"; @@ -138,6 +139,8 @@ export interface BoardItemDraft { briefCriteria: string; briefFiles: string; briefNotes: string; + externalRefs: string; + relatedItemIds: ProjectBoardItemId[]; } export function createBoardItemDraft(item: ProjectBoardItem): BoardItemDraft { @@ -150,6 +153,8 @@ export function createBoardItemDraft(item: ProjectBoardItem): BoardItemDraft { briefCriteria: item.brief?.acceptanceCriteria.join("\n") ?? "", briefFiles: item.brief?.importantFiles.join("\n") ?? "", briefNotes: item.brief?.notes ?? "", + externalRefs: item.externalRefs?.join("\n") ?? "", + relatedItemIds: item.relatedItemIds ? [...item.relatedItemIds] : [], }; } diff --git a/apps/web/src/components/ProjectBoardPanel.tsx b/apps/web/src/components/ProjectBoardPanel.tsx index aad080d72323..f986e6ed7c6d 100644 --- a/apps/web/src/components/ProjectBoardPanel.tsx +++ b/apps/web/src/components/ProjectBoardPanel.tsx @@ -445,6 +445,8 @@ export function ProjectBoardPanel({ status: itemDraft.status, notes: itemDraft.notes.trim() || null, area: itemDraft.area.trim() || null, + externalRefs: list(itemDraft.externalRefs), + relatedItemIds: itemDraft.relatedItemIds, source: detailItem.source, sourceThreadId: detailItem.sourceThreadId ?? null, brief: goal @@ -698,6 +700,67 @@ export function ProjectBoardPanel({ onChange={(event) => setItemDraft({ ...itemDraft, briefNotes: event.target.value })} placeholder="Brief notes" /> +