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
51 changes: 51 additions & 0 deletions .claude/skills/visual-plan/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: visual-plan
description: Use when the user asks you to plan a feature, change, or task and wants to review it visually — renders an interactive, structured plan in the GITS visual plan side panel instead of a wall of chat text. Trigger on "plan", "visual plan", "render a plan", or when entering plan mode in GITS.
---

# Visual Plan

GITS hosts a native visual-plan MCP server (`gits-visual-plan`). Instead of dumping a
plan into chat, author a **structured block document** that renders live in the GITS
visual plan side panel, where the user can read, edit, and comment on it. The document
is the source of truth, not the chat.

## Tools

All tools are exposed by the `gits-visual-plan` MCP server (call them as
`mcp__gits-visual-plan__<tool>`):

- `get-plan-blocks` — the authoritative block catalog. **Always call this first.**
- `create-visual-plan` — create/replace the plan: `{ title?, brief?, content }`.
- `update-visual-plan` — apply `{ contentPatches }` (targeted edits).
- `get-visual-plan` — read the current plan JSON.
- `get-plan-feedback` — read the user's anchored comments. **Call before editing.**
- `export-visual-plan` — get the plan + open comments as one markdown document.

## Workflow

1. **Research first.** Inspect the real files, symbols, and schema you'll touch. Name
them concretely in the plan — never plan against imagined code.
2. **Call `get-plan-blocks`** to load the current block catalog.
3. **Author `content`** as `{ version: 1, title, brief, blocks: [...] }` and call
`create-visual-plan`. Lead with the outcome (a `rich-text` block), then break the
work into the right blocks:
- `rich-text` for prose/rationale (GFM markdown).
- `checklist` for the step-by-step task breakdown.
- `annotated-code` / `implementation-map` / `file-tree` for the files you'll change.
- `api-endpoint` / `data-model` for contracts and schema.
- `callout` with `tone: "decision"` for hard-to-reverse choices; `tone: "risk"` for risks.
- `question-form` (single block, at the end) for open questions needing the user's call.
3. **Tell the user** the plan is rendering in the GITS visual plan panel and ask them to
review, edit, and comment there. **Do not start implementing** until they approve.
4. **Before revising,** call `get-plan-feedback`. Act on comments whose
`resolutionTarget` is `"agent"`; apply targeted `update-visual-plan` patches rather
than recreating the whole plan.
5. When the user approves, implement from the plan (and their edits/comments).

## Discipline

- Planning is read-only — make no source edits until the user approves the plan.
- Decide the load-bearing bets up front (wire formats, public ids, data-model shape,
auth boundaries) and record them as `decision` callouts.
- Don't ship a single-step plan; if the work is trivial, just do it.
1 change: 1 addition & 0 deletions apps/server/src/crit/critHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const make_thread = (overrides: Partial<OrchestrationThread> = {}): Orchestratio
deletedAt: null,
messages: [],
proposedPlans: [],
visualPlans: [],
activities: [],
checkpoints: [],
session: null,
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/gits/mcp/VisualPlanMcpRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Process-global registry backing the native visual-plan MCP endpoint.
*
* Maps per-session bearer tokens to their thread, and caches the latest plan
* state per thread so the `update-visual-plan` tool can apply patches against
* the current content. Implemented as a module-level singleton (not an Effect
* service) so the MCP route and the provider adapters share one instance
* without threading a layer requirement through the entire provider stack.
*/
import { randomUUID } from "node:crypto";
import type { PlanComment, PlanContent, ThreadId } from "@t3tools/contracts";

/** HTTP path of the native visual-plan MCP endpoint. */
export const VISUAL_PLAN_MCP_PATH = "/api/gits/visual-plan/mcp";

export interface VisualPlanState {
readonly planId: string;
readonly content: PlanContent;
readonly comments: ReadonlyArray<PlanComment>;
readonly createdAt: string;
}

const tokensToThread = new Map<string, ThreadId>();
const threadToToken = new Map<ThreadId, string>();
const threadState = new Map<ThreadId, VisualPlanState>();

/** Mint (or reuse) a bearer token for a thread's MCP session. */
export function issueVisualPlanToken(threadId: ThreadId): string {
const existing = threadToToken.get(threadId);
if (existing) {
return existing;
}
const token = `vpmcp_${randomUUID()}`;
tokensToThread.set(token, threadId);
threadToToken.set(threadId, token);
return token;
}

export function resolveVisualPlanThread(token: string): ThreadId | undefined {
return tokensToThread.get(token);
}

export function getVisualPlanState(threadId: ThreadId): VisualPlanState | undefined {
return threadState.get(threadId);
}

export function setVisualPlanState(threadId: ThreadId, state: VisualPlanState): void {
threadState.set(threadId, state);
}
270 changes: 270 additions & 0 deletions apps/server/src/gits/mcp/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/**
* Native visual-plan MCP endpoint.
*
* A minimal Streamable-HTTP MCP server (JSON-RPC 2.0 over POST) that any agent
* provider (Claude / Codex / Cursor) connects to with a per-session bearer
* token. Tool calls are resolved to the owning thread via the token and
* dispatched into the orchestration engine as `thread.visual-plan.upsert`
* commands, so the plan renders live in the GITS visual plan panel.
*/
import {
CommandId,
type OrchestrationVisualPlan,
PlanComment,
PlanContent,
PlanContentPatch,
type ThreadId,
} from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";

import { browserApiCorsHeaders } from "../../httpCors.ts";
import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import {
getVisualPlanState,
resolveVisualPlanThread,
setVisualPlanState,
VISUAL_PLAN_MCP_PATH,
type VisualPlanState,
} from "./VisualPlanMcpRegistry.ts";
import {
applyPlanPatches,
buildBlockCatalog,
exportPlanToMarkdown,
VISUAL_PLAN_TOOLS,
} from "./visualPlanModel.ts";

const PROTOCOL_VERSION = "2025-06-18";

const decodeContent = Schema.decodeUnknownEffect(PlanContent);
const decodePatches = Schema.decodeUnknownEffect(Schema.Array(PlanContentPatch));
const encodeJson = Schema.encodeSync(Schema.UnknownFromJsonString);
const jsonString = (value: unknown): string => encodeJson(value);

interface JsonRpcMessage {
readonly jsonrpc?: string;
readonly id?: string | number | null;
readonly method?: string;
readonly params?: Record<string, unknown>;
}

function jsonRpcResult(id: string | number | null, result: unknown) {
return HttpServerResponse.jsonUnsafe(
{ jsonrpc: "2.0", id, result },
{ status: 200, headers: browserApiCorsHeaders },
);
}

function jsonRpcError(id: string | number | null, code: number, message: string) {
return HttpServerResponse.jsonUnsafe(
{ jsonrpc: "2.0", id, error: { code, message } },
{ status: 200, headers: browserApiCorsHeaders },
);
}

function toolText(text: string, isError = false) {
return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
}

const bearerFromRequest = (request: HttpServerRequest.HttpServerRequest): Option.Option<string> => {
const header = request.headers["authorization"] ?? request.headers["Authorization"];
if (!header) {
return Option.none();
}
const match = /^Bearer\s+(.+)$/i.exec(header);
const value = match?.[1];
return value ? Option.some(value.trim()) : Option.none();
};

const nowIso = Effect.map(DateTime.now, DateTime.formatIso);

/** Load the current plan state from cache, falling back to the persisted read model. */
const loadState = (threadId: ThreadId) =>
Effect.gen(function* () {
const cached = getVisualPlanState(threadId);
if (cached) {
return Option.some(cached);
}
const snapshot = yield* ProjectionSnapshotQuery;
const detail = yield* snapshot
.getThreadDetailById(threadId)
.pipe(Effect.orElseSucceed(() => Option.none()));
if (Option.isNone(detail)) {
return Option.none<VisualPlanState>();
}
const plans = detail.value.visualPlans;
const latest = plans.length > 0 ? plans[plans.length - 1] : undefined;
if (!latest) {
return Option.none<VisualPlanState>();
}
return Option.some<VisualPlanState>({
planId: latest.id,
content: latest.content,
comments: latest.comments,
createdAt: latest.createdAt,
});
});

const upsertVisualPlan = (threadId: ThreadId, next: VisualPlanState) =>
Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
const crypto = yield* Crypto.Crypto;
const at = yield* nowIso;
const uuid = yield* crypto.randomUUIDv4;
const visualPlan: OrchestrationVisualPlan = {
id: next.planId,
turnId: null,
content: next.content,
comments: next.comments,
createdAt: next.createdAt,
updatedAt: at,
};
yield* engine
.dispatch({
type: "thread.visual-plan.upsert",
commandId: CommandId.make(`visual-plan:${threadId}:${uuid}`),
threadId,
visualPlan,
createdAt: at,
})
.pipe(
Effect.catch((cause: unknown) => Effect.logError("visual-plan dispatch failed", cause)),
);
setVisualPlanState(threadId, next);
});

const callTool = (threadId: ThreadId, name: string, args: Record<string, unknown>) =>
Effect.gen(function* () {
switch (name) {
case "get-plan-blocks":
return toolText(jsonString(buildBlockCatalog()));

case "get-visual-plan": {
const state = yield* loadState(threadId);
return Option.isSome(state)
? toolText(jsonString(state.value.content))
: toolText("No visual plan exists yet for this session.");
}

case "get-plan-feedback": {
const state = yield* loadState(threadId);
const comments = Option.isSome(state) ? state.value.comments : [];
return toolText(jsonString(comments));
}

case "export-visual-plan": {
const state = yield* loadState(threadId);
return Option.isSome(state)
? toolText(exportPlanToMarkdown(state.value.content, state.value.comments))
: toolText("No visual plan exists yet for this session.");
}

case "create-visual-plan": {
const decoded = yield* Effect.option(decodeContent(args.content));
if (Option.isNone(decoded)) {
return toolText("Invalid plan content. Call get-plan-blocks and retry.", true);
}
const existing = yield* loadState(threadId);
const at = yield* nowIso;
const next: VisualPlanState = {
planId: Option.isSome(existing) ? existing.value.planId : `vp_${threadId}`,
content: decoded.value,
comments: Option.isSome(existing) ? existing.value.comments : [],
createdAt: Option.isSome(existing) ? existing.value.createdAt : at,
};
yield* upsertVisualPlan(threadId, next);
return toolText(
"Visual plan created. It is now rendering in the GITS visual plan side panel.",
);
}

case "update-visual-plan": {
const decoded = yield* Effect.option(decodePatches(args.contentPatches));
if (Option.isNone(decoded)) {
return toolText("Invalid contentPatches. Call get-plan-blocks and retry.", true);
}
const existing = yield* loadState(threadId);
if (Option.isNone(existing)) {
return toolText("No visual plan to update. Call create-visual-plan first.", true);
}
const nextContent = applyPlanPatches(existing.value.content, decoded.value);
yield* upsertVisualPlan(threadId, { ...existing.value, content: nextContent });
return toolText("Visual plan updated.");
}

default:
return toolText(`Unknown tool: ${name}`, true);
}
});

export const visualPlanMcpRouteLayer = HttpRouter.add(
"POST",
VISUAL_PLAN_MCP_PATH,
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;

const token = bearerFromRequest(request);
if (Option.isNone(token)) {
return HttpServerResponse.jsonUnsafe(
{ jsonrpc: "2.0", id: null, error: { code: -32001, message: "Missing bearer token" } },
{ status: 401, headers: browserApiCorsHeaders },
);
}
const threadId = resolveVisualPlanThread(token.value);
if (!threadId) {
return HttpServerResponse.jsonUnsafe(
{ jsonrpc: "2.0", id: null, error: { code: -32001, message: "Invalid session token" } },
{ status: 401, headers: browserApiCorsHeaders },
);
}

const body = yield* Effect.option(HttpServerRequest.schemaBodyJson(Schema.Unknown));
const message = (Option.getOrElse(body, () => ({})) ?? {}) as JsonRpcMessage;

const method = message.method ?? "";
const id = message.id ?? null;

if (method.startsWith("notifications/")) {
return HttpServerResponse.empty({ status: 202 });
}

switch (method) {
case "initialize": {
const requested =
(message.params?.protocolVersion as string | undefined) ?? PROTOCOL_VERSION;
return jsonRpcResult(id, {
protocolVersion: requested,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "gits-visual-plan", version: "0.1.0" },
});
}
case "ping":
return jsonRpcResult(id, {});
case "tools/list":
return jsonRpcResult(id, { tools: VISUAL_PLAN_TOOLS });
case "tools/call": {
const name = (message.params?.name as string | undefined) ?? "";
const args = (message.params?.arguments as Record<string, unknown> | undefined) ?? {};
const result = yield* callTool(threadId, name, args);
return jsonRpcResult(id, result);
}
default:
return jsonRpcError(id, -32601, `Method not found: ${method}`);
}
}).pipe(
Effect.catch((cause: unknown) =>
Effect.gen(function* () {
yield* Effect.logError("visual-plan MCP route failed", cause);
return HttpServerResponse.jsonUnsafe(
{ jsonrpc: "2.0", id: null, error: { code: -32603, message: "Internal error" } },
{ status: 200, headers: browserApiCorsHeaders },
);
}),
),
),
);
Loading
Loading