From d179b0f5b813614f17037ab6341a4628ee151e9c Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:55:23 -0300 Subject: [PATCH 1/5] Add PFM visual packet metadata --- packages/server/annotate.test.ts | 101 ++++++++++++++++++++++++++ packages/server/annotate.ts | 3 + packages/server/reference-handlers.ts | 6 +- packages/shared/package.json | 1 + packages/shared/pfm-packet.test.ts | 39 ++++++++++ packages/shared/pfm-packet.ts | 95 ++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 packages/shared/pfm-packet.test.ts create mode 100644 packages/shared/pfm-packet.ts diff --git a/packages/server/annotate.test.ts b/packages/server/annotate.test.ts index 86b3034c4..5190ce593 100644 --- a/packages/server/annotate.test.ts +++ b/packages/server/annotate.test.ts @@ -86,6 +86,107 @@ describe("annotate server: /api/save-notes wiring", () => { }); }); +describe("annotate server: visual PFM packet metadata", () => { + let savedPort: string | undefined; + let savedRemote: string | undefined; + + beforeEach(() => { + savedPort = process.env.PLANNOTATOR_PORT; + savedRemote = process.env.PLANNOTATOR_REMOTE; + delete process.env.PLANNOTATOR_PORT; + process.env.PLANNOTATOR_REMOTE = "0"; + }); + + afterEach(() => { + if (savedPort === undefined) delete process.env.PLANNOTATOR_PORT; + else process.env.PLANNOTATOR_PORT = savedPort; + if (savedRemote === undefined) delete process.env.PLANNOTATOR_REMOTE; + else process.env.PLANNOTATOR_REMOTE = savedRemote; + }); + + test("marks a single-file annotate gate visual packet without changing annotate mode", async () => { + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Plan\n", + filePath: join(tmpdir(), "visual-plan.md"), + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const response = await fetch(`${server.url}/api/plan`); + const plan = await response.json() as { + mode?: string; + gate?: boolean; + pfmPacket?: { kind?: string; visual?: boolean; detectedBy?: string }; + }; + + expect(plan.mode).toBe("annotate"); + expect(plan.gate).toBe(true); + expect(plan.pfmPacket).toEqual({ + kind: "visual-plan", + visual: true, + detectedBy: "frontmatter", + }); + } finally { + server.stop(); + } + }); + + test("does not mark ordinary markdown as a visual packet", async () => { + const server = await startAnnotateServer({ + markdown: "# Plain\n\nJust markdown.\n", + filePath: join(tmpdir(), "plain.md"), + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const response = await fetch(`${server.url}/api/plan`); + const plan = await response.json() as { pfmPacket?: unknown }; + + expect(plan.pfmPacket).toBeUndefined(); + } finally { + server.stop(); + } + }); + + test("keeps folder annotate on the file browser flow while marking opened packet files", async () => { + const folderPath = mkdtempSync(join(tmpdir(), "plannotator-visual-packet-folder-")); + const planPath = join(folderPath, "plan.md"); + writeFileSync(planPath, "---\npfm: visual-plan\n---\n\n# Plan\n", "utf-8"); + + const server = await startAnnotateServer({ + markdown: "", + filePath: folderPath, + folderPath, + mode: "annotate-folder", + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const planResponse = await fetch(`${server.url}/api/plan`); + const plan = await planResponse.json() as { mode?: string; pfmPacket?: unknown }; + expect(plan.mode).toBe("annotate-folder"); + expect(plan.pfmPacket).toBeUndefined(); + + const docResponse = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(planPath)}`); + const doc = await docResponse.json() as { + markdown?: string; + pfmPacket?: { kind?: string; visual?: boolean; detectedBy?: string }; + }; + expect(doc.markdown).toContain("# Plan"); + expect(doc.pfmPacket).toEqual({ + kind: "visual-plan", + visual: true, + detectedBy: "frontmatter", + }); + } finally { + server.stop(); + } + }); +}); + describe("annotate server: /api/share-html symlink containment", () => { let savedPort: string | undefined; let savedRemote: string | undefined; diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index e774cbb08..a819afaa6 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -41,6 +41,7 @@ import type { AIEndpoints } from "@plannotator/ai"; import { createHtmlAssetRegistry } from "./html-assets"; import { createBunAgentTerminalBridge } from "./agent-terminal"; import { isAgentTerminalWsRoute, supportsAnnotateAgentTerminalMode } from "@plannotator/shared/agent-terminal"; +import { detectPfmPacket } from "@plannotator/shared/pfm-packet"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -321,6 +322,7 @@ export async function startAnnotateServer( if (url.pathname === "/api/plan" && req.method === "GET") { const displayRawHtml = renderHtml && rawHtml ? htmlAssets.rewriteHtml(rawHtml, filePath) : undefined; const primarySource = getPrimarySource(); + const pfmPacket = displayRawHtml ? null : detectPfmPacket(primarySource.plan); return Response.json({ plan: primarySource.plan, origin, @@ -341,6 +343,7 @@ export async function startAnnotateServer( isWSL: wslFlag, serverConfig: getServerConfig(gitUser), agentTerminal: agentTerminal.capability, + ...(pfmPacket ? { pfmPacket } : {}), ...(recentMessages ? { recentMessages } : {}), }); } diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 53c4676d3..16d46d3b3 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -28,6 +28,7 @@ import { } from "@plannotator/shared/resolve-file"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { disabledSourceSave, type SourceFileSnapshot, type SourceSaveCapability } from "@plannotator/shared/source-save"; +import { detectPfmPacket } from "@plannotator/shared/pfm-packet"; import { createSourceSaveCapability, createSourceSaveCapabilityFromSnapshot, @@ -190,7 +191,10 @@ function applyDocOptions>( } function docJson(data: Record, options?: HandleDocOptions, sourceSnapshot?: SourceFileSnapshot): Response { - return Response.json(applyDocOptions(data, options, sourceSnapshot)); + const packet = typeof data.markdown === "string" && data.renderAs === "markdown" + ? detectPfmPacket(data.markdown) + : null; + return Response.json(applyDocOptions(packet ? { ...data, pfmPacket: packet } : data, options, sourceSnapshot)); } /** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */ diff --git a/packages/shared/package.json b/packages/shared/package.json index f16b0e69c..ce39e37c0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,6 +40,7 @@ "./prompts": "./prompts.ts", "./improvement-hooks": "./improvement-hooks.ts", "./pfm-reminder": "./pfm-reminder.ts", + "./pfm-packet": "./pfm-packet.ts", "./worktree": "./worktree.ts", "./worktree-pool": "./worktree-pool.ts", "./html-to-markdown": "./html-to-markdown.ts", diff --git a/packages/shared/pfm-packet.test.ts b/packages/shared/pfm-packet.test.ts new file mode 100644 index 000000000..ba944b95c --- /dev/null +++ b/packages/shared/pfm-packet.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { detectPfmPacket } from "./pfm-packet"; + +describe("detectPfmPacket", () => { + test("detects visual plan packets from simple frontmatter", () => { + const packet = detectPfmPacket(`--- +pfm: visual-plan +title: Checkout plan +--- + +# Checkout Plan +`); + + expect(packet).toEqual({ + kind: "visual-plan", + visual: true, + detectedBy: "frontmatter", + }); + }); + + test("detects visual plan packets from Plannotator visual block syntax", () => { + const packet = detectPfmPacket(`# Checkout Plan + +:::file-map +- src/app.ts +::: +`); + + expect(packet).toEqual({ + kind: "visual-plan", + visual: true, + detectedBy: "visual-block", + }); + }); + + test("leaves ordinary markdown unclassified", () => { + expect(detectPfmPacket("# Plain doc\n\nJust markdown.\n")).toBeNull(); + }); +}); diff --git a/packages/shared/pfm-packet.ts b/packages/shared/pfm-packet.ts new file mode 100644 index 000000000..8e5e1a79b --- /dev/null +++ b/packages/shared/pfm-packet.ts @@ -0,0 +1,95 @@ +export type PfmPacketKind = "visual-plan"; + +export type PfmPacketDetectionSource = "frontmatter" | "visual-block"; + +export interface PfmPacket { + kind: PfmPacketKind; + visual: true; + detectedBy: PfmPacketDetectionSource; +} + +const VISUAL_PLAN_VALUES = new Set([ + "visual-plan", + "plannotator-visual-plan", + "visual_plan", +]); + +const FRONTMATTER_VISUAL_KEYS = new Set([ + "pfm", + "plannotator", + "plannotator-packet", + "packet", +]); + +const VISUAL_BLOCK_KINDS = new Set([ + "visual-plan", + "file-map", + "diagram", + "annotated-diff", + "code-walkthrough", + "checklist", + "open-questions", +]); + +export function detectPfmPacket(markdown: string): PfmPacket | null { + const frontmatter = extractSimpleFrontmatter(markdown); + if (frontmatter && isVisualPlanFrontmatter(frontmatter)) { + return { kind: "visual-plan", visual: true, detectedBy: "frontmatter" }; + } + + if (hasVisualPlanBlock(markdown)) { + return { kind: "visual-plan", visual: true, detectedBy: "visual-block" }; + } + + return null; +} + +function isVisualPlanFrontmatter(frontmatter: Record): boolean { + for (const [rawKey, rawValue] of Object.entries(frontmatter)) { + const key = normalizeToken(rawKey); + const value = normalizeToken(rawValue); + if (FRONTMATTER_VISUAL_KEYS.has(key) && VISUAL_PLAN_VALUES.has(value)) { + return true; + } + if (key === "visual" && value === "plan") { + return true; + } + } + return false; +} + +function hasVisualPlanBlock(markdown: string): boolean { + for (const line of markdown.split(/\r?\n/)) { + const directive = line.trim().match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\b/); + if (directive && VISUAL_BLOCK_KINDS.has(normalizeToken(directive[1]))) { + return true; + } + } + return false; +} + +function extractSimpleFrontmatter(markdown: string): Record | null { + const trimmed = markdown.trimStart(); + if (!trimmed.startsWith("---")) return null; + const endIndex = trimmed.indexOf("\n---", 3); + if (endIndex === -1) return null; + + const frontmatterRaw = trimmed.slice(4, endIndex).trim(); + const entries: Record = {}; + for (const line of frontmatterRaw.split(/\r?\n/)) { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine.startsWith("#") || trimmedLine.startsWith("- ")) { + continue; + } + const colonIndex = trimmedLine.indexOf(":"); + if (colonIndex <= 0) continue; + const key = trimmedLine.slice(0, colonIndex).trim(); + const value = trimmedLine.slice(colonIndex + 1).trim(); + if (key && value) entries[key] = value; + } + return entries; +} + +function normalizeToken(value: string): string { + return value.trim().replace(/^['"]|['"]$/g, "").toLowerCase(); +} From 839ad57d0a1c83cfa222bed8e8719c5010069545 Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:52:28 -0300 Subject: [PATCH 2/5] Render visual directives in block renderer --- packages/ui/components/BlockRenderer.tsx | 14 + .../components/BlockRenderer.visual.test.tsx | 77 ++++ .../blocks/VisualDirectiveBlock.tsx | 350 ++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 packages/ui/components/BlockRenderer.visual.test.tsx create mode 100644 packages/ui/components/blocks/VisualDirectiveBlock.tsx diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index 542ec1165..50133f709 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -8,6 +8,7 @@ import { Callout } from "./blocks/Callout"; import { AlertBlock } from "./blocks/AlertBlock"; import { TableBlock } from "./blocks/TableBlock"; import { MathBlock } from "./blocks/MathBlock"; +import { isVisualDirectiveKind, VisualDirectiveBlock } from "./blocks/VisualDirectiveBlock"; export const BlockRenderer: React.FC<{ block: Block; @@ -132,6 +133,19 @@ export const BlockRenderer: React.FC<{ case 'directive': { const kind = block.directiveKind || 'note'; + if (isVisualDirectiveKind(kind)) { + return ( + + ); + } return ( ); +} + +describe("BlockRenderer visual PFM directives", () => { + test("renders every visual block vocabulary item as a native visual block", () => { + const samples: Record = { + callout: "Decision: keep annotate gate.", + "file-map": "- [A] packages/shared/pfm-packet.ts - packet detection", + checklist: "- [x] Detection\n- [ ] Rendering", + "open-questions": "- Should visual review reuse this renderer?", + diagram: "graph TD\n A[Plan] --> B[Annotate]", + "annotated-diff": "+ Added detector\n- Removed broad fallback", + "code-walkthrough": "- packages/server/annotate.ts:322 wires metadata", + }; + + for (const [kind, body] of Object.entries(samples)) { + const html = renderDirective(kind, body); + expect(html).toContain(`data-visual-block="${kind}"`); + } + }); + + test("renders file maps with file rows and status badges", () => { + const html = renderDirective( + "file-map", + "- [A] packages/shared/pfm-packet.ts - Detect visual packets\n- [M] packages/server/annotate.ts - Return metadata", + ); + + expect(html).toContain("data-visual-file-map"); + expect(html).toContain("packages/shared/pfm-packet.ts"); + expect(html).toContain("Detect visual packets"); + expect(html).toContain("A"); + expect(html).toContain("M"); + }); + + test("keeps unknown directives on the existing generic callout path", () => { + const html = renderDirective("unknown-widget", "Body"); + + expect(html).not.toContain("data-visual-block"); + expect(html).toContain('data-block-type="directive"'); + expect(html).toContain('data-directive-kind="unknown-widget"'); + }); +}); + +describe("parseMarkdownToBlocks visual PFM directives", () => { + test("parses the visual block vocabulary as constrained directive blocks", () => { + for (const kind of ["callout", "file-map", "checklist", "open-questions", "diagram", "annotated-diff", "code-walkthrough"]) { + const blocks = parseMarkdownToBlocks(`:::${kind}\nbody\n:::`).filter((block) => block.type !== "paragraph"); + expect(blocks[0].type).toBe("directive"); + expect(blocks[0].directiveKind).toBe(kind); + expect(blocks[0].content).toBe("body"); + } + }); + + test("does not parse MDX component syntax as a visual block", () => { + const blocks = parseMarkdownToBlocks(""); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("paragraph"); + expect(blocks[0].content).toBe(""); + }); +}); diff --git a/packages/ui/components/blocks/VisualDirectiveBlock.tsx b/packages/ui/components/blocks/VisualDirectiveBlock.tsx new file mode 100644 index 000000000..48271dc64 --- /dev/null +++ b/packages/ui/components/blocks/VisualDirectiveBlock.tsx @@ -0,0 +1,350 @@ +import React from 'react'; +import type { Block } from '../../types'; +import { InlineMarkdown } from '../InlineMarkdown'; +import { renderProseBody } from './proseBody'; + +const VISUAL_DIRECTIVE_KINDS = new Set([ + 'callout', + 'file-map', + 'checklist', + 'open-questions', + 'diagram', + 'annotated-diff', + 'code-walkthrough', +]); + +const TITLES: Record = { + callout: 'Callout', + 'file-map': 'File Map', + checklist: 'Checklist', + 'open-questions': 'Open Questions', + diagram: 'Diagram', + 'annotated-diff': 'Annotated Diff', + 'code-walkthrough': 'Code Walkthrough', +}; + +interface VisualDirectiveBlockProps { + block: Block; + onOpenLinkedDoc?: (path: string) => void; + onOpenCodeFile?: (path: string) => void; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + githubRepo?: string; + onNavigateAnchor?: (hash: string) => void; +} + +interface FileMapItem { + status: string; + path: string; + description: string; +} + +export function isVisualDirectiveKind(kind: string | undefined): boolean { + return kind ? VISUAL_DIRECTIVE_KINDS.has(kind) : false; +} + +export const VisualDirectiveBlock: React.FC = ({ + block, + onOpenLinkedDoc, + onOpenCodeFile, + imageBaseDir, + onImageClick, + githubRepo, + onNavigateAnchor, +}) => { + const kind = block.directiveKind || 'callout'; + const inline = (text: string) => ( + + ); + + return ( +
+
+
+ {TITLES[kind] ?? kind} +
+
+
+ {renderVisualBody(kind, block, inline, { + imageBaseDir, + onImageClick, + onOpenLinkedDoc, + onOpenCodeFile, + githubRepo, + onNavigateAnchor, + })} +
+
+ ); +}; + +function renderVisualBody( + kind: string, + block: Block, + inline: (text: string) => React.ReactNode, + proseOptions: Omit[0], 'body'>, +): React.ReactNode { + switch (kind) { + case 'file-map': + return ; + case 'checklist': + return ; + case 'open-questions': + return ; + case 'diagram': + return ; + case 'annotated-diff': + return ; + case 'code-walkthrough': + return ; + case 'callout': + default: + return renderProseBody({ + body: block.content, + paragraphClassName: 'text-[15px] leading-relaxed text-foreground/90', + listClassName: 'text-[15px] leading-relaxed text-foreground/90', + ...proseOptions, + }); + } +} + +const FileMap: React.FC<{ body: string; inline: (text: string) => React.ReactNode }> = ({ body, inline }) => { + const items = parseFileMap(body); + if (items.length === 0) { + return
{inline(body)}
; + } + return ( +
+ {items.map((item, index) => ( +
+ + {item.status} + +
+
{inline(item.path)}
+ {item.description ? ( +
{inline(item.description)}
+ ) : null} +
+
+ ))} +
+ ); +}; + +const Checklist: React.FC<{ body: string; inline: (text: string) => React.ReactNode }> = ({ body, inline }) => { + const items = parseChecklist(body); + const done = items.filter((item) => item.checked).length; + return ( +
+
+
+
+
+ {items.map((item, index) => ( +
+ + ✓ + +
+ {inline(item.text)} +
+
+ ))} +
+
+ ); +}; + +const OpenQuestions: React.FC<{ body: string; inline: (text: string) => React.ReactNode }> = ({ body, inline }) => { + const questions = parseListItems(body); + return ( +
+ {questions.map((question, index) => ( +
+
Question {index + 1}
+
{inline(question)}
+
+ ))} +
+ ); +}; + +const Diagram: React.FC<{ block: Block }> = ({ block }) => { + const { language, source } = normalizeDiagramSource(block.content); + const diagramBlock: Block = { + ...block, + type: 'code', + language, + content: source, + }; + const rendered = ; + return ( +
+
+ {language} +
+ {rendered} +
+ ); +}; + +const LazyDiagramRenderer: React.FC<{ block: Block; language: 'mermaid' | 'dot' }> = ({ block, language }) => { + const [Renderer, setRenderer] = React.useState | null>(null); + + React.useEffect(() => { + let cancelled = false; + const load = async () => { + if (language === 'dot') { + const module = await import('../GraphvizBlock'); + if (!cancelled) setRenderer(() => module.GraphvizBlock); + return; + } + const module = await import('../MermaidBlock'); + if (cancelled) return; + setRenderer(() => module.MermaidBlock); + }; + void load(); + return () => { + cancelled = true; + }; + }, [language]); + + if (Renderer) { + return ( +
+ +
+ ); + } + + return ( +
+      {block.content}
+    
+ ); +}; + +const AnnotatedDiff: React.FC<{ body: string }> = ({ body }) => { + const lines = stripFence(body).split('\n').filter((line) => line.length > 0); + return ( +
+      
+        {lines.map((line, index) => (
+          
+            {line}
+          
+        ))}
+      
+    
+ ); +}; + +const CodeWalkthrough: React.FC<{ body: string; inline: (text: string) => React.ReactNode }> = ({ body, inline }) => { + const steps = parseListItems(body); + return ( +
+ {steps.map((step, index) => ( +
+ + {index + 1} + +
{inline(step)}
+
+ ))} +
+ ); +}; + +function parseFileMap(body: string): FileMapItem[] { + return body + .split('\n') + .map((line) => { + const trimmed = line.trim().replace(/^[-*]\s+/, ''); + if (!trimmed) return null; + const statusMatch = trimmed.match(/^\[([A-Za-z?+-])\]\s+(.*)$/); + const status = statusMatch?.[1]?.toUpperCase() ?? '•'; + const rest = statusMatch?.[2] ?? trimmed; + const [pathPart, ...descriptionParts] = rest.split(/\s+-\s+/); + const path = pathPart.trim(); + if (!path) return null; + return { + status, + path, + description: descriptionParts.join(' - ').trim(), + }; + }) + .filter((item): item is FileMapItem => item !== null); +} + +function parseChecklist(body: string): { checked: boolean; text: string }[] { + const items = body + .split('\n') + .map((line) => { + const match = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/); + if (!match) return null; + return { checked: match[1].toLowerCase() === 'x', text: match[2].trim() }; + }) + .filter((item): item is { checked: boolean; text: string } => item !== null); + return items.length > 0 ? items : parseListItems(body).map((text) => ({ checked: false, text })); +} + +function parseListItems(body: string): string[] { + const items = body + .split('\n') + .map((line) => line.trim().replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '')) + .filter(Boolean); + return items.length > 0 ? items : [body.trim()].filter(Boolean); +} + +function normalizeDiagramSource(body: string): { language: 'mermaid' | 'dot'; source: string } { + const fenced = body.trim().match(/^```([a-zA-Z0-9_-]+)?\n([\s\S]*?)\n```$/); + const language = fenced?.[1]?.toLowerCase(); + const source = fenced?.[2] ?? body.trim(); + return { language: language === 'dot' || language === 'graphviz' ? 'dot' : 'mermaid', source }; +} + +function stripFence(body: string): string { + const fenced = body.trim().match(/^```(?:diff|patch)?\n([\s\S]*?)\n```$/); + return fenced?.[1] ?? body.trim(); +} + +function statusClass(status: string): string { + switch (status) { + case 'A': + case '+': + return 'bg-emerald-500/15 text-emerald-400'; + case 'M': + return 'bg-sky-500/15 text-sky-400'; + case 'D': + case '-': + return 'bg-red-500/15 text-red-400'; + case 'R': + return 'bg-violet-500/15 text-violet-400'; + default: + return 'bg-muted text-muted-foreground'; + } +} + +function diffLineClass(line: string): string { + if (line.startsWith('+')) return 'bg-emerald-500/10 text-emerald-300'; + if (line.startsWith('-')) return 'bg-red-500/10 text-red-300'; + if (line.startsWith('@@')) return 'bg-sky-500/10 text-sky-300'; + return 'text-muted-foreground'; +} From e8160dcfbecf3a2ab280ec515b8a71a79a5c1e15 Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:08:25 -0300 Subject: [PATCH 3/5] Add toggleable checklists to visual directives --- packages/ui/components/BlockRenderer.tsx | 2 + .../components/BlockRenderer.visual.test.tsx | 47 ++++++++ .../blocks/VisualDirectiveBlock.tsx | 111 ++++++++++++++---- packages/ui/utils/parser.test.ts | 18 +++ packages/ui/utils/parser.ts | 15 +-- 5 files changed, 166 insertions(+), 27 deletions(-) diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index 50133f709..20b2ce018 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -141,6 +141,8 @@ export const BlockRenderer: React.FC<{ onOpenCodeFile={onOpenCodeFile} imageBaseDir={imageBaseDir} onImageClick={onImageClick} + onToggleCheckbox={onToggleCheckbox} + checkboxOverrides={checkboxOverrides} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} /> diff --git a/packages/ui/components/BlockRenderer.visual.test.tsx b/packages/ui/components/BlockRenderer.visual.test.tsx index 5da8f8d53..9b18d83e9 100644 --- a/packages/ui/components/BlockRenderer.visual.test.tsx +++ b/packages/ui/components/BlockRenderer.visual.test.tsx @@ -17,6 +17,25 @@ function renderDirective(kind: string, body: string): string { return renderToStaticMarkup(); } +function renderDirectiveWithOptions( + kind: string, + body: string, + options: { + onToggleCheckbox?: (blockId: string, checked: boolean) => void; + checkboxOverrides?: Map; + } = {}, +): string { + const block: Block = { + id: `block-${kind}`, + type: "directive", + directiveKind: kind, + content: body, + order: 1, + startLine: 1, + }; + return renderToStaticMarkup(); +} + describe("BlockRenderer visual PFM directives", () => { test("renders every visual block vocabulary item as a native visual block", () => { const samples: Record = { @@ -48,6 +67,34 @@ describe("BlockRenderer visual PFM directives", () => { expect(html).toContain("M"); }); + test("renders visual checklists as toggleable checkbox rows", () => { + const overrides = new Map([["block-checklist:checklist:1", true]]); + const html = renderDirectiveWithOptions( + "checklist", + "- [x] Detection\n- [ ] Rendering", + { onToggleCheckbox: () => {}, checkboxOverrides: overrides }, + ); + + expect(html).toContain('role="checkbox"'); + expect(html).toContain('aria-checked="true"'); + expect(html).toContain(" { + const html = renderDirective( + "annotated-diff", + "+ Added detector\n- Removed broad fallback\n@@ context", + ); + + expect(html).toContain("text-emerald-300"); + expect(html).toContain("[.light_&]:text-emerald-800"); + expect(html).toContain("text-red-300"); + expect(html).toContain("[.light_&]:text-red-800"); + expect(html).toContain("text-sky-300"); + expect(html).toContain("[.light_&]:text-sky-800"); + }); + test("keeps unknown directives on the existing generic callout path", () => { const html = renderDirective("unknown-widget", "Body"); diff --git a/packages/ui/components/blocks/VisualDirectiveBlock.tsx b/packages/ui/components/blocks/VisualDirectiveBlock.tsx index 48271dc64..29b290571 100644 --- a/packages/ui/components/blocks/VisualDirectiveBlock.tsx +++ b/packages/ui/components/blocks/VisualDirectiveBlock.tsx @@ -29,6 +29,8 @@ interface VisualDirectiveBlockProps { onOpenCodeFile?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void; + onToggleCheckbox?: (blockId: string, checked: boolean) => void; + checkboxOverrides?: Map; githubRepo?: string; onNavigateAnchor?: (hash: string) => void; } @@ -49,6 +51,8 @@ export const VisualDirectiveBlock: React.FC = ({ onOpenCodeFile, imageBaseDir, onImageClick, + onToggleCheckbox, + checkboxOverrides, githubRepo, onNavigateAnchor, }) => { @@ -83,6 +87,8 @@ export const VisualDirectiveBlock: React.FC = ({ onImageClick, onOpenLinkedDoc, onOpenCodeFile, + onToggleCheckbox, + checkboxOverrides, githubRepo, onNavigateAnchor, })} @@ -95,13 +101,24 @@ function renderVisualBody( kind: string, block: Block, inline: (text: string) => React.ReactNode, - proseOptions: Omit[0], 'body'>, + options: Omit[0], 'body'> & { + onToggleCheckbox?: (blockId: string, checked: boolean) => void; + checkboxOverrides?: Map; + }, ): React.ReactNode { switch (kind) { case 'file-map': return ; case 'checklist': - return ; + return ( + + ); case 'open-questions': return ; case 'diagram': @@ -116,7 +133,7 @@ function renderVisualBody( body: block.content, paragraphClassName: 'text-[15px] leading-relaxed text-foreground/90', listClassName: 'text-[15px] leading-relaxed text-foreground/90', - ...proseOptions, + ...options, }); } } @@ -148,30 +165,79 @@ const FileMap: React.FC<{ body: string; inline: (text: string) => React.ReactNod ); }; -const Checklist: React.FC<{ body: string; inline: (text: string) => React.ReactNode }> = ({ body, inline }) => { +const Checklist: React.FC<{ + blockId: string; + body: string; + inline: (text: string) => React.ReactNode; + onToggleCheckbox?: (blockId: string, checked: boolean) => void; + checkboxOverrides?: Map; +}> = ({ blockId, body, inline, onToggleCheckbox, checkboxOverrides }) => { const items = parseChecklist(body); - const done = items.filter((item) => item.checked).length; + const viewItems = items.map((item, index) => { + const itemId = `${blockId}:checklist:${index}`; + const checked = checkboxOverrides?.has(itemId) ? checkboxOverrides.get(itemId) === true : item.checked; + return { ...item, id: itemId, checked }; + }); + const done = viewItems.filter((item) => item.checked).length; return (
-
+
- {items.map((item, index) => ( -
- - ✓ - -
- {inline(item.text)} -
-
+ {viewItems.map((item) => ( + ))}
); }; +const ChecklistRow: React.FC<{ + item: { id: string; checked: boolean; text: string }; + inline: (text: string) => React.ReactNode; + onToggleCheckbox?: (blockId: string, checked: boolean) => void; +}> = ({ item, inline, onToggleCheckbox }) => { + const markerClass = item.checked + ? 'border-emerald-600 bg-emerald-600 text-white dark:border-emerald-500 dark:bg-emerald-500' + : 'border-border text-transparent'; + const textClass = item.checked ? 'text-muted-foreground line-through' : 'text-foreground/90'; + const rowClass = 'flex w-full items-start gap-2 rounded-md bg-background/35 px-3 py-2 text-left'; + const marker = ( + + ✓ + + ); + const label = {inline(item.text)}; + + if (onToggleCheckbox) { + return ( + + ); + } + + return ( +
+ {marker} + {label} +
+ ); +}; + const OpenQuestions: React.FC<{ body: string; inline: (text: string) => React.ReactNode }> = ({ body, inline }) => { const questions = parseListItems(body); return ( @@ -315,8 +381,13 @@ function parseListItems(body: string): string[] { function normalizeDiagramSource(body: string): { language: 'mermaid' | 'dot'; source: string } { const fenced = body.trim().match(/^```([a-zA-Z0-9_-]+)?\n([\s\S]*?)\n```$/); - const language = fenced?.[1]?.toLowerCase(); - const source = fenced?.[2] ?? body.trim(); + const rawLanguage = fenced?.[1]?.toLowerCase(); + const rawSource = fenced?.[2] ?? body.trim(); + const lines = rawSource.split('\n'); + const firstLine = lines[0]?.trim().toLowerCase(); + const hasLeadingLanguage = firstLine === 'mermaid' || firstLine === 'dot' || firstLine === 'graphviz'; + const language = rawLanguage ?? (hasLeadingLanguage ? firstLine : undefined); + const source = hasLeadingLanguage ? lines.slice(1).join('\n').trim() : rawSource; return { language: language === 'dot' || language === 'graphviz' ? 'dot' : 'mermaid', source }; } @@ -343,8 +414,8 @@ function statusClass(status: string): string { } function diffLineClass(line: string): string { - if (line.startsWith('+')) return 'bg-emerald-500/10 text-emerald-300'; - if (line.startsWith('-')) return 'bg-red-500/10 text-red-300'; - if (line.startsWith('@@')) return 'bg-sky-500/10 text-sky-300'; + if (line.startsWith('+')) return 'bg-emerald-500/10 text-emerald-300 [.light_&]:bg-emerald-500/12 [.light_&]:text-emerald-800'; + if (line.startsWith('-')) return 'bg-red-500/10 text-red-300 [.light_&]:bg-red-500/12 [.light_&]:text-red-800'; + if (line.startsWith('@@')) return 'bg-sky-500/10 text-sky-300 [.light_&]:bg-sky-500/12 [.light_&]:text-sky-800'; return 'text-muted-foreground'; } diff --git a/packages/ui/utils/parser.test.ts b/packages/ui/utils/parser.test.ts index 7f559185c..7ce75bb10 100644 --- a/packages/ui/utils/parser.test.ts +++ b/packages/ui/utils/parser.test.ts @@ -756,6 +756,24 @@ describe("parseMarkdownToBlocks — directive containers", () => { expect(blocks[0].content).toBe("Body line."); }); + test("captures body between ::kind and ::", () => { + const md = "::file-map\n- [A] packages/shared/pfm-packet.ts - packet detection\n::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("directive"); + expect(blocks[0].directiveKind).toBe("file-map"); + expect(blocks[0].content).toBe("- [A] packages/shared/pfm-packet.ts - packet detection"); + }); + + test("keeps directive opening metadata with the directive body", () => { + const md = "::diagram mermaid\nflowchart LR\n A --> B\n::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("directive"); + expect(blocks[0].directiveKind).toBe("diagram"); + expect(blocks[0].content).toBe("mermaid\nflowchart LR\n A --> B"); + }); + test("supports arbitrary kinds (info, success, danger)", () => { for (const kind of ["info", "success", "danger", "warning"]) { const blocks = parseMarkdownToBlocks(`:::${kind}\nbody\n:::`); diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index f61a80ab0..a45f46cff 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -451,18 +451,19 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => { // CommonMark §4.6 Type 6 blank-line termination). For a line that starts // with a close tag, we fall back to blank-line termination. Content is // sanitized at render time, not here. - // Directive container: `:::kind` opens, `:::` closes. Inline kind is - // restricted to simple identifiers (letters, digits, hyphens). Body is - // accumulated verbatim and rendered with inline markdown. - const directiveOpen = trimmed.match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\s*$/); + // Directive container: `:::kind` / `::kind` opens and a matching bare + // fence closes. Inline kind is restricted to simple identifiers. + const directiveOpen = trimmed.match(/^(:::{1}|::)\s*([a-zA-Z][a-zA-Z0-9-]*)(?:\s+(.+?))?\s*$/); if (directiveOpen) { flush(); const directiveStartLine = currentLineNum; - const kind = directiveOpen[1].toLowerCase(); - const bodyLines: string[] = []; + const closingFence = directiveOpen[1]; + const kind = directiveOpen[2].toLowerCase(); + const directiveMeta = directiveOpen[3]?.trim(); + const bodyLines: string[] = directiveMeta ? [directiveMeta] : []; while (i + 1 < lines.length) { i++; - if (lines[i].trim() === ':::') break; + if (lines[i].trim() === closingFence) break; bodyLines.push(lines[i]); } blocks.push({ From bd99913f7ca915ed67c31a693d538df88680b88b Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:20:36 -0300 Subject: [PATCH 4/5] Handle visual checklist checkbox overrides --- packages/editor/App.tsx | 2 +- .../editor/hooks/useCheckboxOverrides.test.ts | 58 +++++++++ packages/editor/hooks/useCheckboxOverrides.ts | 123 ++++++++++++++++-- packages/ui/types.ts | 1 + 4 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 packages/editor/hooks/useCheckboxOverrides.test.ts diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index c8851c180..74ddecae0 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -2936,7 +2936,7 @@ const App: React.FC = () => { // If this is a checkbox annotation, revert the visual override if (id.startsWith('ann-checkbox-')) { if (ann) { - checkbox.revertOverride(ann.blockId); + checkbox.revertOverride(ann.checkboxOverrideId ?? ann.blockId); } } removeAnnotation(id); diff --git a/packages/editor/hooks/useCheckboxOverrides.test.ts b/packages/editor/hooks/useCheckboxOverrides.test.ts new file mode 100644 index 000000000..a080d3f66 --- /dev/null +++ b/packages/editor/hooks/useCheckboxOverrides.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import type { Annotation, Block } from "@plannotator/ui/types"; +import { + collectCheckboxOverrideIds, + isCheckboxAnnotationForOverride, + resolveCheckboxToggleTarget, +} from "./useCheckboxOverrides"; + +const blocks: Block[] = [ + { + id: "heading-1", + type: "heading", + content: "Execution Checklist", + level: 2, + order: 1, + startLine: 10, + }, + { + id: "block-checklist", + type: "directive", + directiveKind: "checklist", + content: "- [x] Existing item\n- [ ] Visual checklist item", + order: 2, + startLine: 12, + }, +]; + +describe("useCheckboxOverrides visual checklist helpers", () => { + test("collects synthetic override ids for visual checklist items", () => { + const ids = collectCheckboxOverrideIds(blocks); + + expect(ids.has("block-checklist:checklist:0")).toBe(true); + expect(ids.has("block-checklist:checklist:1")).toBe(true); + }); + + test("resolves a visual checklist item to the parent block and item text", () => { + const target = resolveCheckboxToggleTarget(blocks, "block-checklist:checklist:1"); + + expect(target).not.toBeNull(); + expect(target?.overrideId).toBe("block-checklist:checklist:1"); + expect(target?.annotationBlockId).toBe("block-checklist"); + expect(target?.originalChecked).toBe(false); + expect(target?.content).toBe("Visual checklist item"); + expect(target?.startLine).toBe(13); + }); + + test("matches checkbox annotations by stored override id", () => { + const annotation = { + id: "ann-checkbox-block-checklist:checklist:1-123", + blockId: "block-checklist", + checkboxOverrideId: "block-checklist:checklist:1", + } as Annotation; + + expect(isCheckboxAnnotationForOverride(annotation, "block-checklist:checklist:1")).toBe(true); + expect(isCheckboxAnnotationForOverride(annotation, "block-checklist:checklist:0")).toBe(false); + }); +}); + diff --git a/packages/editor/hooks/useCheckboxOverrides.ts b/packages/editor/hooks/useCheckboxOverrides.ts index 288b658ad..3f54fdd42 100644 --- a/packages/editor/hooks/useCheckboxOverrides.ts +++ b/packages/editor/hooks/useCheckboxOverrides.ts @@ -9,6 +9,16 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Annotation, AnnotationType, Block } from '@plannotator/ui/types'; +interface CheckboxToggleTarget { + overrideId: string; + annotationBlockId: string; + originalChecked: boolean; + content: string; + startLine: number; + endOffset: number; + blockIndex: number; +} + export interface UseCheckboxOverridesOptions { blocks: Block[]; annotations: Annotation[]; @@ -42,8 +52,8 @@ export function useCheckboxOverrides({ // Clean up stale overrides when blocks change (e.g. markdown reloaded) useEffect(() => { if (overrides.size === 0) return; - const blockIds = new Set(blocks.map(b => b.id)); - const stale = [...overrides.keys()].filter(id => !blockIds.has(id)); + const overrideIds = collectCheckboxOverrideIds(blocks); + const stale = [...overrides.keys()].filter(id => !overrideIds.has(id)); if (stale.length > 0) { setOverrides(prev => { const next = new Map(prev); @@ -56,8 +66,8 @@ export function useCheckboxOverrides({ const toggle = useCallback((blockId: string, checked: boolean) => { const blocks = blocksRef.current; const annotations = annotationsRef.current; - const block = blocks.find(b => b.id === blockId); - const isRevertingToOriginal = block && checked === block.checked; + const target = resolveCheckboxToggleTarget(blocks, blockId); + const isRevertingToOriginal = target && checked === target.originalChecked; if (isRevertingToOriginal) { // Undo: remove the override and delete ALL checkbox annotations for this block @@ -66,11 +76,11 @@ export function useCheckboxOverrides({ next.delete(blockId); return next; }); - const toDelete = annotations.filter(a => a.blockId === blockId && a.id.startsWith('ann-checkbox-')); + const toDelete = annotations.filter(a => isCheckboxAnnotationForOverride(a, blockId)); toDelete.forEach(a => removeAnnotation(a.id)); } else { // Toggle: remove any existing checkbox annotations for this block first (prevents duplicates from rapid clicks) - const existing = annotations.filter(a => a.blockId === blockId && a.id.startsWith('ann-checkbox-')); + const existing = annotations.filter(a => isCheckboxAnnotationForOverride(a, blockId)); existing.forEach(a => removeAnnotation(a.id)); setOverrides(prev => { @@ -78,11 +88,10 @@ export function useCheckboxOverrides({ next.set(blockId, checked); return next; }); - if (block) { + if (target) { // Find the nearest heading above this block for section context - const blockIdx = blocks.indexOf(block); let sectionHeading = ''; - for (let i = blockIdx - 1; i >= 0; i--) { + for (let i = target.blockIndex - 1; i >= 0; i--) { if (blocks[i].type === 'heading') { sectionHeading = blocks[i].content; break; @@ -90,16 +99,17 @@ export function useCheckboxOverrides({ } const action = checked ? 'Mark as completed' : 'Mark as not completed'; - const context = sectionHeading ? ` (under "${sectionHeading}")` : ` (line ${block.startLine})`; + const context = sectionHeading ? ` (under "${sectionHeading}")` : ` (line ${target.startLine})`; const ann: Annotation = { id: `ann-checkbox-${blockId}-${Date.now()}`, - blockId, + blockId: target.annotationBlockId, startOffset: 0, - endOffset: block.content.length, + endOffset: target.endOffset, type: AnnotationType.COMMENT, - text: `${action}${context}: ${block.content}`, - originalText: block.content, + text: `${action}${context}: ${target.content}`, + originalText: target.content, createdA: Date.now(), + checkboxOverrideId: target.overrideId, }; addAnnotation(ann); } @@ -116,3 +126,88 @@ export function useCheckboxOverrides({ return { overrides, toggle, revertOverride }; } + +export function collectCheckboxOverrideIds(blocks: Block[]): Set { + const ids = new Set(); + for (const block of blocks) { + if (block.checked !== undefined) ids.add(block.id); + if (block.type === 'directive' && block.directiveKind === 'checklist') { + parseVisualChecklistItems(block).forEach((_, index) => ids.add(visualChecklistOverrideId(block.id, index))); + } + } + return ids; +} + +export function resolveCheckboxToggleTarget(blocks: Block[], overrideId: string): CheckboxToggleTarget | null { + const directIndex = blocks.findIndex((block) => block.id === overrideId); + if (directIndex >= 0) { + const block = blocks[directIndex]; + if (block.checked === undefined) return null; + return { + overrideId, + annotationBlockId: block.id, + originalChecked: block.checked, + content: block.content, + startLine: block.startLine, + endOffset: block.content.length, + blockIndex: directIndex, + }; + } + + const visual = parseVisualChecklistOverrideId(overrideId); + if (!visual) return null; + const parentIndex = blocks.findIndex((block) => block.id === visual.blockId); + if (parentIndex < 0) return null; + const parent = blocks[parentIndex]; + if (parent.type !== 'directive' || parent.directiveKind !== 'checklist') return null; + const item = parseVisualChecklistItems(parent)[visual.index]; + if (!item) return null; + return { + overrideId, + annotationBlockId: parent.id, + originalChecked: item.checked, + content: item.text, + startLine: parent.startLine + item.lineOffset, + endOffset: item.text.length, + blockIndex: parentIndex, + }; +} + +export function isCheckboxAnnotationForOverride(annotation: Annotation, overrideId: string): boolean { + if (!annotation.id.startsWith('ann-checkbox-')) return false; + return annotation.checkboxOverrideId === overrideId || annotation.blockId === overrideId; +} + +function visualChecklistOverrideId(blockId: string, index: number): string { + return `${blockId}:checklist:${index}`; +} + +function parseVisualChecklistOverrideId(overrideId: string): { blockId: string; index: number } | null { + const marker = ':checklist:'; + const markerIndex = overrideId.lastIndexOf(marker); + if (markerIndex < 0) return null; + const blockId = overrideId.slice(0, markerIndex); + const index = Number.parseInt(overrideId.slice(markerIndex + marker.length), 10); + if (!blockId || !Number.isFinite(index) || index < 0) return null; + return { blockId, index }; +} + +function parseVisualChecklistItems(block: Block): { checked: boolean; text: string; lineOffset: number }[] { + const items: { checked: boolean; text: string; lineOffset: number }[] = []; + block.content.split('\n').forEach((line, lineOffset) => { + const match = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/); + if (match) { + items.push({ + checked: match[1].toLowerCase() === 'x', + text: match[2].trim(), + lineOffset, + }); + return; + } + const fallback = line.trim().replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, ''); + if (fallback) { + items.push({ checked: false, text: fallback, lineOffset }); + } + }); + return items; +} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 48ca291ad..cf283844e 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -39,6 +39,7 @@ export interface Annotation { images?: ImageAttachment[]; // Attached images with human-readable names isQuickLabel?: boolean; // true if created via quick label chip quickLabelTip?: string; // optional instruction tip from the label definition + checkboxOverrideId?: string; // for checkbox annotations whose visual override id differs from blockId diffContext?: 'added' | 'removed' | 'modified'; // set when annotation created in plan diff view mathTargets?: Array<{ blockId: string; From 0120e77f557fd263bd9f1e7860e669913ee4bec2 Mon Sep 17 00:00:00 2001 From: "Leonardo R. Dias" <47978193+leoreisdias@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:32:49 -0300 Subject: [PATCH 5/5] Add annotate outcome formatter and visual plan skill docs --- apps/hook/server/annotate-outcome.test.ts | 34 +++ apps/hook/server/annotate-outcome.ts | 44 +++ apps/hook/server/index.ts | 37 +-- apps/kiro-cli/README.md | 6 +- apps/kiro-cli/agents/plannotator.json | 2 +- .../docs/getting-started/installation.md | 6 +- .../src/content/docs/guides/claude-code.md | 2 +- .../src/content/docs/guides/kiro-cli.md | 2 + apps/pi-extension/server.test.ts | 40 +++ apps/pi-extension/server/serverAnnotate.ts | 3 + apps/pi-extension/vendor.sh | 2 +- .../extra/plannotator-visual-plan/SKILL.md | 48 ++++ .../plannotator-visual-plan/SKILL.test.ts | 66 +++++ .../examples/visual-plan-packet.md | 62 ++++ .../references/visual-blocks.md | 90 ++++++ packages/server/annotate.test.ts | 271 ++++++++++++++++++ packages/server/annotate.ts | 4 +- scripts/install.cmd | 21 +- scripts/install.ps1 | 17 +- scripts/install.sh | 13 +- scripts/install.test.ts | 26 +- 21 files changed, 726 insertions(+), 70 deletions(-) create mode 100644 apps/hook/server/annotate-outcome.test.ts create mode 100644 apps/hook/server/annotate-outcome.ts create mode 100644 apps/skills/extra/plannotator-visual-plan/SKILL.md create mode 100644 apps/skills/extra/plannotator-visual-plan/SKILL.test.ts create mode 100644 apps/skills/extra/plannotator-visual-plan/examples/visual-plan-packet.md create mode 100644 apps/skills/extra/plannotator-visual-plan/references/visual-blocks.md diff --git a/apps/hook/server/annotate-outcome.test.ts b/apps/hook/server/annotate-outcome.test.ts new file mode 100644 index 000000000..2408f7b1f --- /dev/null +++ b/apps/hook/server/annotate-outcome.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { APPROVED_PLAINTEXT_MARKER, formatAnnotateOutcome } from "./annotate-outcome"; + +describe("formatAnnotateOutcome", () => { + test("preserves plaintext gate output", () => { + expect(formatAnnotateOutcome({ feedback: "", approved: true }, "plaintext")).toBe(APPROVED_PLAINTEXT_MARKER); + expect(formatAnnotateOutcome({ feedback: "", exit: true }, "plaintext")).toBeNull(); + expect(formatAnnotateOutcome({ feedback: "Revise this." }, "plaintext")).toBe("Revise this."); + }); + + test("preserves structured JSON gate output", () => { + expect(formatAnnotateOutcome({ feedback: "", approved: true }, "json")).toBe(JSON.stringify({ decision: "approved" })); + expect(formatAnnotateOutcome({ feedback: "", exit: true }, "json")).toBe(JSON.stringify({ decision: "dismissed" })); + expect(formatAnnotateOutcome({ + feedback: "Revise this.", + selectedMessageId: "message-1", + feedbackScope: "message", + }, "json")).toBe(JSON.stringify({ + decision: "annotated", + feedback: "Revise this.", + selectedMessageId: "message-1", + feedbackScope: "message", + })); + }); + + test("preserves hook-native blocking output", () => { + expect(formatAnnotateOutcome({ feedback: "", approved: true }, "hook")).toBeNull(); + expect(formatAnnotateOutcome({ feedback: "", exit: true }, "hook")).toBeNull(); + expect(formatAnnotateOutcome({ feedback: "Revise this." }, "hook")).toBe(JSON.stringify({ + decision: "block", + reason: "Revise this.", + })); + }); +}); diff --git a/apps/hook/server/annotate-outcome.ts b/apps/hook/server/annotate-outcome.ts new file mode 100644 index 000000000..203be4982 --- /dev/null +++ b/apps/hook/server/annotate-outcome.ts @@ -0,0 +1,44 @@ +// Slash-command templates match this approval marker literally. +export const APPROVED_PLAINTEXT_MARKER = "The user approved."; + +export type AnnotateOutcomeFormat = "hook" | "json" | "plaintext"; + +export type AnnotateOutcome = { + feedback: string; + exit?: boolean; + approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; +}; + +export function formatAnnotateOutcome( + result: AnnotateOutcome, + format: AnnotateOutcomeFormat, +): string | null { + if (format === "hook") { + if (result.approved || result.exit) return null; + if (result.feedback) { + return JSON.stringify({ decision: "block", reason: result.feedback }); + } + return null; + } + + if (format === "json") { + if (result.approved) { + return JSON.stringify({ decision: "approved" }); + } + if (result.exit) { + return JSON.stringify({ decision: "dismissed" }); + } + return JSON.stringify({ + decision: "annotated", + feedback: result.feedback || "", + ...(result.selectedMessageId && { selectedMessageId: result.selectedMessageId }), + ...(result.feedbackScope && { feedbackScope: result.feedbackScope }), + }); + } + + if (result.exit) return null; + if (result.approved) return APPROVED_PLAINTEXT_MARKER; + return result.feedback || null; +} diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 98f0c47a0..40e634ed7 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -139,6 +139,7 @@ import { isTopLevelHelpInvocation, isVersionInvocation, } from "./cli"; +import { formatAnnotateOutcome } from "./annotate-outcome"; import path from "path"; import { tmpdir } from "os"; import { buildLocalWorkspaceReview, type WorkspaceDiffType } from "@plannotator/server/review-workspace"; @@ -200,41 +201,21 @@ if (renderMarkdownFlag) args.splice(renderMarkdownIdx, 1); // Plaintext (default): // Close → empty. Approve → "The user approved." Annotate → feedback. // -// TODO: The plaintext --gate approval sentinel must stay as the exact string -// "The user approved." because slash command templates (plannotator-annotate.md, -// plannotator-last.md) instruct the agent to match it literally. Making this -// configurable requires updating those templates to accept dynamic values or -// switching gate mode to structured output only. -const APPROVED_PLAINTEXT_MARKER = "The user approved."; - function emitAnnotateOutcome(result: { feedback: string; exit?: boolean; approved?: boolean; + selectedMessageId?: string; + feedbackScope?: "message" | "messages"; }): void { + let format: "hook" | "json" | "plaintext" = "plaintext"; if (hookFlag) { - if (result.approved || result.exit) return; - if (result.feedback) { - console.log(JSON.stringify({ decision: "block", reason: result.feedback })); - } - return; - } - if (jsonFlag) { - if (result.approved) { - console.log(JSON.stringify({ decision: "approved" })); - } else if (result.exit) { - console.log(JSON.stringify({ decision: "dismissed" })); - } else { - console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); - } - return; - } - if (result.exit) return; - if (result.approved) { - console.log(APPROVED_PLAINTEXT_MARKER); - return; + format = "hook"; + } else if (jsonFlag) { + format = "json"; } - if (result.feedback) console.log(result.feedback); + const output = formatAnnotateOutcome(result, format); + if (output) console.log(output); } async function loadGoalSetupBundle( diff --git a/apps/kiro-cli/README.md b/apps/kiro-cli/README.md index eace991f5..c4d3b8de6 100644 --- a/apps/kiro-cli/README.md +++ b/apps/kiro-cli/README.md @@ -21,7 +21,7 @@ one-liner as everyone else. convention used for Codex and Gemini) and installs: - the 2 Kiro-specific skills above → `~/.kiro/skills` -- the 2 shared skills `plannotator-setup-goal` and `plannotator-visual-explainer` (pulled from +- the shared skills `plannotator-setup-goal`, `plannotator-visual-plan`, and `plannotator-visual-explainer` (pulled from `apps/skills/extra/`, not duplicated here) → `~/.kiro/skills` - the example agent `agents/plannotator.json` → `~/.kiro/agents/plannotator.json` (an existing file is never overwritten) @@ -32,8 +32,8 @@ curl -fsSL https://plannotator.ai/install.sh | bash ## Use the Plannotator agent -The installed agent wires all four skills via `skill://` resources and, in its prompt, documents -which skill to use for which task (review, annotate, setup-goal, visual-explainer). Launch +The installed agent wires the Plannotator skills via `skill://` resources and, in its prompt, documents +which skill to use for which task (review, annotate, setup-goal, visual-plan, visual-explainer). Launch it: ```bash diff --git a/apps/kiro-cli/agents/plannotator.json b/apps/kiro-cli/agents/plannotator.json index 58aae47b8..d1927babe 100644 --- a/apps/kiro-cli/agents/plannotator.json +++ b/apps/kiro-cli/agents/plannotator.json @@ -1,7 +1,7 @@ { "name": "plannotator", "description": "Kiro custom agent wiring for Plannotator review and annotation workflows.", - "prompt": "You run Plannotator, which opens a browser UI for human review and annotation. Choose the skill that matches the task:\n- plannotator-review: review the current code changes (git/jj diff) or a pull request before continuing; optionally pass a PR URL.\n- plannotator-annotate: annotate a markdown or HTML file, a folder of docs, or a URL, then act on the returned annotations.\n- plannotator-setup-goal: turn an idea into a structured goal package by interviewing the user, building a fact sheet, then a plan.\n- plannotator-visual-explainer: generate a polished, self-contained HTML visual (implementation plan, PR walkthrough, or diagram) and open it for review.\nEach skill runs a `plannotator` shell command. plannotator-review and plannotator-annotate set PLANNOTATOR_ORIGIN=kiro-cli.", + "prompt": "You run Plannotator, which opens a browser UI for human review and annotation. Choose the skill that matches the task:\n- plannotator-review: review the current code changes (git/jj diff) or a pull request before continuing; optionally pass a PR URL.\n- plannotator-annotate: annotate a markdown or HTML file, a folder of docs, or a URL, then act on the returned annotations.\n- plannotator-setup-goal: turn an idea into a structured goal package by interviewing the user, building a fact sheet, then a plan.\n- plannotator-visual-plan: author a PFM visual plan packet and open it with annotate gate for approval.\n- plannotator-visual-explainer: generate a polished, self-contained HTML visual (implementation plan, PR walkthrough, or diagram) and open it for review.\nEach skill runs a `plannotator` shell command. plannotator-review and plannotator-annotate set PLANNOTATOR_ORIGIN=kiro-cli.", "tools": [ "shell" ], diff --git a/apps/marketing/src/content/docs/getting-started/installation.md b/apps/marketing/src/content/docs/getting-started/installation.md index beae65652..e41d4ee29 100644 --- a/apps/marketing/src/content/docs/getting-started/installation.md +++ b/apps/marketing/src/content/docs/getting-started/installation.md @@ -30,7 +30,7 @@ irm https://plannotator.ai/install.ps1 | iex When run in a terminal for the first time, the installer asks two questions: -1. **Install the extra skills?** (compound planning, setup-goal, visual explainer) — answering yes launches `npx skills add` so you pick which agents get them in its UI. Skipped automatically if the extras are already installed. +1. **Install the extra skills?** (compound planning, setup-goal, visual plan, visual explainer) — answering yes launches `npx skills add` so you pick which agents get them in its UI. Skipped automatically if the extras are already installed. 2. **Make any skills callable by the model?** — answering yes opens a picker (space toggles on macOS/Linux/PowerShell; numbered toggles in the cmd installer). Chosen skills have `disable-model-invocation` removed from their *installed* copies (and the Codex sidecar flipped to match); everything else stays user-invoked only. Answers are saved to `/install-prefs` and reused silently on re-runs — pass `--reconfigure` to change them. **Automated installs are unaffected**: runs without a terminal (CI, scripts) never prompt and keep the defaults (no extras, nothing model-invocable). Automation can opt in explicitly with `--extras` / `--no-extras` / `--model-invocable ` / `--non-interactive`. @@ -114,7 +114,7 @@ Plannotator's slash commands (`/plannotator-review`, `/plannotator-annotate`, `/ Upgrading from an older version? The installer removes the legacy `~/.claude/commands/plannotator-*.md` files automatically, but the marketplace plugin's old namespaced `plannotator:*` command entries are managed by Claude Code — run `/plugin marketplace update` once so they disappear from the `/` menu. -Optional extra skills (compound planning, setup-goal, visual explainer) are not installed by default. Add them with: +Optional extra skills (compound planning, setup-goal, visual plan, visual explainer) are not installed by default. Add them with: ```bash npx skills add backnotprop/plannotator/apps/skills/extra @@ -219,7 +219,7 @@ Notes: - Codex hooks are currently experimental. - The current official Codex hooks docs say hooks are disabled on Windows, so this flow is currently macOS/Linux/WSL only. -The installer also copies Plannotator's core skills (`plannotator-review`, `plannotator-annotate`, `plannotator-last`) into `~/.agents/skills` — the official OpenAI agent skills path. Optional extra skills (compound planning, setup-goal, visual explainer) are not installed by default; add them with: +The installer also copies Plannotator's core skills (`plannotator-review`, `plannotator-annotate`, `plannotator-last`) into `~/.agents/skills` — the official OpenAI agent skills path. Optional extra skills (compound planning, setup-goal, visual plan, visual explainer) are not installed by default; add them with: ```bash npx skills add backnotprop/plannotator/apps/skills/extra diff --git a/apps/marketing/src/content/docs/guides/claude-code.md b/apps/marketing/src/content/docs/guides/claude-code.md index 0f7394dfb..ac1539b2f 100644 --- a/apps/marketing/src/content/docs/guides/claude-code.md +++ b/apps/marketing/src/content/docs/guides/claude-code.md @@ -89,7 +89,7 @@ Opens any markdown file in the annotation UI. See the [annotate docs](/docs/comm Annotates the agent's most recent message. See the [annotate last docs](/docs/commands/annotate-last/) for details. -Optional extra skills (compound planning, setup-goal, visual explainer) are not installed by default. Add them with: +Optional extra skills (compound planning, setup-goal, visual plan, visual explainer) are not installed by default. Add them with: ```bash npx skills add backnotprop/plannotator/apps/skills/extra diff --git a/apps/marketing/src/content/docs/guides/kiro-cli.md b/apps/marketing/src/content/docs/guides/kiro-cli.md index 638e58159..316f0e439 100644 --- a/apps/marketing/src/content/docs/guides/kiro-cli.md +++ b/apps/marketing/src/content/docs/guides/kiro-cli.md @@ -48,6 +48,7 @@ Kiro-specific skills (run with `PLANNOTATOR_ORIGIN=kiro-cli`): Shared extra skills (installed from Plannotator's canonical `apps/skills/extra/` set, not duplicated): - `plannotator-setup-goal` +- `plannotator-visual-plan` - `plannotator-visual-explainer` The shared skills show the default agent badge rather than "Kiro CLI" — origin is cosmetic for @@ -64,6 +65,7 @@ commands, and its prompt spells out which skill to use for which task: | `plannotator-review` | Review the current code changes or a pull request | | `plannotator-annotate` | Annotate a markdown/HTML file, folder, or URL | | `plannotator-setup-goal` | Turn an idea into a structured goal package | +| `plannotator-visual-plan` | Author a PFM visual plan packet and open it with annotate gate | | `plannotator-visual-explainer` | Generate a polished visual HTML explainer | Launch it: diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index f5afcaa0e..119b2d839 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -14,6 +14,7 @@ import { runVcsDiff, stageFile, startPlanReviewServer, + startAnnotateServer, startReviewServer, unstageFile, } from "./server"; @@ -1007,6 +1008,45 @@ describe("pi review server", () => { }, 20_000); }); +describe("pi annotate server", () => { + test("marks visual packet annotate gates without changing annotate mode", async () => { + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Plan\n", + filePath: join(tmpdir(), "pi-visual-plan.md"), + htmlContent: "annotate", + gate: true, + }); + + try { + const response = await fetch(`${server.url}/api/plan`); + const plan = await response.json() as { + mode?: string; + gate?: boolean; + pfmPacket?: { kind?: string; visual?: boolean; detectedBy?: string }; + }; + + expect(plan.mode).toBe("annotate"); + expect(plan.gate).toBe(true); + expect(plan.pfmPacket).toEqual({ + kind: "visual-plan", + visual: true, + detectedBy: "frontmatter", + }); + + await fetch(`${server.url}/api/approve`, { method: "POST" }); + await expect(server.waitForDecision()).resolves.toEqual({ + feedback: "", + annotations: [], + approved: true, + }); + } finally { + server.stop(); + } + }); +}); + describe("pi plan server file browser", () => { test("filters excluded folders from tree and workspace status", async () => { const repo = makeTempDir("plannotator-pi-files-"); diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index ee1f02efd..a8f936ee9 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -56,6 +56,7 @@ import { supportsAnnotateAgentTerminalMode, type AgentTerminalCapability, } from "../generated/agent-terminal.js"; +import { detectPfmPacket } from "../generated/pfm-packet.js"; export interface AnnotateServerResult { port: number; @@ -342,6 +343,7 @@ export async function startAnnotateServer(options: { ? htmlAssets.rewriteHtml(options.rawHtml, options.filePath) : undefined; const primarySource = getPrimarySource(); + const pfmPacket = displayRawHtml ? null : detectPfmPacket(primarySource.plan); json(res, { plan: primarySource.plan, origin: options.origin ?? "pi", @@ -361,6 +363,7 @@ export async function startAnnotateServer(options: { projectRoot: options.folderPath || process.cwd(), serverConfig: getServerConfig(gitUser), agentTerminal: agentTerminalCapability, + ...(pfmPacket ? { pfmPacket } : {}), ...(options.recentMessages ? { recentMessages: options.recentMessages } : {}), }); } else if (url.pathname === "/api/share-html" && req.method === "GET") { diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 31848cf7a..fa3254296 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -7,7 +7,7 @@ cd "$(dirname "$0")" rm -rf generated mkdir -p generated generated/ai/providers -for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-context-live pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file annotate-reference-roots-node config external-annotation agent-jobs agent-terminal worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour guide annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status open-in-apps review-profiles commit-avatars commit-history; do +for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-context-live pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file annotate-reference-roots-node config external-annotation agent-jobs agent-terminal worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour guide annotate-args at-reference review-workspace-node review-workspace pfm-reminder pfm-packet improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status open-in-apps review-profiles commit-avatars commit-history; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done diff --git a/apps/skills/extra/plannotator-visual-plan/SKILL.md b/apps/skills/extra/plannotator-visual-plan/SKILL.md new file mode 100644 index 000000000..a4a29134d --- /dev/null +++ b/apps/skills/extra/plannotator-visual-plan/SKILL.md @@ -0,0 +1,48 @@ +--- +name: plannotator-visual-plan +disable-model-invocation: true +description: Author a Plannotator Flavored Markdown visual plan packet and open it in annotate gate. +--- + +# Plannotator Visual Plan + +Create a visual plan packet in Plannotator Flavored Markdown (PFM), then open it through annotate gate for approval or requested changes. + +## Build The Packet + +1. Inspect the codebase or source material enough to name real files, commands, risks, and decisions. +2. Write a readable markdown packet with `plannotator-visual-plan` frontmatter and Plannotator visual directives. +3. Keep the packet standalone: a reviewer who did not read the chat should understand the scope, planned changes, risks, and verification. +4. Include the visual blocks that help the plan scan faster. Read `references/visual-blocks.md` before authoring the first packet in a run. +5. Save the packet as `plan.md` inside a task-specific folder when there are supporting fragments, or as a single `.md` file for small plans. + +Use this frontmatter: + +```markdown +--- +plannotator: visual-plan +title: Human-readable plan title +--- +``` + +## Open The Gate + +Run Plannotator yourself and wait for the browser session to finish: + +```bash +plannotator annotate --gate +``` + +If approved, continue with the approved plan. If feedback or annotations return, revise the source packet and rerun the same command. If the session is closed without feedback, report that the gate was closed and do not treat it as approval. + +## Constraints + +- Use PFM source, not MDX. +- Do not claim Agent-Native compatibility. +- Do not use React imports, MDX components, or runtime component execution. +- Use only Plannotator's documented visual directives for custom blocks. +- Keep arbitrary HTML out of the packet unless a supported directive explicitly calls for visual markup. + +## Example + +See `examples/visual-plan-packet.md` for a compact packet that exercises the visual plan path. diff --git a/apps/skills/extra/plannotator-visual-plan/SKILL.test.ts b/apps/skills/extra/plannotator-visual-plan/SKILL.test.ts new file mode 100644 index 000000000..2aa58d8e3 --- /dev/null +++ b/apps/skills/extra/plannotator-visual-plan/SKILL.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { detectPfmPacket } from "../../../../packages/shared/pfm-packet"; +import { parseMarkdownToBlocks } from "../../../../packages/ui/utils/parser"; + +const skillDir = import.meta.dir; +const skill = readFileSync(join(skillDir, "SKILL.md"), "utf-8"); +const visualBlocks = readFileSync(join(skillDir, "references", "visual-blocks.md"), "utf-8"); +const examplePacket = readFileSync(join(skillDir, "examples", "visual-plan-packet.md"), "utf-8"); + +describe("plannotator-visual-plan skill", () => { + test("routes visual plans through annotate gate", () => { + expect(skill).toContain("plannotator annotate --gate "); + expect(skill).toContain("Run Plannotator yourself and wait for the browser session to finish"); + expect(skill).toContain("If approved, continue with the approved plan"); + expect(skill).toContain("If feedback or annotations return, revise the source packet"); + }); + + test("documents constrained PFM instead of MDX or runtime components", () => { + expect(skill).toContain("Use PFM source, not MDX"); + expect(skill).toContain("Do not claim Agent-Native compatibility"); + expect(skill).toContain("Do not use React imports, MDX components, or runtime component execution"); + expect(skill).toContain("plannotator: visual-plan"); + }); + + test("lists the supported visual block vocabulary", () => { + for (const directive of [ + "callout", + "file-map", + "checklist", + "diagram", + "open-questions", + "annotated-diff", + "code-walkthrough", + ]) { + expect(visualBlocks).toContain(`## ${directive}`); + expect(examplePacket).toContain(`::${directive}`); + } + }); + + test("example packet parses every visual directive as a directive block", () => { + const directiveKinds = parseMarkdownToBlocks(examplePacket) + .filter((block) => block.type === "directive") + .map((block) => block.directiveKind); + + expect(directiveKinds).toEqual([ + "callout", + "file-map", + "diagram", + "checklist", + "open-questions", + "code-walkthrough", + "annotated-diff", + ]); + }); + + test("example packet is detected as a visual plan packet", () => { + expect(detectPfmPacket(examplePacket)).toEqual({ + kind: "visual-plan", + visual: true, + detectedBy: "frontmatter", + }); + }); +}); diff --git a/apps/skills/extra/plannotator-visual-plan/examples/visual-plan-packet.md b/apps/skills/extra/plannotator-visual-plan/examples/visual-plan-packet.md new file mode 100644 index 000000000..758b1c470 --- /dev/null +++ b/apps/skills/extra/plannotator-visual-plan/examples/visual-plan-packet.md @@ -0,0 +1,62 @@ +--- +plannotator: visual-plan +title: Annotate Gate Visual Plan Fixture +--- + +# Annotate Gate Visual Plan Fixture + +This packet describes a small Plannotator-native plan for exercising visual plan rendering through annotate gate. + +::callout +**Public Surface** + +Use `plannotator annotate --gate ` for external visual plans. Do not add a new `plannotator plan` command in this slice. +:: + +## Scope + +- Detect a visual plan packet from frontmatter. +- Render supported PFM directives as native Plannotator blocks. +- Preserve approve, feedback, close, folder browsing, and source-save behavior from annotate mode. + +::file-map +- [M] packages/shared/pfm-packet.ts - Detects visual plan packet metadata. +- [M] packages/ui/components/blocks/VisualDirectiveBlock.tsx - Renders native visual blocks. +- [A] apps/skills/extra/plannotator-visual-plan/SKILL.md - Teaches agents how to author the packet. +:: + +## Flow + +::diagram mermaid +flowchart LR + Skill["plannotator-visual-plan skill"] --> Packet["PFM plan packet"] + Packet --> Gate["plannotator annotate --gate"] + Gate --> Review["Reviewer annotations"] + Review --> Agent["Agent revises or proceeds"] +:: + +::checklist +- [ ] Write the visual packet skill. +- [ ] Add a packet fixture. +- [ ] Validate packet detection and skill instructions. +- [ ] Run focused install and skill tests. +:: + +::open-questions +- Should visual review reuse the same directive vocabulary later? Recommended: yes, with review-specific blocks added through the same constrained contract. +- Should user-authored custom PFM execute arbitrary components? Recommended: no, use a future extension registry with schemas and renderer adapters. +:: + +::code-walkthrough +- `packages/shared/pfm-packet.ts` is the shared packet detection contract. +- `packages/server/annotate.ts` preserves gate semantics for visual packets. +- `apps/pi-extension/server/serverAnnotate.ts` mirrors visual packet metadata for Pi. +:: + +::annotated-diff +```diff ++ plannotator: visual-plan ++ title: Annotate Gate Visual Plan Fixture +- rawHtml: true +``` +:: diff --git a/apps/skills/extra/plannotator-visual-plan/references/visual-blocks.md b/apps/skills/extra/plannotator-visual-plan/references/visual-blocks.md new file mode 100644 index 000000000..eeb7d29f2 --- /dev/null +++ b/apps/skills/extra/plannotator-visual-plan/references/visual-blocks.md @@ -0,0 +1,90 @@ +# Visual Blocks + +Visual plan packets are ordinary markdown plus directive blocks. Unknown directives degrade as document content, so choose a supported block when you want Plannotator-native rendering. + +## callout + +Use for decisions, assumptions, warnings, scope notes, and local-only context. + +```markdown +::callout +**Decision** + +Use annotate gate as the public approval surface. +:: +``` + +## file-map + +Use for the implementation footprint. Prefer paths that exist or are planned exact paths. Include status badges such as `A`, `M`, `D`, or `?` when useful. + +```markdown +::file-map +- [A] apps/skills/extra/plannotator-visual-plan/SKILL.md - Skill recipe. +- [M] packages/shared/pfm-packet.ts - Existing packet detection contract. +:: +``` + +## checklist + +Use for execution steps or verification steps that the reviewer may comment on. Checklist toggles create Plannotator annotations in annotate mode. + +```markdown +::checklist +- [ ] Add packet fixture. +- [ ] Run focused tests. +:: +``` + +## diagram + +Use Mermaid or Graphviz for flows and architecture. Put the language after the directive name. + +```markdown +::diagram mermaid +flowchart LR + Skill["visual-plan skill"] --> Packet["PFM packet"] + Packet --> Gate["plannotator annotate --gate"] +:: +``` + +## open-questions + +Use for unresolved decisions. Give each question enough context and a recommended default when possible. + +```markdown +::open-questions +- Should the first cut include folder-level summary rendering? Recommended: no, preserve annotate folder browsing. +:: +``` + +## annotated-diff + +Use for small before/after snippets where the plan hinges on a concrete code change. Keep hunks short and explanatory. + +````markdown +::annotated-diff +```diff ++ plannotator: visual-plan +- rawHtml: true +``` +:: +```` + +## code-walkthrough + +Use for important files, functions, or data contracts that need review context. + +```markdown +::code-walkthrough +- `packages/shared/pfm-packet.ts` decides whether a markdown document is a visual packet. +- `packages/ui/components/BlockRenderer.tsx` routes visual directives to native renderers. +:: +``` + +## Source Rules + +- Keep prose useful without the renderer; visual blocks should enhance, not replace, the plan. +- Prefer lists, fenced code, and simple directive attributes over custom syntax. +- Keep examples truthful to the current repository. +- Do not embed MDX imports, React component names, or Agent-Native block names as executable syntax. diff --git a/packages/server/annotate.test.ts b/packages/server/annotate.test.ts index 5190ce593..165d42a9d 100644 --- a/packages/server/annotate.test.ts +++ b/packages/server/annotate.test.ts @@ -21,6 +21,10 @@ import { join } from "path"; import { startAnnotateServer } from "./annotate"; const MINIMAL_HTML = "Plannotator"; +const VISUAL_PLAN_SKILL_EXAMPLE = join( + import.meta.dir, + "../../apps/skills/extra/plannotator-visual-plan/examples/visual-plan-packet.md", +); describe("annotate server: /api/save-notes wiring", () => { // Bind a random local port regardless of env left behind by sibling suites. @@ -132,6 +136,38 @@ describe("annotate server: visual PFM packet metadata", () => { } }); + test("opens the visual plan skill example as an annotate gate visual packet", async () => { + const exampleMarkdown = readFileSync(VISUAL_PLAN_SKILL_EXAMPLE, "utf-8"); + const server = await startAnnotateServer({ + markdown: exampleMarkdown, + filePath: VISUAL_PLAN_SKILL_EXAMPLE, + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const response = await fetch(`${server.url}/api/plan`); + const plan = await response.json() as { + plan?: string; + mode?: string; + gate?: boolean; + pfmPacket?: { kind?: string; visual?: boolean; detectedBy?: string }; + }; + + expect(plan.mode).toBe("annotate"); + expect(plan.gate).toBe(true); + expect(plan.plan).toContain("Annotate Gate Visual Plan Fixture"); + expect(plan.plan).toContain("::file-map"); + expect(plan.pfmPacket).toEqual({ + kind: "visual-plan", + visual: true, + detectedBy: "frontmatter", + }); + } finally { + server.stop(); + } + }); + test("does not mark ordinary markdown as a visual packet", async () => { const server = await startAnnotateServer({ markdown: "# Plain\n\nJust markdown.\n", @@ -185,6 +221,149 @@ describe("annotate server: visual PFM packet metadata", () => { server.stop(); } }); + + test("preserves approve decision semantics for visual annotate gates", async () => { + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Plan\n", + filePath: join(tmpdir(), "visual-approve.md"), + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const decision = server.waitForDecision(); + const response = await fetch(`${server.url}/api/approve`, { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(await decision).toEqual({ + feedback: "", + annotations: [], + approved: true, + }); + } finally { + server.stop(); + } + }); + + test("preserves feedback decision semantics for visual annotate gates", async () => { + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Plan\n", + filePath: join(tmpdir(), "visual-feedback.md"), + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const decision = server.waitForDecision(); + const response = await fetch(`${server.url}/api/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "Revise scope.", + annotations: [{ id: "annotation-1" }], + selectedMessageId: "message-1", + feedbackScope: "message", + }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(await decision).toEqual({ + feedback: "Revise scope.", + annotations: [{ id: "annotation-1" }], + selectedMessageId: "message-1", + feedbackScope: "message", + }); + } finally { + server.stop(); + } + }); + + test("narrows optional message feedback fields for visual annotate gates", async () => { + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Plan\n", + filePath: join(tmpdir(), "visual-feedback-invalid-message-fields.md"), + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const decision = server.waitForDecision(); + const response = await fetch(`${server.url}/api/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + feedback: "Revise scope.", + annotations: [], + selectedMessageId: 123, + feedbackScope: "everything", + }), + }); + + expect(response.status).toBe(200); + expect(await decision).toEqual({ + feedback: "Revise scope.", + annotations: [], + selectedMessageId: undefined, + feedbackScope: undefined, + }); + } finally { + server.stop(); + } + }); + + test("preserves close decision semantics for visual annotate gates", async () => { + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Plan\n", + filePath: join(tmpdir(), "visual-close.md"), + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const decision = server.waitForDecision(); + const response = await fetch(`${server.url}/api/exit`, { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(await decision).toEqual({ + feedback: "", + annotations: [], + exit: true, + }); + } finally { + server.stop(); + } + }); + + test("keeps annotate-last terminal rules separate from visual packet detection", async () => { + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Plan\n", + filePath: "last-message", + mode: "annotate-last", + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const response = await fetch(`${server.url}/api/plan`); + const plan = await response.json() as { + mode?: string; + pfmPacket?: { kind?: string }; + agentTerminal?: { enabled?: boolean; reason?: string }; + }; + + expect(plan.mode).toBe("annotate-last"); + expect(plan.pfmPacket?.kind).toBe("visual-plan"); + expect(plan.agentTerminal).toEqual({ + enabled: false, + reason: "not-annotate-mode", + }); + } finally { + server.stop(); + } + }); }); describe("annotate server: /api/share-html symlink containment", () => { @@ -292,6 +471,50 @@ describe("annotate server: source save", () => { } }); + test("keeps source save enabled for visual packet markdown files", async () => { + const docDir = mkdtempSync(join(tmpdir(), "plannotator-visual-source-save-")); + const sourcePath = join(docDir, "visual-plan.md"); + writeFileSync(sourcePath, "---\npfm: visual-plan\n---\n\n# Before\n", "utf-8"); + + const server = await startAnnotateServer({ + markdown: "---\npfm: visual-plan\n---\n\n# Before\n", + filePath: sourcePath, + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const planResponse = await fetch(`${server.url}/api/plan`); + const plan = await planResponse.json() as { + pfmPacket?: { kind?: string }; + sourceSave?: { + enabled?: boolean; + hash: string; + mtimeMs: number; + eol: "lf" | "crlf" | "mixed" | "none"; + }; + }; + expect(plan.pfmPacket?.kind).toBe("visual-plan"); + expect(plan.sourceSave?.enabled).toBe(true); + + const response = await fetch(`${server.url}/api/source/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "---\npfm: visual-plan\n---\n\n# After\n", + baseHash: plan.sourceSave!.hash, + baseMtimeMs: plan.sourceSave!.mtimeMs, + baseEol: plan.sourceSave!.eol, + }), + }); + + expect(response.status).toBe(200); + expect(readFileSync(sourcePath, "utf-8")).toBe("---\npfm: visual-plan\n---\n\n# After\n"); + } finally { + server.stop(); + } + }); + test("recreates a missing single-file source when the session started for that path", async () => { const docDir = mkdtempSync(join(tmpdir(), "plannotator-source-save-missing-start-")); const sourcePath = join(docDir, "source.md"); @@ -442,6 +665,54 @@ describe("annotate server: source save", () => { } }); + test("keeps folder source save enabled for opened visual packet files", async () => { + const folderPath = mkdtempSync(join(tmpdir(), "plannotator-visual-folder-source-save-")); + const openedPath = join(folderPath, "visual-plan.md"); + writeFileSync(openedPath, "---\npfm: visual-plan\n---\n\n# Before\n", "utf-8"); + + const server = await startAnnotateServer({ + markdown: "", + filePath: folderPath, + folderPath, + mode: "annotate-folder", + htmlContent: MINIMAL_HTML, + gate: true, + }); + + try { + const docResponse = await fetch(`${server.url}/api/doc?path=${encodeURIComponent(openedPath)}`); + const doc = await docResponse.json() as { + pfmPacket?: { kind?: string }; + sourceSave?: { + enabled?: boolean; + path: string; + hash: string; + mtimeMs: number; + eol: "lf" | "crlf" | "mixed" | "none"; + }; + }; + expect(doc.pfmPacket?.kind).toBe("visual-plan"); + expect(doc.sourceSave?.enabled).toBe(true); + + const response = await fetch(`${server.url}/api/source/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: doc.sourceSave!.path, + text: "---\npfm: visual-plan\n---\n\n# After\n", + baseHash: doc.sourceSave!.hash, + baseMtimeMs: doc.sourceSave!.mtimeMs, + baseEol: doc.sourceSave!.eol, + }), + }); + + expect(response.status).toBe(200); + expect(readFileSync(openedPath, "utf-8")).toBe("---\npfm: visual-plan\n---\n\n# After\n"); + } finally { + server.stop(); + } + }); + test("recreates a deleted folder source opened through a relative base link", async () => { const folderPath = mkdtempSync(join(tmpdir(), "plannotator-folder-relative-source-save-")); const subDir = join(folderPath, "sub"); diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index a819afaa6..e28268093 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -577,8 +577,8 @@ export async function startAnnotateServer( resolveDecision({ feedback: body.feedback || "", annotations: body.annotations || [], - selectedMessageId: body.selectedMessageId, - feedbackScope: body.feedbackScope, + selectedMessageId: typeof body.selectedMessageId === "string" ? body.selectedMessageId : undefined, + feedbackScope: body.feedbackScope === "messages" ? "messages" : body.feedbackScope === "message" ? "message" : undefined, }); return Response.json({ ok: true }); diff --git a/scripts/install.cmd b/scripts/install.cmd index 855d67ca3..0817aafbb 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -550,7 +550,7 @@ set "AGENTS_SKILLS_DIR=%USERPROFILE%\.agents\skills" set "MIGRATIONS_DIR=!_CONFIG_DIR!\migrations" set "EXTRAS_MIGRATION=!MIGRATIONS_DIR!\2026-06-extras-default-install-removed" if not exist "!EXTRAS_MIGRATION!" ( - for %%S in (plannotator-compound plannotator-setup-goal plannotator-visual-explainer) do ( + for %%S in (plannotator-compound plannotator-setup-goal plannotator-visual-plan plannotator-visual-explainer) do ( if exist "!CLAUDE_SKILLS_DIR!\%%S" ( rmdir /s /q "!CLAUDE_SKILLS_DIR!\%%S" >nul 2>&1 echo Removed extra Plannotator skill from !CLAUDE_SKILLS_DIR!\%%S ^(reinstall via npx skills add^) @@ -584,7 +584,7 @@ if exist "!PREFS_FILE!" ( REM Extras already on disk? Then the extras question is moot — they still REM count toward the picker list, and we never launch the npx flow over them. set "EXTRAS_PRESENT=0" -for %%S in (plannotator-compound plannotator-setup-goal plannotator-visual-explainer) do ( +for %%S in (plannotator-compound plannotator-setup-goal plannotator-visual-plan plannotator-visual-explainer) do ( if exist "!CLAUDE_SKILLS_DIR!\%%S" set "EXTRAS_PRESENT=1" if exist "!AGENTS_SKILLS_DIR!\%%S" set "EXTRAS_PRESENT=1" ) @@ -724,11 +724,15 @@ if !ERRORLEVEL! equ 0 ( xcopy /s /i /y /q "apps\kiro-cli\skills\%%S" "!KIRO_SKILLS_DIR!\%%S\" >nul 2>&1 ) ) - REM The two extras Kiro keeps receiving come from apps\skills\extra. + REM Shared extras Kiro receives come from apps\skills\extra. if exist "apps\skills\extra\plannotator-setup-goal" ( if exist "!KIRO_SKILLS_DIR!\plannotator-setup-goal" rmdir /s /q "!KIRO_SKILLS_DIR!\plannotator-setup-goal" >nul 2>&1 xcopy /s /i /y /q "apps\skills\extra\plannotator-setup-goal" "!KIRO_SKILLS_DIR!\plannotator-setup-goal\" >nul 2>&1 ) + if exist "apps\skills\extra\plannotator-visual-plan" ( + if exist "!KIRO_SKILLS_DIR!\plannotator-visual-plan" rmdir /s /q "!KIRO_SKILLS_DIR!\plannotator-visual-plan" >nul 2>&1 + xcopy /s /i /y /q "apps\skills\extra\plannotator-visual-plan" "!KIRO_SKILLS_DIR!\plannotator-visual-plan\" >nul 2>&1 + ) if exist "apps\skills\extra\plannotator-visual-explainer" ( if exist "!KIRO_SKILLS_DIR!\plannotator-visual-explainer" rmdir /s /q "!KIRO_SKILLS_DIR!\plannotator-visual-explainer" >nul 2>&1 xcopy /s /i /y /q "apps\skills\extra\plannotator-visual-explainer" "!KIRO_SKILLS_DIR!\plannotator-visual-explainer\" >nul 2>&1 @@ -783,7 +787,7 @@ if exist "!OPENCODE_COMMANDS_DIR!\plannotator-archive.md" ( REM Codex no longer hosts core skills (they live in %%USERPROFILE%%\.agents\skills). REM Core skills are removed only once their replacement exists; the stale REM shared-agent extras were never Codex's and are removed unconditionally. -for %%S in (plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal) do ( +for %%S in (plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal plannotator-visual-plan) do ( if exist "!STALE_CODEX_SKILLS_DIR!\%%S" ( set "OK_REMOVE=1" if "%%S"=="plannotator-review" if not exist "!AGENTS_SKILLS_DIR!\%%S" set "OK_REMOVE=0" @@ -929,7 +933,7 @@ echo. echo The /plannotator-review, /plannotator-annotate, and /plannotator-last skills are ready to use! if not "!EXTRAS_CHOICE!"=="yes" ( echo. - echo Optional skills ^(compound planning, setup-goal, visual explainer^): + echo Optional skills ^(compound planning, setup-goal, visual plan, visual explainer^): echo npx skills add backnotprop/plannotator/apps/skills/extra ) @@ -1107,7 +1111,7 @@ if "!EXTRAS_PRESENT!"=="1" ( set "DEF_EXTRAS=no" if defined SAVED_EXTRAS set "DEF_EXTRAS=!SAVED_EXTRAS!" set "ANSWER=" - set /p "ANSWER=Install the extra skills (compound planning, setup-goal, visual explainer)? [y/N] " + set /p "ANSWER=Install the extra skills (compound planning, setup-goal, visual plan, visual explainer)? [y/N] " set "EXTRAS_CHOICE=no" if /i "!ANSWER!"=="y" set "EXTRAS_CHOICE=yes" if /i "!ANSWER!"=="yes" set "EXTRAS_CHOICE=yes" @@ -1132,10 +1136,11 @@ set "SKILL_1=plannotator-review" set "SKILL_2=plannotator-annotate" set "SKILL_3=plannotator-last" if "!EXTRAS_CHOICE!"=="yes" ( - set "SKILL_COUNT=6" + set "SKILL_COUNT=7" set "SKILL_4=plannotator-compound" set "SKILL_5=plannotator-setup-goal" - set "SKILL_6=plannotator-visual-explainer" + set "SKILL_6=plannotator-visual-plan" + set "SKILL_7=plannotator-visual-explainer" ) REM Preselect previously chosen skills. NOTE: no pipes here — each side of a REM cmd pipe runs in a child without delayed expansion, so !vars! would pass diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7a32df189..4f287fe2c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -462,7 +462,7 @@ foreach ($junk in @("core", "extra")) { } } -# Extras (compound / setup-goal / visual-explainer) are no longer managed in +# Extras (compound / setup-goal / visual-plan / visual-explainer) are no longer managed in # the Claude or shared-agent skill scopes. Remove previously default-installed # copies ONCE per machine — recorded in the migrations ledger under the # Plannotator data dir — because copies the user reinstalls via `npx skills @@ -473,7 +473,7 @@ $agentsSkillsDir = "$env:USERPROFILE\.agents\skills" $migrationsDir = Join-Path $configDir "migrations" $extrasMigration = Join-Path $migrationsDir "2026-06-extras-default-install-removed" if (-not (Test-Path $extrasMigration)) { - foreach ($skill in @("plannotator-compound", "plannotator-setup-goal", "plannotator-visual-explainer")) { + foreach ($skill in @("plannotator-compound", "plannotator-setup-goal", "plannotator-visual-plan", "plannotator-visual-explainer")) { foreach ($scopeDir in @($claudeSkillsDir, $agentsSkillsDir)) { $extraSkillPath = Join-Path $scopeDir $skill if (Test-Path $extraSkillPath) { @@ -493,7 +493,7 @@ if (-not (Test-Path $extrasMigration)) { # redirected/CI runs never prompt. Flags win over everything. $prefsFile = Join-Path $configDir "install-prefs" $coreSkillNames = @("plannotator-review", "plannotator-annotate", "plannotator-last") -$extraSkillNames = @("plannotator-compound", "plannotator-setup-goal", "plannotator-visual-explainer") +$extraSkillNames = @("plannotator-compound", "plannotator-setup-goal", "plannotator-visual-plan", "plannotator-visual-explainer") $savedExtras = "" $savedInvocable = "" @@ -631,7 +631,7 @@ if ($runWizard) { $extrasChoice = if ($Extras) { "yes" } else { "no" } } else { $defaultExtras = if ($savedExtras) { $savedExtras } else { "no" } - $extrasChoice = Read-YesNo "Install the extra skills (compound planning, setup-goal, visual explainer)?" $defaultExtras + $extrasChoice = Read-YesNo "Install the extra skills (compound planning, setup-goal, visual plan, visual explainer)?" $defaultExtras } $invocableList = $coreSkillNames if ($extrasChoice -eq "yes") { $invocableList = $coreSkillNames + $extraSkillNames } @@ -757,15 +757,16 @@ try { Write-Host "Tag $latestTag predates the core/extra skill layout — skipping shared agent skill install" } - # Kiro: hand-maintained skills (origin baked in) + two extras. + # Kiro: hand-maintained skills (origin baked in) + shared extras. if ($kiroAvailable -and (Test-Path "apps\kiro-cli\skills")) { $kiroSkillsDir = "$env:USERPROFILE\.kiro\skills" New-Item -ItemType Directory -Force -Path $kiroSkillsDir | Out-Null # Kiro-specific skills (origin baked in) come from apps/kiro-cli/skills. Copy-SkillIfPresent "apps\kiro-cli\skills\plannotator-review" $kiroSkillsDir Copy-SkillIfPresent "apps\kiro-cli\skills\plannotator-annotate" $kiroSkillsDir - # Two extras come from apps/skills/extra (not duplicated into apps/kiro-cli/skills). + # Shared extras come from apps/skills/extra (not duplicated into apps/kiro-cli/skills). Copy-SkillIfPresent "apps\skills\extra\plannotator-setup-goal" $kiroSkillsDir + Copy-SkillIfPresent "apps\skills\extra\plannotator-visual-plan" $kiroSkillsDir Copy-SkillIfPresent "apps\skills\extra\plannotator-visual-explainer" $kiroSkillsDir # Plannotator custom agent — don't clobber a user's existing one. $kiroAgentsDir = "$env:USERPROFILE\.kiro\agents" @@ -851,7 +852,7 @@ if (Test-Path $staleOpencodeArchive) { # Codex no longer hosts core skills (they now live in ~/.agents/skills). # Core skills are removed only once their replacement exists; the stale # shared-agent extras were never Codex's and are removed unconditionally. -foreach ($skill in @("plannotator-review", "plannotator-annotate", "plannotator-last", "plannotator-compound", "plannotator-setup-goal")) { +foreach ($skill in @("plannotator-review", "plannotator-annotate", "plannotator-last", "plannotator-compound", "plannotator-setup-goal", "plannotator-visual-plan")) { $staleSkillPath = Join-Path $staleCodexSkillsDir $skill if (Test-Path $staleSkillPath) { $isCore = $skill -in @("plannotator-review", "plannotator-annotate", "plannotator-last") @@ -1013,7 +1014,7 @@ Write-Host "The /plannotator-review, /plannotator-annotate, and /plannotator-las if ($extrasChoice -ne "yes") { Write-Host "" - Write-Host "Optional skills (compound planning, setup-goal, visual explainer):" + Write-Host "Optional skills (compound planning, setup-goal, visual plan, visual explainer):" Write-Host " npx skills add backnotprop/plannotator/apps/skills/extra" } diff --git a/scripts/install.sh b/scripts/install.sh index f3aaa97a9..d7495ac02 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -58,7 +58,7 @@ Options: --skip-attestation Force-skip provenance verification even if enabled via env var or ~/.plannotator/config.json. --extras Install the extra skills (compound, setup-goal, - visual-explainer) via `npx skills add` without asking. + visual-plan, visual-explainer) via `npx skills add` without asking. --no-extras Skip the extras without asking. --model-invocable Comma-separated skill names to make model-invocable (e.g. plannotator-review,plannotator-compound), or @@ -799,7 +799,7 @@ MIGRATIONS_DIR="$_config_dir/migrations" EXTRAS_MIGRATION="$MIGRATIONS_DIR/2026-06-extras-default-install-removed" if [ ! -f "$EXTRAS_MIGRATION" ]; then for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR"; do - for skill in plannotator-compound plannotator-setup-goal plannotator-visual-explainer; do + for skill in plannotator-compound plannotator-setup-goal plannotator-visual-plan plannotator-visual-explainer; do if [ -d "$scope/$skill" ]; then rm -rf "$scope/$skill" echo "Removed extra Plannotator skill from ${scope}/$skill (reinstall via npx skills add)" @@ -817,7 +817,7 @@ fi # CI runs without a terminal never prompt. CLI flags win over everything. PREFS_FILE="$_config_dir/install-prefs" CORE_SKILL_NAMES="plannotator-review plannotator-annotate plannotator-last" -EXTRA_SKILL_NAMES="plannotator-compound plannotator-setup-goal plannotator-visual-explainer" +EXTRA_SKILL_NAMES="plannotator-compound plannotator-setup-goal plannotator-visual-plan plannotator-visual-explainer" saved_extras="" saved_invocable="" @@ -973,7 +973,7 @@ if [ "$run_wizard" -eq 1 ]; then # Flag already answered this question — don't ask and then ignore. extras_choice="$EXTRAS_FLAG" else - extras_choice=$(ask_yes_no "Install the extra skills (compound planning, setup-goal, visual explainer)?" "${saved_extras:-no}") || wizard_timed_out=1 + extras_choice=$(ask_yes_no "Install the extra skills (compound planning, setup-goal, visual plan, visual explainer)?" "${saved_extras:-no}") || wizard_timed_out=1 fi invocable_list="$CORE_SKILL_NAMES" if [ "$extras_choice" = "yes" ]; then @@ -1138,6 +1138,7 @@ checkout_failed=0 copy_skill_if_present apps/kiro-cli/skills/plannotator-annotate "$KIRO_SKILLS_DIR" # Extras come from apps/skills/extra (not duplicated into apps/kiro-cli/skills). copy_skill_if_present apps/skills/extra/plannotator-setup-goal "$KIRO_SKILLS_DIR" + copy_skill_if_present apps/skills/extra/plannotator-visual-plan "$KIRO_SKILLS_DIR" copy_skill_if_present apps/skills/extra/plannotator-visual-explainer "$KIRO_SKILLS_DIR" # Plannotator custom agent — don't clobber a user's existing one. if [ ! -f "$HOME/.kiro/agents/plannotator.json" ] && [ -f "apps/kiro-cli/agents/plannotator.json" ]; then @@ -1186,7 +1187,7 @@ fi # Codex no longer hosts core skills (they now live in ~/.agents/skills). # Core skills are removed only once their replacement exists; the stale # shared-agent extras were never Codex's and are removed unconditionally. -for skill in plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal; do +for skill in plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal plannotator-visual-plan; do if [ -d "$STALE_CODEX_SKILLS_DIR/$skill" ]; then case "$skill" in plannotator-review|plannotator-annotate|plannotator-last) @@ -1373,7 +1374,7 @@ echo "The /plannotator-review, /plannotator-annotate, and /plannotator-last comm if [ "$extras_choice" != "yes" ]; then echo "" - echo "Optional skills (compound planning, setup-goal, visual explainer):" + echo "Optional skills (compound planning, setup-goal, visual plan, visual explainer):" echo " npx skills add backnotprop/plannotator/apps/skills/extra" fi diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 2d95376d4..5f9550f17 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -21,6 +21,13 @@ const CORE_SKILLS = [ "plannotator-last", ]; +const EXTRA_SKILLS = [ + "plannotator-compound", + "plannotator-setup-goal", + "plannotator-visual-plan", + "plannotator-visual-explainer", +]; + describe("install.sh", () => { const script = readFileSync(join(scriptsDir, "install.sh"), "utf-8"); @@ -170,8 +177,9 @@ describe("install.sh", () => { // Kiro-specific skills (origin baked in) come from apps/kiro-cli/skills. expect(script).toContain('copy_skill_if_present apps/kiro-cli/skills/plannotator-review "$KIRO_SKILLS_DIR"'); expect(script).toContain('copy_skill_if_present apps/kiro-cli/skills/plannotator-annotate "$KIRO_SKILLS_DIR"'); - // The two extras Kiro keeps receiving come from apps/skills/extra. + // Shared extras Kiro receives come from apps/skills/extra. expect(script).toContain('copy_skill_if_present apps/skills/extra/plannotator-setup-goal "$KIRO_SKILLS_DIR"'); + expect(script).toContain('copy_skill_if_present apps/skills/extra/plannotator-visual-plan "$KIRO_SKILLS_DIR"'); expect(script).toContain('copy_skill_if_present apps/skills/extra/plannotator-visual-explainer "$KIRO_SKILLS_DIR"'); // sparse-checkout fetches apps/kiro-cli (skills + agent example). expect(script).toContain("git sparse-checkout set apps/skills apps/kiro-cli"); @@ -195,10 +203,10 @@ describe("install.sh", () => { // previously-stale compound/setup-goal. expect(script).toContain("STALE_CODEX_SKILLS_DIR"); expect(script).toContain( - "for skill in plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal; do", + "for skill in plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal plannotator-visual-plan; do", ); // Extras stop being managed in the Claude and shared-agent scopes. - expect(script).toContain("plannotator-compound plannotator-setup-goal plannotator-visual-explainer"); + expect(script).toContain(EXTRA_SKILLS.join(" ")); // plannotator-archive no longer ships as a skill — a stale installed copy // is removed unconditionally from every skill scope. expect(script).toContain( @@ -210,7 +218,7 @@ describe("install.sh", () => { }); test("suggests installing extras via npx skills add", () => { - expect(script).toContain("Optional skills (compound planning, setup-goal, visual explainer):"); + expect(script).toContain("Optional skills (compound planning, setup-goal, visual plan, visual explainer):"); expect(script).toContain("npx skills add backnotprop/plannotator/apps/skills/extra"); }); @@ -385,9 +393,9 @@ describe("install.ps1", () => { expect(script).not.toContain("legacyAgentsSkillsDir"); // Codex cleanup includes the per-command skills now. expect(script).toContain("staleCodexSkillsDir"); - expect(script).toContain('"plannotator-review", "plannotator-annotate", "plannotator-last", "plannotator-compound", "plannotator-setup-goal"'); + expect(script).toContain('"plannotator-review", "plannotator-annotate", "plannotator-last", "plannotator-compound", "plannotator-setup-goal", "plannotator-visual-plan"'); // Extras removed from Claude + shared-agent scopes, once, via the ledger. - expect(script).toContain('"plannotator-compound", "plannotator-setup-goal", "plannotator-visual-explainer"'); + expect(script).toContain('"plannotator-compound", "plannotator-setup-goal", "plannotator-visual-plan", "plannotator-visual-explainer"'); expect(script).toContain("2026-06-extras-default-install-removed"); expect(script).toContain("if (-not (Test-Path $extrasMigration))"); // plannotator-archive no longer ships as a skill — a stale installed copy @@ -407,7 +415,7 @@ describe("install.ps1", () => { }); test("suggests installing extras via npx skills add", () => { - expect(script).toContain("Optional skills (compound planning, setup-goal, visual explainer):"); + expect(script).toContain("Optional skills (compound planning, setup-goal, visual plan, visual explainer):"); expect(script).toContain("npx skills add backnotprop/plannotator/apps/skills/extra"); }); @@ -512,9 +520,9 @@ describe("install.cmd", () => { expect(script).not.toContain("LEGACY_AGENTS_SKILLS_DIR"); // Codex cleanup includes the per-command skills now. expect(script).toContain("STALE_CODEX_SKILLS_DIR"); - expect(script).toContain("for %%S in (plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal) do"); + expect(script).toContain("for %%S in (plannotator-review plannotator-annotate plannotator-last plannotator-compound plannotator-setup-goal plannotator-visual-plan) do"); // Extras removed from Claude + shared-agent scopes, once, via the ledger. - expect(script).toContain("for %%S in (plannotator-compound plannotator-setup-goal plannotator-visual-explainer) do"); + expect(script).toContain("for %%S in (plannotator-compound plannotator-setup-goal plannotator-visual-plan plannotator-visual-explainer) do"); expect(script).toContain("2026-06-extras-default-install-removed"); expect(script).toContain('if not exist "!EXTRAS_MIGRATION!"'); // plannotator-archive no longer ships as a skill — a stale installed copy