mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
121082430e
* fix(opencode): consolidate V2 system parts into one composed prompt (#1114) The OpenCode 2 adapter still shipped the pre-#1114 multi-part system injection: replacePlanningSystemParts kept one part per source and the generic reminder pushed a separate part, so Qwen3.x Jinja template corruption persisted for OpenCode 2 users. Mirror the V1 entry exactly: compose the stripped existing text plus additions into a single system part via composeSystemPrompt, and compose the generic reminder into the existing text instead of appending a second part. Also adds the regression tests for the bug class flagged in #1114's review: both helpers must read/compose the existing system text BEFORE truncating the array (a reorder to 'system.length = 0' first drops the host prompt and goes red here). * perf(annotate): harden the raw-HTML overlay reconcile (dead-target backoff, cull, batching) Bridge-script hardening for mutation-heavy pages and large annotation sets, plus the lost click-to-select hover affordance: - A: dead-target re-search now carries a wall-clock backoff (300ms doubling to a 5s cap, reset on success) ON TOP of the generation gate, plus a 2-searches-per-reconcile-pass budget with a scheduled follow-up pass for budget-skipped eligible targets. A page that mutates every frame advances domGeneration every frame, so the generation gate alone re-ran the whole-document TreeWalker sweep (and anchor re-resolution) per frame forever for permanently unresolvable targets. - B1: early viewport cull (64px margin) for element and range targets: wholly offscreen targets skip targetStyleHidden / getComputedStyle / clipBoundsFor / client-rect collection entirely and just omit their markers, which is what the visible pipeline produced anyway. - B2: read/write batching in renderAnnotationOverlay: highlight rects are queued during the read phase and flushed as one write phase, so the pass no longer forces a synchronous layout per record. - B3: restoreAnnotation defers its render through the existing rAF-coalesced reconcile scheduler; restoring N annotations now renders once instead of N full passes (searches stay synchronous for the mark-applied reply). DOM tests flush the frame via the suite's standard macrotask flush. - B4: zero-work observer gate: page mutations with no records, no pending draft, and pinpoint inactive still bump domGeneration but no longer schedule a reconcile frame. - D: hover affordance for click-to-select: the rAF-throttled mousemove hit-tests the pointer against the CACHED rendered committed rects and toggles a brightness class on that annotation's rect divs inside the shadow root. No page-DOM writes, rects stay pointer-transparent, and shadow-root writes are unobserved so there is no reconcile loop. - G: while a text drag is in progress in drag mode, placed markers yield pointer input (data-pn-hittest) so the 25px bubble cannot capture a selection drag; armed only by a >4px primary-button move from a non-overlay mousedown, so marker clicks and click-to-select paths are untouched. withMarkersYielded now restores (not clears) the attribute. New regression tests for A, B1, B3, B4, D; A/B1/B3 mutation-verified (fix reverted, test observed failing, fix restored). * fix(annotate): make on-page marker numbers match exportAnnotations numbering The HtmlViewer sync excluded GLOBAL_COMMENT annotations before numbering while exportAnnotations numbers '## N.' sections across the FULL list including globals — so an on-page 'Comment 2' could be '## 3.' in the feedback the agent reads. The sync now derives each marker's number from its position in the full createdA-sorted list (globals occupy a number but ship no entry, leaving the correct gaps on-page). Export format is unchanged. New buildSyncNumbering helper + tests asserting a mixed list yields identical numbers between the sync payload and exportAnnotations output (mutation-verified against the pre-fix ordering). * chore: sync stale workspace versions in bun.lock (0.26.1 -> 0.26.7) * docs: document raw-HTML overlay model, multi-target types, and known limitations - Data Types: add htmlAdditionalTargets to the Annotation listing plus the HtmlElementAnchor (including the optional normalized point used by placed markers) and HtmlAnnotationTarget shapes. - Annotation System: describe the post-#1257 raw-HTML surface (placed comment markers + overlay-projected highlights, no inline mark mutation; durable anchors persisted, disposable markers projected) and the print-parity limitation. - URL Sharing: note that share links intentionally drop HTML element anchors and additional targets (restore is text-search based, per sharing.multiTarget.test.ts). * test: fix Range.getClientRects stub typing in the B1 cull test * fix(annotate): hover-race teardown and unbounded one-shot dead-search passes Polish round on the overlay hardening: - Hover race (1): switching into pinpoint mode (or opening a draft) now tears hover down fully via clearHoverHighlight() — cancels the pending rAF hit test and clears the tracked position and id — and the rAF callback itself refuses to paint outside drag mode / with an open draft. Previously the pending callback re-applied the class after the mode switch and every flushQueuedHighlights re-painted it from the stale hoverHighlightId, leaving a permanent phantom hover. - One-shot budgets (3): beginDeadSearchPass takes a per-pass budget. Reconcile passes keep 2 (they repeat, skipped targets get follow-up frames); print and scroll-to are user-initiated one-shots with no follow-up and now run unbounded (backoff and generation gates still apply), so printing with 3+ dead-but-recoverable targets no longer silently prints fewer highlights. Both changes carry new regression tests, mutation-verified (fix reverted, test observed failing, fix restored). * fix(annotate): number markers by array position and cap entries after dropping globals The createdA sort made the export-match invariant false with external annotations: exportAnnotations' sort keys tie for every raw-HTML annotation (blockId '', startOffset 0), so its stable sort numbers the combined [...local, ...external] list in ARRAY order — and external annotations arrive appended with server-stamped createdA values that can interleave with local timestamps. buildSyncNumbering now numbers by array position of the input (verified to be the same combined list both consumers receive from packages/editor/App.tsx allAnnotations; the viewerAnnotations diffContext filter is order-preserving and vacuous on the raw-HTML surface). Also reorders the cap: number the full list, drop globals, THEN slice 512 entries — globals no longer waste sync capacity and a non-global the export numbers past position 512 still syncs while slots remain. Numbers may now exceed 512 (array positions); the bridge's own bound (100000) accepts them and its 512-entry cap still agrees with the sender. Tests updated: interleaved-external agreement with exportAnnotations (mutation-verified against the createdA sort) and slice-after-filter capacity. * docs(opencode): note the accepted cache-hint flattening trade-off in V2 consolidation
421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
import type { Plugin } from "@opencode-ai/plugin";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { loadConfig, resolveSharingEnabled } from "@plannotator/shared/config";
|
|
import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
|
|
import { composeImproveContext } from "@plannotator/shared/pfm-reminder";
|
|
import { composeSystemPrompt, stripConflictingPlanModeRules } from "./plan-mode";
|
|
import {
|
|
isPlanningAgent,
|
|
normalizeWorkflowOptions,
|
|
shouldInjectFullPlanningPrompt,
|
|
shouldInjectGenericPlanReminder,
|
|
shouldModifyPrompts,
|
|
shouldRegisterSubmitPlan,
|
|
type PlannotatorOpenCodeOptions,
|
|
type RuntimeMode,
|
|
} from "./workflow";
|
|
import {
|
|
runCliPlanReview,
|
|
type OpenCodeBridgeAgent,
|
|
type OpenCodeBridgeContext,
|
|
type OpenCodePlanReviewResult,
|
|
} from "./cli-bridge";
|
|
import { resolveTargetAgent } from "./agent-switch";
|
|
import { executeSubmitPlan } from "./submit-plan-executor";
|
|
import type { PlanEdit } from "./plan-edits";
|
|
import { getPlanningPrompt } from "./planning-prompt";
|
|
|
|
const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600;
|
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
let planHtml: string | undefined;
|
|
|
|
type V2Client = {
|
|
app: {
|
|
agents: () => Promise<{ data: OpenCodeBridgeAgent[] }>;
|
|
log: (entry: { level: "info" | "error"; message: string }) => void;
|
|
};
|
|
};
|
|
|
|
type EmbeddedRuntimeModule = {
|
|
runEmbeddedPlanReview: (input: {
|
|
client: V2Client;
|
|
planContent: string;
|
|
sharingEnabled: boolean;
|
|
shareBaseUrl?: string;
|
|
pasteApiUrl?: string;
|
|
htmlContent: string;
|
|
timeoutSeconds: number | null;
|
|
abortSignal?: AbortSignal;
|
|
logReady: (url: string, isRemote: boolean, port: number) => void;
|
|
}) => Promise<OpenCodePlanReviewResult>;
|
|
};
|
|
|
|
// `Plugin.define` is an identity function in @opencode-ai/plugin; keeping the import
|
|
// type-only avoids shipping a runtime dependency on an exact prerelease nightly.
|
|
const serverPlugin = {
|
|
id: "plannotator",
|
|
setup: async (ctx) => {
|
|
const workflowOptions = normalizeWorkflowOptions(ctx.options as PlannotatorOpenCodeOptions);
|
|
let cachedAgents: OpenCodeBridgeAgent[] | undefined;
|
|
|
|
const getAgents = async (): Promise<OpenCodeBridgeAgent[]> => {
|
|
if (cachedAgents) return cachedAgents;
|
|
try {
|
|
const response = await ctx.agent.list();
|
|
cachedAgents = response.data.map((agent) => ({
|
|
name: agent.id,
|
|
description: agent.description,
|
|
mode: agent.mode,
|
|
hidden: agent.hidden,
|
|
}));
|
|
} catch {
|
|
cachedAgents = [];
|
|
}
|
|
return cachedAgents;
|
|
};
|
|
|
|
if (shouldModifyPrompts(workflowOptions)) {
|
|
await ctx.session.hook("context", async (event) => {
|
|
if (
|
|
workflowOptions.workflow === "plan-agent"
|
|
&& !isPlanningAgent(event.agent, workflowOptions)
|
|
) {
|
|
delete event.tools.submit_plan;
|
|
return;
|
|
}
|
|
|
|
const currentAgent = workflowOptions.workflow === "all-agents"
|
|
? (await getAgents()).find((candidate) => candidate.name === event.agent)
|
|
: undefined;
|
|
if (!allowSubagents() && currentAgent?.mode === "subagent") {
|
|
delete event.tools.submit_plan;
|
|
return;
|
|
}
|
|
|
|
if (event.tools.plan_exit) {
|
|
event.tools.plan_exit.description =
|
|
"Do not call this tool. Use submit_plan instead - it opens a visual review UI for plan approval.";
|
|
}
|
|
if (event.tools.todowrite) {
|
|
event.tools.todowrite.description =
|
|
"While actively planning with the user, use submit_plan instead. Only use todos once implementation begins or unless the user explicitly asks.";
|
|
}
|
|
|
|
replaceStrictPlanReminder(event.messages);
|
|
|
|
const systemText = event.system.map((part) => part.text).join("\n").toLowerCase();
|
|
if (systemText.includes("title generator") || systemText.includes("generate a title")) return;
|
|
|
|
if (shouldInjectFullPlanningPrompt(event.agent, workflowOptions)) {
|
|
const additions = [getPlanningPrompt()];
|
|
const hook = readImprovementHook("enterplanmode-improve");
|
|
const improveContext = composeImproveContext({
|
|
pfmEnabled: loadConfig().pfmReminder === true,
|
|
improvementHookContent: hook?.content ?? null,
|
|
});
|
|
if (improveContext) additions.push(improveContext);
|
|
replacePlanningSystemParts(
|
|
event.system,
|
|
additions,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!shouldInjectGenericPlanReminder(
|
|
event.agent,
|
|
currentAgent?.mode === "subagent",
|
|
workflowOptions,
|
|
)) return;
|
|
|
|
pushComposedSystemReminder(event.system, getGenericPlanReminder());
|
|
});
|
|
}
|
|
|
|
if (!shouldRegisterSubmitPlan(workflowOptions)) return;
|
|
|
|
await ctx.tool.transform((tools) => {
|
|
tools.add({
|
|
name: "submit_plan",
|
|
description:
|
|
"Submit a plan for user review via line-range edits. First call: pass a single edit with start=1 and your full plan as content (omit end). Subsequent calls after denial: pass targeted edits using the line numbers from the previous response. The tool manages a backing file; you never touch the file directly.",
|
|
input: {
|
|
type: "object",
|
|
properties: {
|
|
edits: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
start: {
|
|
type: "number",
|
|
description: "1-indexed start line (inclusive)",
|
|
},
|
|
end: {
|
|
type: "number",
|
|
description: "1-indexed end line (inclusive). Omit to replace from start through end of file.",
|
|
},
|
|
content: {
|
|
type: "string",
|
|
description: "Replacement content. Empty string deletes the line range.",
|
|
},
|
|
},
|
|
required: ["start", "content"],
|
|
additionalProperties: false,
|
|
},
|
|
description: "Array of line-range edits to apply to the plan.",
|
|
},
|
|
},
|
|
required: ["edits"],
|
|
additionalProperties: false,
|
|
},
|
|
options: { codemode: false },
|
|
execute: async (input, toolContext) => {
|
|
const session = await ctx.session.get({ sessionID: toolContext.sessionID });
|
|
const directory = session.location.directory;
|
|
const bridge = await getBridgeContext(getAgents);
|
|
const client = createV2Client(getAgents);
|
|
const result = await executeSubmitPlan({
|
|
edits: getPlanEdits(input),
|
|
invokingAgent: toolContext.agent,
|
|
sessionId: toolContext.sessionID,
|
|
directory,
|
|
workflowOptions,
|
|
}, {
|
|
reviewPlan: async ({ planContent }) => await runPlanReview({
|
|
client,
|
|
runtime: workflowOptions.runtime,
|
|
planContent,
|
|
sharingEnabled: bridge.sharingEnabled ?? true,
|
|
shareBaseUrl: bridge.shareBaseUrl,
|
|
pasteApiUrl: bridge.pasteApiUrl,
|
|
timeoutSeconds: getPlanTimeoutSeconds(),
|
|
directory,
|
|
bridge,
|
|
}),
|
|
resolveTargetAgent: async ({ requestedAgent }) => {
|
|
const targetAgent = resolveTargetAgent(requestedAgent);
|
|
if (!targetAgent) return undefined;
|
|
const available = (await getAgents()).some((agent) => agent.name === targetAgent);
|
|
if (!available) {
|
|
console.error(`[Plannotator] Configured OpenCode agent "${targetAgent}" is not available; approving the plan without switching agents.`);
|
|
return undefined;
|
|
}
|
|
// The current OpenCode 2 API exposes no session-agent switch operation to plugins.
|
|
console.error("[Plannotator] OpenCode 2 does not currently expose agent switching to plugins; approving the plan without switching agents.");
|
|
return undefined;
|
|
},
|
|
sendApprovalHandoff: async () => {},
|
|
});
|
|
|
|
return { content: result };
|
|
},
|
|
});
|
|
});
|
|
},
|
|
} satisfies Plugin.Plugin;
|
|
|
|
function getPlanEdits(input: unknown): PlanEdit[] | undefined {
|
|
if (!input || typeof input !== "object") return undefined;
|
|
const edits = Reflect.get(input, "edits");
|
|
return Array.isArray(edits) ? edits as PlanEdit[] : undefined;
|
|
}
|
|
|
|
function getPlanTimeoutSeconds(): number | null {
|
|
const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim();
|
|
if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
console.error(`[Plannotator] Invalid PLANNOTATOR_PLAN_TIMEOUT_SECONDS="${raw}". Using default ${DEFAULT_PLAN_TIMEOUT_SECONDS}s.`);
|
|
return DEFAULT_PLAN_TIMEOUT_SECONDS;
|
|
}
|
|
return parsed === 0 ? null : parsed;
|
|
}
|
|
|
|
function createV2Client(
|
|
getAgents: () => Promise<OpenCodeBridgeAgent[]>,
|
|
): V2Client {
|
|
const loggedUrls = new Set<string>();
|
|
return {
|
|
app: {
|
|
agents: async () => ({ data: await getAgents() }),
|
|
log: ({ message }) => {
|
|
const url = /https?:\/\/\S+/.exec(message)?.[0];
|
|
if (url && loggedUrls.has(url)) return;
|
|
if (url) loggedUrls.add(url);
|
|
console.error(message);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function allowSubagents(): boolean {
|
|
const value = process.env.PLANNOTATOR_ALLOW_SUBAGENTS?.trim();
|
|
return value === "1" || value === "true";
|
|
}
|
|
|
|
async function getBridgeContext(
|
|
getAgents: () => Promise<OpenCodeBridgeAgent[]>,
|
|
): Promise<OpenCodeBridgeContext> {
|
|
return {
|
|
sharingEnabled: resolveSharingEnabled(loadConfig()),
|
|
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
|
|
pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
|
|
agents: await getAgents(),
|
|
};
|
|
}
|
|
|
|
function hasEmbeddedRuntime(): boolean {
|
|
return typeof (globalThis as typeof globalThis & { Bun?: { serve?: unknown } }).Bun?.serve === "function";
|
|
}
|
|
|
|
async function importEmbeddedRuntime(): Promise<EmbeddedRuntimeModule> {
|
|
const builtPath = path.join(moduleDir, "embedded.js");
|
|
if (existsSync(builtPath)) {
|
|
return await import(pathToFileURL(builtPath).href) as EmbeddedRuntimeModule;
|
|
}
|
|
const sourceSpecifier = "./embedded";
|
|
return await import(sourceSpecifier) as EmbeddedRuntimeModule;
|
|
}
|
|
|
|
function getPlanHtml(): string {
|
|
if (planHtml) return planHtml;
|
|
const candidates = [
|
|
path.join(moduleDir, "plannotator.html"),
|
|
path.join(moduleDir, "..", "plannotator.html"),
|
|
];
|
|
const htmlPath = candidates.find((candidate) => existsSync(candidate));
|
|
if (!htmlPath) throw new Error("Could not find bundled HTML asset: plannotator.html");
|
|
planHtml = readFileSync(htmlPath, "utf-8");
|
|
return planHtml;
|
|
}
|
|
|
|
async function runPlanReview(input: {
|
|
client: V2Client;
|
|
runtime: RuntimeMode;
|
|
planContent: string;
|
|
sharingEnabled: boolean;
|
|
shareBaseUrl?: string;
|
|
pasteApiUrl?: string;
|
|
timeoutSeconds: number | null;
|
|
abortSignal?: AbortSignal;
|
|
directory: string;
|
|
bridge: OpenCodeBridgeContext;
|
|
}): Promise<OpenCodePlanReviewResult> {
|
|
if (input.runtime === "embedded" && !hasEmbeddedRuntime()) {
|
|
throw new Error('runtime "embedded" requires a Bun-hosted OpenCode plugin runtime. Use runtime "auto" or "cli" with this OpenCode host.');
|
|
}
|
|
|
|
if (input.runtime !== "cli" && hasEmbeddedRuntime()) {
|
|
try {
|
|
const embedded = await importEmbeddedRuntime();
|
|
return await embedded.runEmbeddedPlanReview({
|
|
client: input.client,
|
|
planContent: input.planContent,
|
|
sharingEnabled: input.sharingEnabled,
|
|
shareBaseUrl: input.shareBaseUrl,
|
|
pasteApiUrl: input.pasteApiUrl,
|
|
htmlContent: getPlanHtml(),
|
|
timeoutSeconds: input.timeoutSeconds,
|
|
abortSignal: input.abortSignal,
|
|
// Intentionally empty. OpenCode 2's server-plugin context exposes no log or
|
|
// tui domain, and the V2 client's app.log falls through to console.error,
|
|
// which is the same stderr stream handleServerReady already prints to.
|
|
// Wiring this up would duplicate the session URL in remote mode and add a
|
|
// stray line locally. V1 does target client.app.log and client.tui.showToast,
|
|
// which are HTTP surfaces separate from stderr, so V1 never repeats itself.
|
|
// A real toast here needs an upstream OpenCode API that does not exist yet.
|
|
logReady: () => {},
|
|
});
|
|
} catch (error) {
|
|
if (input.runtime === "embedded") throw error;
|
|
console.error(`[Plannotator] Embedded runtime unavailable; falling back to CLI: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
return await runCliPlanReview({
|
|
client: input.client,
|
|
planContent: input.planContent,
|
|
cwd: input.directory,
|
|
timeoutSeconds: input.timeoutSeconds,
|
|
abortSignal: input.abortSignal,
|
|
bridge: input.bridge,
|
|
});
|
|
}
|
|
|
|
type SystemPart = { type: "text"; text: string; [key: string]: unknown };
|
|
|
|
/**
|
|
* Replace the system array with ONE composed text part (#1114): multiple
|
|
* system parts corrupt Qwen3.x Jinja chat templates, which render each part
|
|
* as its own system message. Mirrors the V1 entry (index.ts) exactly —
|
|
* stripped existing text first, then the additions, joined by blank lines.
|
|
*
|
|
* Order matters: the existing texts are read and composed BEFORE the array is
|
|
* truncated. Reordering to `system.length = 0` first silently drops the
|
|
* host's entire system prompt (the bug class flagged in #1114's review).
|
|
*
|
|
* Accepted trade-off: consolidation flattens per-part metadata (e.g.
|
|
* third-party cache hints) — template integrity beats part-level caching.
|
|
*/
|
|
export function replacePlanningSystemParts(
|
|
system: SystemPart[],
|
|
additions: string[],
|
|
): void {
|
|
const stripped = stripConflictingPlanModeRules(system.map((part) => part.text));
|
|
const composed = composeSystemPrompt([], [...stripped, ...additions.filter(Boolean)]);
|
|
system.length = 0;
|
|
system.push(...composed.map((text) => ({ type: "text" as const, text })));
|
|
}
|
|
|
|
/**
|
|
* Append a reminder by composing it into a single system part (#1114) instead
|
|
* of pushing a separate part — same Jinja-template rationale as above, and
|
|
* the same compose-before-truncate ordering requirement.
|
|
*/
|
|
export function pushComposedSystemReminder(
|
|
system: SystemPart[],
|
|
reminder: string,
|
|
): void {
|
|
const composed = composeSystemPrompt(system.map((part) => part.text), [reminder]);
|
|
system.length = 0;
|
|
system.push(...composed.map((text) => ({ type: "text" as const, text })));
|
|
}
|
|
|
|
function replaceStrictPlanReminder(messages: unknown[]): void {
|
|
for (const message of messages) {
|
|
if (!message || typeof message !== "object" || Reflect.get(message, "role") !== "user") continue;
|
|
const content = Reflect.get(message, "content");
|
|
if (!Array.isArray(content)) continue;
|
|
for (const part of content) {
|
|
if (!part || typeof part !== "object" || Reflect.get(part, "type") !== "text") continue;
|
|
const text = Reflect.get(part, "text");
|
|
if (typeof text !== "string" || !text.includes("STRICTLY FORBIDDEN")) continue;
|
|
Reflect.set(part, "text", `<system-reminder>
|
|
# Plan Mode - System Reminder
|
|
|
|
CRITICAL: Plan mode ACTIVE. You are in a PLANNING phase. The ONLY file modifications
|
|
allowed are writing or editing markdown files (.md) - plans, specs, documentation, etc.
|
|
All other file edits, code modifications, and system changes are STRICTLY FORBIDDEN.
|
|
Do NOT use shell commands to manipulate non-markdown files. Commands may ONLY read/inspect.
|
|
|
|
Use submit_plan to submit the completed plan for user review. Do not proceed with
|
|
implementation until the plan is approved.
|
|
</system-reminder>`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getGenericPlanReminder(): string {
|
|
return `## Plan Submission
|
|
|
|
When you have completed your plan, call the \`submit_plan\` tool to submit it for user review. Pass your full plan as a single edit: \`{ "edits": [{ "start": 1, "content": "..." }] }\`.
|
|
|
|
The user will review your plan in a visual UI where they can annotate, approve, or request changes. If rejected, the response includes your plan with line numbers; use targeted edits to revise specific sections.
|
|
|
|
Do NOT proceed with implementation until your plan is approved.`;
|
|
}
|
|
|
|
export default serverPlugin;
|