diff --git a/.agents/skills/checklist/SKILL.md b/.agents/skills/checklist/SKILL.md new file mode 100644 index 00000000..4ebd5c8c --- /dev/null +++ b/.agents/skills/checklist/SKILL.md @@ -0,0 +1,171 @@ +--- +name: checklist +description: > + Generate a QA checklist for manual developer verification of code changes. + Use when the user wants to verify completed work, review a diff for quality, + create acceptance criteria checks, or run through QA steps before shipping. + Triggers on requests like "create a checklist", "what should I test", + "verify my changes", "QA this", or "pre-flight check". +disable-model-invocation: true +--- + +# QA Checklist + +You are a senior QA engineer. Your job is to analyze the current code changes and produce a **QA checklist** — a structured list of verification tasks the developer needs to manually review before the work is considered done. + +This is not a code review. Code reviews catch style issues and logic bugs in the diff itself. A QA checklist catches the things that only a human can verify by actually running, clicking, testing, and observing the software. You're producing the verification plan that bridges "the code looks right" to "the software actually works." + +## Principles + +**Focus on what humans must verify.** If an automated test already covers something with meaningful assertions, it doesn't need a checklist item. But "tests exist" is not enough — test coverage that only asserts existence or happy-path behavior still leaves gaps that need human eyes. + +**Be specific, not vague.** "Test the login flow" is useless. "Verify that login with an expired JWT returns a 401 with `{error: 'token_expired'}` body, not a 500 with a stack trace" tells the developer exactly what to check, what to expect, and what failure looks like. + +**Every item is a mini test case.** Each checklist entry should have enough context that a developer unfamiliar with the change could pick it up and verify it. The description explains the change and the risk. The steps walk through the exact verification procedure. The expected outcome is clear. + +**Fewer good items beat many shallow ones.** Aim for 5–15 items. If you're producing more than 15, you're generating busywork — prioritize the items where human verification actually matters. If you're producing fewer than 5, look harder at edge cases, integration points, and deployment concerns. + +## Workflow + +### 1. Gather Context + +Start by understanding what changed and why. + +```bash +git diff HEAD +``` + +If that's empty, try the branch diff: + +```bash +git diff main...HEAD +``` + +As you read the diff, build a mental model: + +- **What kind of change is this?** New feature, bug fix, refactor, dependency update, config/infra change. This determines which categories of verification matter most. +- **Which files changed and what do they do?** UI components need visual verification. API routes need functional testing. Database migrations need data integrity checks. Config files need deployment verification. +- **Do tests exist for this code?** Look for test files related to the changed code. Tests that meaningfully cover the changed behavior reduce the need for manual verification — but tests that only cover the happy path or assert existence still leave gaps. + +As you read the diff, count the number of diff hunks (`@@` markers) per file. You'll use these counts in step 3 to populate `fileDiffs` and `diffMap`. + +Also collect line counts and new/modified status for the PR Balance visualization: +```bash +# Line counts per file (added + removed) +git diff --stat HEAD | head -n -1 +# New files (status "new"), everything else is "modified" +git diff --diff-filter=A --name-only HEAD +``` + +### 2. Decide What Needs Manual Verification + +Think about each change through the lens of what could go wrong that a human needs to catch. Consider categories like: + +- **Visual** — Does it look right? Layout, responsiveness, dark mode, animations, color contrast. Only relevant when UI files changed. +- **Functional** — Does the feature work end-to-end? Happy path and primary error paths. Always relevant for new features and bug fixes. +- **Edge cases** — Empty input, huge input, special characters, concurrent access, timezone issues. Focus on cases the diff suggests are likely, not every theoretical scenario. +- **Integration** — Does this break callers or consumers? API contract changes, event format changes, shared state mutations. +- **Security** — Auth checks on new endpoints, input sanitization, secrets exposure, CORS changes. +- **Data** — Database migrations, schema changes, backwards compatibility, data format changes. +- **Performance** — Only when the diff touches hot paths, adds queries, or changes data structures. +- **Deployment** — New environment variables, feature flags, migration ordering, new dependencies. +- **Developer experience** — Error messages, documentation, CLI help text, logging. + +These are suggestions, not a fixed list. Use whatever category label best describes the type of verification. If the change involves "api-contract" or "accessibility" or "offline-behavior," use that. + +### 3. Generate the Checklist JSON + +Produce a JSON object with this structure: + +```json +{ + "title": "Short title for the checklist", + "summary": "One paragraph explaining what changed and why manual verification matters.", + "pr": { + "number": 142, + "url": "https://github.com/org/repo/pull/142", + "title": "feat: add OAuth2 support", + "branch": "feat/oauth2", + "provider": "github" + }, + "fileDiffs": { + "src/middleware/auth.ts": { "hunks": 5, "lines": 320, "status": "modified" }, + "src/pages/login.tsx": { "hunks": 3, "lines": 180, "status": "modified" }, + "src/lib/api-client.ts": { "hunks": 4, "lines": 250, "status": "new" } + }, + "items": [ + { + "id": "category-N", + "category": "free-form category label", + "check": "Imperative verb phrase — the headline", + "description": "Markdown narrative explaining what changed in the code, what could go wrong, what the expected behavior is, and how the developer knows the test passes.", + "steps": [ + "Step 1: Do this specific thing", + "Step 2: Observe this specific result", + "Step 3: Confirm this specific expectation" + ], + "reason": "Why this needs human eyes — what makes it not fully automatable.", + "files": ["src/middleware/auth.ts", "src/pages/login.tsx"], + "diffMap": { "src/middleware/auth.ts": 3, "src/pages/login.tsx": 2 }, + "critical": false + } + ] +} +``` + +**Field guidance:** + +- **`pr`** (optional): Include when the checklist is associated with a pull/merge request. The UI displays a PR badge in the header and enables automation options (post results as a PR comment, auto-approve if all checks pass). Detect the provider from the git remote: + - `github.com` → `"provider": "github"` + - `gitlab.com` or self-hosted GitLab → `"provider": "gitlab"` + - `dev.azure.com` or `visualstudio.com` → `"provider": "azure-devops"` + + To detect if a PR exists for the current branch: + ```bash + # GitHub + gh pr view --json number,url,title,headRefName 2>/dev/null + # GitLab + glab mr view --output json 2>/dev/null + # Azure DevOps + az repos pr list --source-branch "$(git branch --show-current)" --output json 2>/dev/null + ``` + If the command succeeds, populate the `pr` field. If it fails (no PR exists, CLI not installed), omit it entirely. Do not error on missing CLIs — the `pr` field is optional. + +- **`id`**: Prefix with a short category tag and number: `func-1`, `sec-2`, `visual-1`. This makes items easy to reference in feedback. +- **`category`**: Free-form string. Pick the label that best describes the verification type. Common ones: `visual`, `functional`, `edge-case`, `integration`, `security`, `data`, `performance`, `deployment`, `devex`. +- **`check`**: The headline. Always starts with a verb: Verify, Confirm, Check, Test, Ensure, Open, Navigate, Run. This is what appears as the checklist item label. +- **`description`**: The heart of the item. Write this as a markdown narrative that tells the full story: + - What changed in the code (reference specific files/functions) + - What could go wrong as a result + - What the expected behavior should be + - How the developer knows the test passes vs fails +- **`steps`**: Required. Ordered instructions for conducting the verification. Be concrete — "Open browser devtools" not "check the network." Each step should be a single clear action. +- **`reason`**: One sentence explaining why automation can't fully cover this. "CSS grid rendering varies across browsers" is good. "Because it changed" is not. +- **`files`**: File paths from the diff that this item relates to. Helps the developer trace your reasoning. Optional when `diffMap` is provided (derivable from its keys). +- **`diffMap`**: Object mapping file paths to the number of diff hunks in that file that this check exercises. Paths must be keys in `fileDiffs`. Multiple items can cover the same hunks — that's expected (many-to-many). Example: `{ "src/middleware/auth.ts": 3, "src/pages/login.tsx": 2 }`. +- **`fileDiffs`** (on the top-level checklist, not per-item): Object mapping each changed file's relative path to its diff metadata. Each value is `{ "hunks": N, "lines": N, "status": "new" | "modified" }`. `hunks` = count of `@@` markers in the diff. `lines` = total lines changed (from `git diff --stat`). `status` = `"new"` for added files, `"modified"` for everything else. This enables coverage visualization (hunks) and PR Balance (lines + status). Legacy format (plain number = hunk count) is still accepted but won't enable PR Balance. +- **`critical`**: Reserve for items where failure means data loss, security vulnerability, or broken deployment. Typically 0–3 items per checklist. + +### 4. Launch the Checklist UI + +Write your JSON to a temporary file and pass it via `--file`: + +```bash +cat > /tmp/checklist.json << 'CHECKLIST_EOF' + +CHECKLIST_EOF +plannotator checklist --file /tmp/checklist.json +``` + +This avoids shell quoting issues with large or complex JSON. The UI opens for the developer to work through each item — marking them as passed, failed, or skipped with notes and screenshot evidence. Wait for the output — it contains the developer's results. + +### 5. Respond to Results + +When the checklist results come back: + +- **All passed**: The verification is complete. Acknowledge it and move on. +- **Items failed**: Read the developer's notes carefully. Fix the issue if you can. If the current behavior is actually correct, explain why. +- **Items skipped**: Note the reason. If items were skipped as "not applicable," your checklist may have been too broad for this change — take that as feedback. +- **Questions attached**: Answer them directly, with references to the relevant code. + +$ARGUMENTS diff --git a/.gitignore b/.gitignore index 6bac1df9..2075e369 100644 --- a/.gitignore +++ b/.gitignore @@ -19,13 +19,15 @@ dist-ssr # VS Code extension package *.vsix -# OpenCode plugin build artifacts (generated from hook/review dist) +# OpenCode plugin build artifacts (generated from hook/review/checklist dist) apps/opencode-plugin/plannotator.html apps/opencode-plugin/review-editor.html +apps/opencode-plugin/checklist.html -# Pi extension build artifacts (generated from hook/review dist) +# Pi extension build artifacts (generated from hook/review/checklist dist) apps/pi-extension/plannotator.html apps/pi-extension/review-editor.html +apps/pi-extension/checklist.html # Editor directories and files .vscode/* diff --git a/CLAUDE.md b/CLAUDE.md index fa9bc2f8..4f642711 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,8 @@ plannotator/ │ │ ├── draft.ts # Annotation draft persistence (~/.plannotator/drafts/) │ │ ├── integrations.ts # Obsidian, Bear integrations │ │ ├── ide.ts # VS Code diff integration (openEditorDiff) +│ │ ├── checklist.ts # startChecklistServer(), formatChecklistFeedback() +│ │ ├── serve.ts # Shared Bun server startup (startServer) │ │ ├── editor-annotations.ts # VS Code editor annotation endpoints │ │ └── project.ts # Project name detection for tags │ ├── ui/ # Shared React components @@ -53,13 +55,19 @@ plannotator/ │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts │ │ ├── hooks/ # useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts │ │ └── types.ts -│ ├── shared/ # Cross-package types (EditorAnnotation) +│ ├── shared/ # Cross-package types (EditorAnnotation, checklist-types) │ ├── editor/ # Plan review App.tsx -│ └── review-editor/ # Code review UI -│ ├── App.tsx # Main review app -│ ├── components/ # DiffViewer, FileTree, ReviewPanel -│ ├── demoData.ts # Demo diff for standalone mode -│ └── index.css # Review-specific styles +│ ├── review-editor/ # Code review UI +│ │ ├── App.tsx # Main review app +│ │ ├── components/ # DiffViewer, FileTree, ReviewPanel +│ │ ├── demoData.ts # Demo diff for standalone mode +│ │ └── index.css # Review-specific styles +│ └── checklist-editor/ # QA checklist UI +│ ├── App.tsx # Main checklist app +│ ├── components/ # ChecklistItem, ChecklistGroup, ChecklistHeader, etc. +│ ├── hooks/ # useChecklistState, useChecklistProgress, useChecklistDraft +│ └── index.css # Checklist-specific styles +├── .agents/skills/checklist/ # QA checklist skill (SKILL.md) ├── .claude-plugin/marketplace.json # For marketplace install └── legacy/ # Old pre-monorepo code (reference only) ``` @@ -195,6 +203,16 @@ Send Annotations → feedback sent to agent session | `/api/upload` | POST | Upload image, returns `{ path, originalName }` | | `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes | +### Checklist Server (`packages/server/checklist.ts`) + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/api/checklist` | GET | Returns `{ checklist, origin, mode, initialResults?, initialGlobalNotes? }` | +| `/api/feedback` | POST | Submit results (body: results, globalNotes, automations, agentSwitch) | +| `/api/image` | GET | Serve image by path query param | +| `/api/upload` | POST | Upload image, returns `{ path, originalName }` | +| `/api/draft` | GET/POST/DELETE | Auto-save checklist drafts to survive server crashes | + All servers use random ports locally or fixed port (`19432`) in remote mode. ### Paste Service (`apps/paste-service/`) @@ -358,6 +376,7 @@ bun install # Run any app bun run dev:hook # Hook server (plan review) bun run dev:review # Review editor (code review) +bun run dev:checklist # Checklist editor (QA checklist) bun run dev:portal # Portal editor bun run dev:marketing # Marketing site bun run dev:vscode # VS Code extension (watch mode) @@ -368,7 +387,8 @@ bun run dev:vscode # VS Code extension (watch mode) ```bash bun run build:hook # Single-file HTML for hook server bun run build:review # Code review editor -bun run build:opencode # OpenCode plugin (copies HTML from hook + review) +bun run build:checklist # QA checklist editor +bun run build:opencode # OpenCode plugin (copies HTML from hook + review + checklist) bun run build:portal # Static build for share.plannotator.ai bun run build:marketing # Static build for plannotator.ai bun run build:vscode # VS Code extension bundle diff --git a/apps/checklist/index.html b/apps/checklist/index.html new file mode 100644 index 00000000..fb20bc68 --- /dev/null +++ b/apps/checklist/index.html @@ -0,0 +1,18 @@ + + + + + + QA Checklist + + + + + + + + +
+ + + diff --git a/apps/checklist/index.tsx b/apps/checklist/index.tsx new file mode 100644 index 00000000..3074f9e1 --- /dev/null +++ b/apps/checklist/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from '@plannotator/checklist-editor'; +import '@plannotator/checklist-editor/styles'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); diff --git a/apps/checklist/package.json b/apps/checklist/package.json new file mode 100644 index 00000000..111e729c --- /dev/null +++ b/apps/checklist/package.json @@ -0,0 +1,27 @@ +{ + "name": "@plannotator/checklist", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@plannotator/checklist-editor": "workspace:*", + "@plannotator/server": "workspace:*", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18" + }, + "devDependencies": { + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + "@types/node": "^22.14.0" + } +} diff --git a/apps/checklist/vite.config.ts b/apps/checklist/vite.config.ts new file mode 100644 index 00000000..9382fb6b --- /dev/null +++ b/apps/checklist/vite.config.ts @@ -0,0 +1,37 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { viteSingleFile } from 'vite-plugin-singlefile'; +import tailwindcss from '@tailwindcss/vite'; +import pkg from '../../package.json'; + +export default defineConfig({ + server: { + port: 3002, + host: '0.0.0.0', + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, + plugins: [react(), tailwindcss(), viteSingleFile()], + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'), + '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'), + '@plannotator/checklist-editor/styles': path.resolve(__dirname, '../../packages/checklist-editor/index.css'), + '@plannotator/checklist-editor': path.resolve(__dirname, '../../packages/checklist-editor/App.tsx'), + } + }, + build: { + target: 'esnext', + assetsInlineLimit: 100000000, + chunkSizeWarningLimit: 100000000, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +}); diff --git a/apps/codex/README.md b/apps/codex/README.md index e9defa08..7d155da1 100644 --- a/apps/codex/README.md +++ b/apps/codex/README.md @@ -1,6 +1,14 @@ # Plannotator for Codex -Code review and markdown annotation are supported today. Plan mode is not yet supported — it requires hooks to intercept the agent's plan submission, which Codex does not currently expose. +## Capabilities + +| Feature | How to use | +|---------|------------| +| **Code Review** | `!plannotator review` — Visual diff annotation UI | +| **Markdown Annotation** | `!plannotator annotate path/to/file.md` — Annotate any markdown file | +| **QA Checklist** | Skill: `checklist` — Generate and verify QA checklists interactively | + +Plan mode is not yet supported — it requires hooks to intercept the agent's plan submission, which Codex does not currently expose. ## Install @@ -16,26 +24,28 @@ curl -fsSL https://plannotator.ai/install.sh | bash irm https://plannotator.ai/install.ps1 | iex ``` +This installs the `plannotator` CLI and places skills in `~/.agents/skills/` where Codex discovers them on startup. To install skills only: `npx skills add backnotprop/plannotator`. + ## Usage ### Code Review -Run `!plannotator review` to open the code review UI for your current changes: - ``` !plannotator review ``` -This captures your git diff, opens a browser with the review UI, and waits for your feedback. When you submit annotations, the feedback is printed to stdout. +Captures your git diff, opens a browser with the review UI, and waits for your feedback. Annotations are sent back to the agent as structured feedback. ### Annotate Markdown -Run `!plannotator annotate` to annotate any markdown file: - ``` !plannotator annotate path/to/file.md ``` +### QA Checklist + +The `checklist` skill is invoked by the agent when you ask it to verify changes, create acceptance criteria, or run QA checks. It generates a structured checklist and opens an interactive UI for pass/fail/skip verification. + ## Environment Variables | Variable | Description | diff --git a/apps/factory/README.md b/apps/factory/README.md new file mode 100644 index 00000000..5a1b355f --- /dev/null +++ b/apps/factory/README.md @@ -0,0 +1,19 @@ +# Plannotator for Factory + +## Install + +**macOS / Linux / WSL:** + +```bash +curl -fsSL https://plannotator.ai/install.sh | bash +``` + +## Skills + +Skills are installed automatically by the install script above. + +Alternatively, install skills only via `npx skills add backnotprop/plannotator`. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | diff --git a/apps/hook/README.md b/apps/hook/README.md index 96c747a8..1f6e95b3 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -1,6 +1,13 @@ # Plannotator Claude Code Plugin -This directory contains the Claude Code plugin configuration for Plannotator. +## Capabilities + +| Feature | How to use | +|---------|------------| +| **Plan Review** | Automatic — intercepts `ExitPlanMode` via hooks | +| **Code Review** | `/plannotator-review` — Visual diff annotation UI | +| **Markdown Annotation** | `/plannotator-annotate path/to/file.md` — Annotate any markdown file | +| **QA Checklist** | `/plannotator-checklist` or skill: `checklist` — Generate and verify QA checklists interactively | ## Prerequisites @@ -23,7 +30,7 @@ curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && d --- -[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration) +[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration) --- @@ -36,7 +43,7 @@ In Claude Code: /plugin install plannotator@plannotator ``` -**Important:** Restart Claude Code after installing the plugin for the hooks to take effect. +**Important:** Restart Claude Code after installing the plugin for the hooks to take effect. Skills are included with the plugin install. ## Manual Installation (Hooks) diff --git a/apps/hook/commands/plannotator-checklist.md b/apps/hook/commands/plannotator-checklist.md new file mode 100644 index 00000000..d46ddd63 --- /dev/null +++ b/apps/hook/commands/plannotator-checklist.md @@ -0,0 +1,12 @@ +--- +description: Open interactive QA checklist verification UI +allowed-tools: Bash(plannotator:*) +--- + +## QA Checklist Results + +!`plannotator checklist '$ARGUMENTS'` + +## Your task + +Address the checklist results above. Items marked FAILED need fixes — read the developer's notes and act on them. Items with questions need answers. Items marked SKIPPED were not verified — acknowledge the reason. diff --git a/apps/hook/package.json b/apps/hook/package.json index ccf9c71d..2a16e17d 100644 --- a/apps/hook/package.json +++ b/apps/hook/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html", + "build": "bun run --cwd ../review build && bun run --cwd ../checklist build && vite build && cp dist/index.html dist/redline.html && cp ../review/dist/index.html dist/review.html && cp ../checklist/dist/index.html dist/checklist.html", "serve": "bun run server/index.ts" }, "dependencies": { diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 98b87db0..584e5566 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1,7 +1,7 @@ /** * Plannotator CLI for Claude Code * - * Supports four modes: + * Supports five modes: * * 1. Plan Review (default, no args): * - Spawned by ExitPlanMode hook @@ -18,7 +18,12 @@ * - Opens any markdown file in the annotation UI * - Outputs structured feedback to stdout * - * 4. Sessions (`plannotator sessions`): + * 4. Checklist (`plannotator checklist ''`): + * - Triggered by /plannotator-checklist skill or slash command + * - Opens QA checklist UI for manual verification + * - Outputs per-item results as markdown to stdout + * + * 5. Sessions (`plannotator sessions`): * - Lists active Plannotator server sessions * - `--open [N]` reopens a session in the browser * - `--clean` removes stale session files @@ -43,6 +48,11 @@ import { startAnnotateServer, handleAnnotateServerReady, } from "@plannotator/server/annotate"; +import { + startChecklistServer, + handleChecklistServerReady, + validateChecklist, +} from "@plannotator/server/checklist"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; @@ -60,6 +70,10 @@ const planHtmlContent = planHtml as unknown as string; import reviewHtml from "../dist/review.html" with { type: "text" }; const reviewHtmlContent = reviewHtml as unknown as string; +// @ts-ignore - Bun import attribute for text +import checklistHtml from "../dist/checklist.html" with { type: "text" }; +const checklistHtmlContent = checklistHtml as unknown as string; + // Check for subcommand const args = process.argv.slice(2); @@ -271,6 +285,105 @@ if (args[0] === "sessions") { console.log(result.feedback || "No feedback provided."); process.exit(0); +} else if (args[0] === "checklist") { + // ============================================ + // QA CHECKLIST MODE + // ============================================ + + // JSON can come from CLI argument or --file flag + let jsonInput = args[1]; + + const fileIdx = args.indexOf("--file"); + if (fileIdx !== -1 && args[fileIdx + 1]) { + const filePath = args[fileIdx + 1]; + try { + jsonInput = await Bun.file(filePath).text(); + } catch { + console.error(`Failed to read checklist file: ${filePath}`); + process.exit(1); + } + } + + if (!jsonInput) { + console.error("Usage: plannotator checklist '' or plannotator checklist --file "); + process.exit(1); + } + + // Parse and validate JSON + let checklistData: unknown; + try { + checklistData = JSON.parse(jsonInput); + } catch { + console.error("Invalid JSON. Ensure the checklist is valid JSON."); + process.exit(1); + } + + // Unwrap saved checklist files (which have { checklist, results, ... }) + let initialResults: import("@plannotator/shared/checklist-types").ChecklistItemResult[] | undefined; + let initialGlobalNotes: string[] | undefined; + if (checklistData && typeof checklistData === "object" && "checklist" in checklistData) { + const saved = checklistData as Record; + initialResults = saved.results as typeof initialResults; + initialGlobalNotes = saved.globalNotes as typeof initialGlobalNotes; + checklistData = saved.checklist; + } + + const errors = validateChecklist(checklistData); + if (errors.length > 0) { + console.error("Checklist validation failed:"); + for (const err of errors) { + console.error(` - ${err}`); + } + console.error("\nSee the checklist skill for the expected JSON schema."); + process.exit(1); + } + + const checklist = checklistData as import("@plannotator/shared/checklist-types").Checklist; + const checklistProject = (await detectProjectName()) ?? "_unknown"; + + const server = await startChecklistServer({ + checklist, + origin: "claude-code", + project: checklistProject, + htmlContent: checklistHtmlContent, + initialResults, + initialGlobalNotes, + onReady: async (url, isRemote, port) => { + handleChecklistServerReady(url, isRemote, port); + + if (isRemote && sharingEnabled) { + await writeRemoteShareLink(JSON.stringify(checklist), shareBaseUrl, "review the checklist", "checklist only").catch(() => {}); + } + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "checklist", + project: checklistProject, + startedAt: new Date().toISOString(), + label: `checklist-${checklistProject}`, + }); + + // Wait for user to complete the checklist + const result = await server.waitForDecision(); + + // Give browser time to receive response and update UI + await Bun.sleep(1500); + + // Cleanup + server.stop(); + + // Output feedback (captured by slash command) + let output = result.feedback || "No checklist results provided."; + if (result.savedTo) { + output += `\n\nChecklist results saved to: ${result.savedTo}\nReopen with: plannotator checklist --file ${result.savedTo}`; + } + console.log(output); + process.exit(0); + } else { // ============================================ // PLAN REVIEW MODE (default) diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index f85e5362..ab480fda 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -43,6 +43,14 @@ Restart OpenCode. The `submit_plan` tool is now available. 3. **Approve** → Agent proceeds with implementation 4. **Request changes** → Annotations sent back as structured feedback +## Skills + +Skills are included with the plugin install. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | + ## Features - **Visual annotations**: Select text, choose an action, see feedback in the sidebar diff --git a/apps/opencode-plugin/commands/plannotator-checklist.md b/apps/opencode-plugin/commands/plannotator-checklist.md new file mode 100644 index 00000000..fec9d66b --- /dev/null +++ b/apps/opencode-plugin/commands/plannotator-checklist.md @@ -0,0 +1,6 @@ +--- +description: Open interactive QA checklist verification UI +--- + +The Plannotator Checklist UI has been triggered. Opening the checklist UI... +Acknowledge "Opening checklist UI..." and wait for the user's feedback. diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 949caadf..8cd59f26 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -26,9 +26,15 @@ import { startAnnotateServer, handleAnnotateServerReady, } from "@plannotator/server/annotate"; +import { + startChecklistServer, + handleChecklistServerReady, + validateChecklist, +} from "@plannotator/server/checklist"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; +import { detectProjectName } from "@plannotator/server/project"; // @ts-ignore - Bun import attribute for text import indexHtml from "./plannotator.html" with { type: "text" }; @@ -38,6 +44,10 @@ const htmlContent = indexHtml as unknown as string; import reviewHtml from "./review-editor.html" with { type: "text" }; const reviewHtmlContent = reviewHtml as unknown as string; +// @ts-ignore - Bun import attribute for text +import checklistHtml from "./checklist.html" with { type: "text" }; +const checklistHtmlContent = checklistHtml as unknown as string; + const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours export const PlannotatorPlugin: Plugin = async (ctx) => { @@ -327,6 +337,90 @@ Do NOT proceed with implementation until your plan is approved. } } } + + // Handle /plannotator-checklist command + const isChecklistCommand = commandName === "plannotator-checklist"; + + if (isCommandEvent && isChecklistCommand) { + // @ts-ignore - Event properties contain arguments + const rawArgs = event.properties?.arguments || event.arguments || ""; + + if (!rawArgs) { + ctx.client.app.log({ + level: "error", + message: "Usage: /plannotator-checklist ", + }); + return; + } + + // Parse the JSON argument + let checklistData: unknown; + try { + checklistData = JSON.parse(rawArgs); + } catch { + ctx.client.app.log({ + level: "error", + message: "Invalid JSON argument. Expected a checklist JSON object.", + }); + return; + } + + // Validate the checklist structure + const errors = validateChecklist(checklistData); + if (errors.length > 0) { + ctx.client.app.log({ + level: "error", + message: `Invalid checklist:\n${errors.join("\n")}`, + }); + return; + } + + ctx.client.app.log({ + level: "info", + message: "Opening checklist UI...", + }); + + const checklistProject = (await detectProjectName()) ?? "_unknown"; + const server = await startChecklistServer({ + checklist: checklistData as import("@plannotator/shared/checklist-types").Checklist, + origin: "opencode", + project: checklistProject, + htmlContent: checklistHtmlContent, + onReady: handleChecklistServerReady, + }); + + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + // Send feedback back to the session if provided + if (result.feedback) { + // @ts-ignore - Event properties contain sessionID for command.executed events + const sessionId = event.properties?.sessionID; + + if (sessionId) { + const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled'; + const targetAgent = result.agentSwitch || 'build'; + + try { + await ctx.client.session.prompt({ + path: { id: sessionId }, + body: { + ...(shouldSwitchAgent && { agent: targetAgent }), + parts: [ + { + type: "text", + text: `${result.feedback}\n\nPlease address the checklist results above.`, + }, + ], + }, + }); + } catch { + // Session may not be available + } + } + } + } }, tool: { diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json index f5719cd7..a415d184 100644 --- a/apps/opencode-plugin/package.json +++ b/apps/opencode-plugin/package.json @@ -27,10 +27,11 @@ "commands", "README.md", "plannotator.html", - "review-editor.html" + "review-editor.html", + "checklist.html" ], "scripts": { - "build": "cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", + "build": "cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && cp ../hook/dist/checklist.html ./checklist.html && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", "postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command/ 2>/dev/null || true", "prepublishOnly": "bun run build" }, diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index b0ab7771..c38b7c46 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -84,6 +84,7 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr | `/plannotator-status` | Show current phase, plan file, and progress | | `/plannotator-review` | Open code review UI for current changes | | `/plannotator-annotate ` | Open markdown file in annotation UI | +| `/plannotator-checklist ` | Open QA checklist verification UI | ## Flags @@ -98,6 +99,14 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr |----------|-------------| | `Ctrl+Alt+P` | Toggle plan mode | +## Skills + +Skills are included with the extension install. + +| Skill | Description | +|-------|-------------| +| `checklist` | Generate a QA checklist for manual verification of code changes | + ## How it works The extension manages a state machine: **idle** → **planning** → **executing** → **idle**. diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 1c8e724b..5c4d6126 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -31,6 +31,8 @@ import { startPlanReviewServer, startReviewServer, startAnnotateServer, + startChecklistServer, + validateChecklist, getGitContext, runGitDiff, openBrowser, @@ -40,6 +42,7 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); let planHtmlContent = ""; let reviewHtmlContent = ""; +let checklistHtmlContent = ""; try { planHtmlContent = readFileSync(resolve(__dirname, "plannotator.html"), "utf-8"); } catch { @@ -50,6 +53,11 @@ try { } catch { // HTML not built yet — review feature will be unavailable } +try { + checklistHtmlContent = readFileSync(resolve(__dirname, "checklist.html"), "utf-8"); +} catch { + // HTML not built yet — checklist feature will be unavailable +} /** Extra tools to ensure are available during planning (on top of whatever is already active). */ const PLANNING_EXTRA_TOOLS = ["grep", "find", "ls", "exit_plan_mode"]; @@ -307,6 +315,57 @@ export default function plannotator(pi: ExtensionAPI): void { }, }); + pi.registerCommand("plannotator-checklist", { + description: "Open interactive QA checklist verification UI", + handler: async (args, ctx) => { + const rawArgs = args?.trim(); + if (!rawArgs) { + ctx.ui.notify("Usage: /plannotator-checklist ", "error"); + return; + } + if (!checklistHtmlContent) { + ctx.ui.notify("Checklist UI not available. Run 'bun run build' in the pi-extension directory.", "error"); + return; + } + + // Parse JSON argument + let checklistData: unknown; + try { + checklistData = JSON.parse(rawArgs); + } catch { + ctx.ui.notify("Invalid JSON argument. Expected a checklist JSON object.", "error"); + return; + } + + // Validate the checklist structure + const errors = validateChecklist(checklistData); + if (errors.length > 0) { + ctx.ui.notify(`Invalid checklist:\n${errors.join("\n")}`, "error"); + return; + } + + ctx.ui.notify("Opening checklist UI...", "info"); + + const server = startChecklistServer({ + checklist: checklistData as { title: string; summary: string; items: unknown[] }, + origin: "pi", + htmlContent: checklistHtmlContent, + }); + + openBrowser(server.url); + + const result = await server.waitForDecision(); + await new Promise((r) => setTimeout(r, 1500)); + server.stop(); + + if (result.feedback) { + pi.sendUserMessage(`${result.feedback}\n\nPlease address the checklist results above.`); + } else { + ctx.ui.notify("Checklist closed (no feedback).", "info"); + } + }, + }); + pi.registerShortcut(Key.ctrlAlt("p"), { description: "Toggle plannotator", handler: async (ctx) => togglePlanMode(ctx), diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 00975922..aee00533 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -24,10 +24,11 @@ "utils.ts", "README.md", "plannotator.html", - "review-editor.html" + "review-editor.html", + "checklist.html" ], "scripts": { - "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html", + "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && cp ../hook/dist/checklist.html checklist.html", "prepublishOnly": "cd ../.. && bun run build:pi" }, "peerDependencies": { diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index 75795a2b..092a6078 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -9,8 +9,9 @@ import { createServer, type IncomingMessage, type Server } from "node:http"; import { execSync } from "node:child_process"; import os from "node:os"; -import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { join, basename } from "node:path"; +import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs"; +import { join, basename, resolve, extname } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; // ── Helpers ────────────────────────────────────────────────────────────── @@ -44,6 +45,171 @@ function listenOnRandomPort(server: Server): number { return addr.port; } +// ── Image Validation (duplicated from packages/server/image.ts) ───────── + +const ALLOWED_IMAGE_EXTENSIONS = new Set([ + "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "tiff", "tif", "avif", +]); +const UPLOAD_DIR = "/tmp/plannotator"; + +function getExtension(filePath: string): string { + const lastDot = filePath.lastIndexOf("."); + if (lastDot === -1) return ""; + return filePath.slice(lastDot + 1).toLowerCase(); +} + +function validateImagePath(rawPath: string): { valid: boolean; resolved: string; error?: string } { + const resolved = resolve(rawPath); + if (!ALLOWED_IMAGE_EXTENSIONS.has(getExtension(resolved))) { + return { valid: false, resolved, error: "Path does not point to a supported image file" }; + } + return { valid: true, resolved }; +} + +function validateUploadExtension(fileName: string): { valid: boolean; ext: string; error?: string } { + const ext = getExtension(fileName) || "png"; + if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { + return { valid: false, ext, error: `File extension ".${ext}" is not a supported image type` }; + } + return { valid: true, ext }; +} + +const MIME_MAP: Record = { + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", + webp: "image/webp", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", + tiff: "image/tiff", tif: "image/tiff", avif: "image/avif", +}; + +function handlePiImage(req: IncomingMessage, res: import("node:http").ServerResponse): void { + const url = new URL(req.url!, "http://localhost"); + const imagePath = url.searchParams.get("path"); + if (!imagePath) { json(res, { error: "Missing path parameter" }, 400); return; } + const validation = validateImagePath(imagePath); + if (!validation.valid) { json(res, { error: validation.error }, 403); return; } + try { + if (!existsSync(validation.resolved)) { json(res, { error: "File not found" }, 404); return; } + const data = readFileSync(validation.resolved); + const mime = MIME_MAP[getExtension(validation.resolved)] || "application/octet-stream"; + res.writeHead(200, { "Content-Type": mime, "Content-Length": data.length }); + res.end(data); + } catch { + json(res, { error: "Failed to read file" }, 500); + } +} + +// ── Image Upload (Node multipart parser) ──────────────────────────────── + +function parseMultipartFile(req: IncomingMessage): Promise<{ filename: string; data: Buffer } | null> { + return new Promise((resolve) => { + const contentType = req.headers["content-type"] || ""; + const boundaryMatch = contentType.match(/boundary=(.+)/); + if (!boundaryMatch) { resolve(null); return; } + const boundary = boundaryMatch[1]; + + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)); + req.on("end", () => { + try { + const body = Buffer.concat(chunks); + const boundaryBuf = Buffer.from(`--${boundary}`); + // Find the file part + const bodyStr = body.toString("latin1"); + const parts = bodyStr.split(`--${boundary}`); + for (const part of parts) { + if (!part.includes('name="file"')) continue; + const filenameMatch = part.match(/filename="([^"]+)"/); + if (!filenameMatch) continue; + // Find blank line separating headers from content + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd === -1) continue; + // Extract binary data (use Buffer offset, not string) + const partStart = bodyStr.indexOf(part); + const dataStart = partStart + headerEnd + 4; + // Find the closing boundary + let dataEnd = bodyStr.indexOf(`\r\n--${boundary}`, dataStart); + if (dataEnd === -1) dataEnd = body.length; + resolve({ filename: filenameMatch[1], data: body.subarray(dataStart, dataEnd) }); + return; + } + resolve(null); + } catch { + resolve(null); + } + }); + }); +} + +async function handlePiUpload(req: IncomingMessage, res: import("node:http").ServerResponse): Promise { + try { + const file = await parseMultipartFile(req); + if (!file) { json(res, { error: "No file provided" }, 400); return; } + const extResult = validateUploadExtension(file.filename); + if (!extResult.valid) { json(res, { error: extResult.error }, 400); return; } + mkdirSync(UPLOAD_DIR, { recursive: true }); + const tempPath = `${UPLOAD_DIR}/${randomUUID()}.${extResult.ext}`; + writeFileSync(tempPath, file.data); + json(res, { path: tempPath, originalName: file.filename }); + } catch (err) { + const message = err instanceof Error ? err.message : "Upload failed"; + json(res, { error: message }, 500); + } +} + +// ── Draft Persistence (duplicated from packages/server/draft.ts) ──────── + +function getDraftDir(): string { + const dir = join(os.homedir(), ".plannotator", "drafts"); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function contentHash(content: string): string { + return createHash("sha256").update(content).digest("hex").slice(0, 16); +} + +function saveDraft(key: string, data: object): void { + writeFileSync(join(getDraftDir(), `${key}.json`), JSON.stringify(data), "utf-8"); +} + +function loadDraft(key: string): object | null { + const filePath = join(getDraftDir(), `${key}.json`); + try { + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, "utf-8")); + } catch { + return null; + } +} + +function deleteDraft(key: string): void { + const filePath = join(getDraftDir(), `${key}.json`); + try { + if (existsSync(filePath)) unlinkSync(filePath); + } catch { + // Ignore delete failures + } +} + +async function handlePiDraft(req: IncomingMessage, res: import("node:http").ServerResponse, draftKey: string): Promise { + if (req.method === "POST") { + try { + const body = await parseBody(req); + saveDraft(draftKey, body); + json(res, { ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save draft"; + json(res, { error: message }, 500); + } + } else if (req.method === "DELETE") { + deleteDraft(draftKey); + json(res, { ok: true }); + } else { + const draft = loadDraft(draftKey); + if (!draft) { json(res, { found: false }, 404); return; } + json(res, draft); + } +} + /** * Open URL in system browser (Node-compatible, no Bun $ dependency). * Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts. @@ -277,6 +443,8 @@ export function startPlanReviewServer(options: { project, }; + const draftKey = contentHash(options.plan); + let resolveDecision!: (result: { approved: boolean; feedback?: string }) => void; const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>((r) => { resolveDecision = r; @@ -316,6 +484,12 @@ export function startPlanReviewServer(options: { const body = await parseBody(req); resolveDecision({ approved: false, feedback: (body.feedback as string) || "Plan rejected" }); json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); } else { html(res, options.htmlContent); } @@ -414,6 +588,7 @@ export function startReviewServer(options: { let currentPatch = options.rawPatch; let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; + const draftKey = contentHash(currentPatch || ""); let resolveDecision!: (result: { feedback: string }) => void; const decisionPromise = new Promise<{ feedback: string }>((r) => { @@ -448,6 +623,12 @@ export function startReviewServer(options: { const body = await parseBody(req); resolveDecision({ feedback: (body.feedback as string) || "" }); json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); } else { html(res, options.htmlContent); } @@ -478,6 +659,8 @@ export function startAnnotateServer(options: { htmlContent: string; origin?: string; }): AnnotateServerResult { + const draftKey = contentHash(options.markdown); + let resolveDecision!: (result: { feedback: string }) => void; const decisionPromise = new Promise<{ feedback: string }>((r) => { resolveDecision = r; @@ -497,6 +680,466 @@ export function startAnnotateServer(options: { const body = await parseBody(req); resolveDecision({ feedback: (body.feedback as string) || "" }); json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); + } else { + html(res, options.htmlContent); + } + }); + + const port = listenOnRandomPort(server); + + return { + port, + url: `http://localhost:${port}`, + waitForDecision: () => decisionPromise, + stop: () => server.close(), + }; +} + +// ── Checklist Validation (Node-compatible, duplicated from packages/server) ── + +/** + * Validate a checklist JSON object. + * Returns an array of error messages (empty = valid). + */ +export function validateChecklist(data: unknown): string[] { + const errors: string[] = []; + + if (!data || typeof data !== "object") { + errors.push("Checklist must be a JSON object."); + return errors; + } + + const obj = data as Record; + + if (typeof obj.title !== "string" || !obj.title.trim()) { + errors.push('Missing or empty "title" (string).'); + } + + if (typeof obj.summary !== "string" || !obj.summary.trim()) { + errors.push('Missing or empty "summary" (string).'); + } + + // Validate optional PR field + if (obj.pr !== undefined) { + if (typeof obj.pr !== "object" || obj.pr === null) { + errors.push('"pr" must be an object if provided.'); + } else { + const pr = obj.pr as Record; + if (typeof pr.number !== "number") { + errors.push('pr.number must be a number.'); + } + if (typeof pr.url !== "string" || !pr.url) { + errors.push('pr.url must be a non-empty string.'); + } + const validProviders = ["github", "gitlab", "azure-devops"]; + if (!validProviders.includes(pr.provider as string)) { + errors.push(`pr.provider must be one of: ${validProviders.join(", ")}.`); + } + } + } + + // Validate optional fileDiffs field (accepts number or {hunks, lines, status}) + if (obj.fileDiffs !== undefined) { + if (typeof obj.fileDiffs !== "object" || obj.fileDiffs === null || Array.isArray(obj.fileDiffs)) { + errors.push('"fileDiffs" must be an object mapping file paths to diff info.'); + } else { + for (const [key, val] of Object.entries(obj.fileDiffs as Record)) { + if (typeof val === "number") { + if (val < 1 || !Number.isInteger(val)) { + errors.push(`fileDiffs["${key}"] must be a positive integer.`); + } + } else if (typeof val === "object" && val !== null) { + const info = val as Record; + if (typeof info.hunks !== "number" || info.hunks < 1 || !Number.isInteger(info.hunks)) { + errors.push(`fileDiffs["${key}"].hunks must be a positive integer.`); + } + if (typeof info.lines !== "number" || info.lines < 1 || !Number.isInteger(info.lines)) { + errors.push(`fileDiffs["${key}"].lines must be a positive integer.`); + } + if (info.status !== "new" && info.status !== "modified") { + errors.push(`fileDiffs["${key}"].status must be "new" or "modified".`); + } + } else { + errors.push(`fileDiffs["${key}"] must be a positive integer or {hunks, lines, status} object.`); + } + } + } + } + + if (!Array.isArray(obj.items)) { + errors.push('"items" must be an array.'); + return errors; + } + + if (obj.items.length === 0) { + errors.push('"items" array is empty — include at least one checklist item.'); + } + + for (let i = 0; i < obj.items.length; i++) { + const item = obj.items[i] as Record; + const prefix = `items[${i}]`; + + if (typeof item.id !== "string" || !item.id.trim()) { + errors.push(`${prefix}: missing "id" (string, e.g. "func-1").`); + } + + if (typeof item.category !== "string" || !item.category.trim()) { + errors.push(`${prefix}: missing "category" (string, e.g. "functional").`); + } + + if (typeof item.check !== "string" || !item.check.trim()) { + errors.push(`${prefix}: missing "check" (imperative verb phrase).`); + } + + if (typeof item.description !== "string" || !item.description.trim()) { + errors.push(`${prefix}: missing "description" (markdown narrative).`); + } + + if (!Array.isArray(item.steps) || item.steps.length === 0) { + errors.push(`${prefix}: "steps" must be a non-empty array of strings.`); + } + + if (typeof item.reason !== "string" || !item.reason.trim()) { + errors.push(`${prefix}: missing "reason" (why manual verification is needed).`); + } + + // Validate optional diffMap + if (item.diffMap !== undefined) { + if (typeof item.diffMap !== "object" || item.diffMap === null || Array.isArray(item.diffMap)) { + errors.push(`${prefix}: "diffMap" must be an object mapping file paths to hunk counts.`); + } else { + for (const [key, val] of Object.entries(item.diffMap as Record)) { + if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { + errors.push(`${prefix}: diffMap["${key}"] must be a positive integer.`); + } + } + } + } + } + + return errors; +} + +// ── Checklist Feedback Formatting (Node-compatible) ── + +interface ChecklistItemType { + id: string; + category: string; + check: string; + description: string; + steps: string[]; + reason: string; + files?: string[]; + critical?: boolean; +} + +interface ChecklistType { + title: string; + summary: string; + items: ChecklistItemType[]; + pr?: { number: number; url: string; provider: string; title?: string; branch?: string }; +} + +interface ChecklistItemResultType { + id: string; + status: "passed" | "failed" | "skipped" | "pending"; + notes?: string[] | string; + images?: { path: string; name: string }[]; +} + +function formatChecklistFeedback( + checklist: ChecklistType, + results: ChecklistItemResultType[], + globalNotes?: string[] | string, + automations?: { postToPR?: boolean; approveIfAllPass?: boolean }, +): string { + const resultMap = new Map(results.map((r) => [r.id, r])); + + let passed = 0; + let failed = 0; + let skipped = 0; + let pending = 0; + + for (const item of checklist.items) { + const result = resultMap.get(item.id); + if (result?.status === "passed") passed++; + else if (result?.status === "failed") failed++; + else if (result?.status === "skipped") skipped++; + else pending++; + } + + const lines: string[] = []; + + lines.push("# QA Checklist Results"); + lines.push(""); + lines.push("## Summary"); + lines.push(`- **Title**: ${checklist.title}`); + lines.push(`- **Total**: ${checklist.items.length} items`); + lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}${pending > 0 ? ` | **Pending**: ${pending}` : ""}`); + lines.push(""); + + const failedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "failed" + ); + if (failedItems.length > 0) { + lines.push("## Failed Items"); + lines.push(""); + for (const item of failedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: FAILED`); + lines.push(`**Category**: ${item.category}`); + if (item.critical) lines.push(`**Critical**: yes`); + if (item.files?.length) lines.push(`**Files**: ${item.files.join(", ")}`); + const itemNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of itemNotes) { + lines.push(`**Developer notes**: ${note}`); + } + if (result.images?.length) { + for (const img of result.images) { + lines.push(`[${img.name}] ${img.path}`); + } + } + lines.push(""); + } + } + + const skippedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "skipped" + ); + if (skippedItems.length > 0) { + lines.push("## Skipped Items"); + lines.push(""); + for (const item of skippedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: SKIPPED`); + const skipNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of skipNotes) { + lines.push(`**Reason**: ${note}`); + } + lines.push(""); + } + } + + const passedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "passed" + ); + if (passedItems.length > 0) { + lines.push("## Passed Items"); + lines.push(""); + for (const item of passedItems) { + const result = resultMap.get(item.id); + const passNotes = result ? (Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []) : []; + const notesSuffix = passNotes.length > 0 ? ` — ${passNotes.join("; ")}` : ""; + lines.push(`- [PASS] ${item.id}: ${item.check}${notesSuffix}`); + } + lines.push(""); + } + + const notes = Array.isArray(globalNotes) ? globalNotes : globalNotes ? [globalNotes] : []; + if (notes.length > 0) { + lines.push("## Developer Comments"); + lines.push(""); + for (const note of notes) { + lines.push(`> ${note.trim().replace(/\n/g, "\n> ")}`); + lines.push(""); + } + } + + // Automation instructions (matches packages/server/checklist.ts) + if (automations && checklist.pr) { + const pr = checklist.pr; + const hasAutomations = automations.postToPR || automations.approveIfAllPass; + + if (hasAutomations) { + lines.push("## Automations"); + lines.push(""); + + if (automations.postToPR) { + if (pr.provider === "github") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`gh\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr comment ${pr.number} --body ''`); + lines.push("```"); + lines.push("If `gh` is not available, inform the developer to install the GitHub CLI (`brew install gh` or https://cli.github.com)."); + } else if (pr.provider === "gitlab") { + lines.push("**Post results to MR**: The developer requested that you post these checklist results as a comment on the merge request."); + lines.push(`Use the \`glab\` CLI to post a note to MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr note ${pr.number} --message ''`); + lines.push("```"); + lines.push("If `glab` is not available, inform the developer to install the GitLab CLI (`brew install glab` or https://gitlab.com/gitlab-org/cli)."); + } else if (pr.provider === "azure-devops") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`az\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr update --id ${pr.number} --description ''`); + lines.push("```"); + lines.push("If `az` is not available, inform the developer to install Azure CLI (`brew install azure-cli` or https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)."); + } + lines.push(""); + } + + if (automations.approveIfAllPass && failed === 0 && skipped === 0 && pending === 0) { + if (pr.provider === "github") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`gh\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr review ${pr.number} --approve --body 'QA checklist passed (${passed}/${passed} items)'`); + lines.push("```"); + } else if (pr.provider === "gitlab") { + lines.push("**Approve MR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`glab\` CLI to approve MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr approve ${pr.number}`); + lines.push("```"); + } else if (pr.provider === "azure-devops") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`az\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr set-vote --id ${pr.number} --vote approve`); + lines.push("```"); + } + lines.push(""); + } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0 || pending > 0)) { + lines.push("**Approve PR**: Skipped — not all items passed. Fix the failed/skipped items and re-run the checklist."); + lines.push(""); + } + } + } + + return lines.join("\n"); +} + +// ── Checklist Storage (Node-compatible, duplicated from packages/server) ── + +/** + * Save a completed checklist (original + results) to disk. + * Returns the path to the saved file. + * + * Structure: ~/.plannotator/checklists/{project}/{slug}.json + */ +function saveChecklistResults( + checklist: ChecklistType, + results: ChecklistItemResultType[], + globalNotes: string[] | string | undefined, + project: string, +): string { + const dir = join(os.homedir(), ".plannotator", "checklists", project); + mkdirSync(dir, { recursive: true }); + + const date = new Date().toISOString().split("T")[0]; + const slug = checklist.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + const timestamp = Date.now(); + const filename = `${slug}-${date}-${timestamp}.json`; + const filePath = join(dir, filename); + + writeFileSync(filePath, JSON.stringify({ + checklist, + results, + globalNotes, + submittedAt: new Date().toISOString(), + project, + }, null, 2)); + + return filePath; +} + +// ── Checklist Server ───────────────────────────────────────────────────── + +export interface ChecklistServerResult { + port: number; + url: string; + waitForDecision: () => Promise<{ feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }>; + stop: () => void; +} + +export function startChecklistServer(options: { + checklist: ChecklistType; + htmlContent: string; + origin?: string; + project?: string; + initialResults?: ChecklistItemResultType[]; + initialGlobalNotes?: string[]; + onReady?: (url: string, port: number) => void; +}): ChecklistServerResult { + const project = options.project || detectProjectName(); + const draftKey = contentHash(JSON.stringify(options.checklist)); + + let resolveDecision!: (result: { feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }) => void; + const decisionPromise = new Promise<{ feedback: string; results: ChecklistItemResultType[]; savedTo?: string; agentSwitch?: string }>((r) => { + resolveDecision = r; + }); + + const server = createServer(async (req, res) => { + const url = new URL(req.url!, `http://localhost`); + + if (url.pathname === "/api/checklist" && req.method === "GET") { + json(res, { + checklist: options.checklist, + origin: options.origin ?? "pi", + mode: "checklist", + ...(options.initialResults && { initialResults: options.initialResults }), + ...(options.initialGlobalNotes && { initialGlobalNotes: options.initialGlobalNotes }), + }); + } else if (url.pathname === "/api/feedback" && req.method === "POST") { + const body = await parseBody(req) as { + results?: ChecklistItemResultType[]; + globalNotes?: string[] | string; + automations?: { postToPR?: boolean; approveIfAllPass?: boolean }; + agentSwitch?: string; + }; + + deleteDraft(draftKey); + + const results = body.results || []; + + // Save to disk + let savedTo: string | undefined; + try { + savedTo = saveChecklistResults( + options.checklist, + results, + body.globalNotes, + project, + ); + } catch { + // Non-fatal — feedback still goes to agent + } + + const feedback = formatChecklistFeedback( + options.checklist, + results, + body.globalNotes, + body.automations, + ); + + resolveDecision({ + feedback, + results, + savedTo, + agentSwitch: body.agentSwitch, + }); + + json(res, { ok: true }); + } else if (url.pathname === "/api/image" && req.method === "GET") { + handlePiImage(req, res); + } else if (url.pathname === "/api/upload" && req.method === "POST") { + await handlePiUpload(req, res); + } else if (url.pathname === "/api/draft") { + await handlePiDraft(req, res, draftKey); } else { html(res, options.htmlContent); } @@ -504,6 +1147,10 @@ export function startAnnotateServer(options: { const port = listenOnRandomPort(server); + if (options.onReady) { + options.onReady(`http://localhost:${port}`, port); + } + return { port, url: `http://localhost:${port}`, diff --git a/bun.lock b/bun.lock index 858d55ef..8bb62f08 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,27 @@ "diff": "^8.0.3", }, }, + "apps/checklist": { + "name": "@plannotator/checklist", + "version": "0.0.1", + "dependencies": { + "@plannotator/checklist-editor": "workspace:*", + "@plannotator/server": "workspace:*", + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "@tailwindcss/vite": "^4.1.18", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.0.3", + }, + }, "apps/hook": { "name": "@plannotator/hooks", "version": "0.0.1", @@ -50,7 +71,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.11.4", + "version": "0.12.0", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -71,7 +92,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.11.4", + "version": "0.12.0", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -126,6 +147,17 @@ "typescript": "^5.0.0", }, }, + "packages/checklist-editor": { + "name": "@plannotator/checklist-editor", + "version": "0.0.1", + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18", + }, + }, "packages/editor": { "name": "@plannotator/editor", "version": "0.0.1", @@ -151,7 +183,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.11.4", + "version": "0.12.0", "dependencies": { "@plannotator/shared": "workspace:*", }, @@ -577,6 +609,10 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@plannotator/checklist": ["@plannotator/checklist@workspace:apps/checklist"], + + "@plannotator/checklist-editor": ["@plannotator/checklist-editor@workspace:packages/checklist-editor"], + "@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"], "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"], diff --git a/checklist-after-action-report.md b/checklist-after-action-report.md new file mode 100644 index 00000000..76868271 --- /dev/null +++ b/checklist-after-action-report.md @@ -0,0 +1,195 @@ +# Checklist Review — After Action Report + +Structured against the original implementation plan. Each section notes what was planned, what was implemented, and any divergences. + +--- + +## Naming Conventions + +**Planned:** Standardized on `checklist` everywhere. + +**Implemented:** Exactly as planned. All layers use `checklist`: +- Skill: `.agents/skills/checklist/SKILL.md` +- CLI: `plannotator checklist` +- Slash command: `/plannotator-checklist` +- App: `apps/checklist/` +- Package: `packages/checklist-editor/` +- Server: `packages/server/checklist.ts` +- Built HTML: `checklist.html` + +**Note:** The original skill stub was at `.agents/skills/checklist-review/` — renamed to `.agents/skills/checklist/` during implementation. + +--- + +## 1. Data Model + +**Planned:** `ChecklistItem` with `id`, `category` (free-form string), `check`, `description`, `steps` (required), `reason`, `files?`, `critical?`. Response model with `ChecklistItemResult` (status, notes, images) and `ChecklistSubmission`. + +**Implemented:** Exactly as planned in `packages/shared/checklist-types.ts` (49 lines). One divergence: + +- **Divergence:** `ImageAttachment` type was inlined as `{ path: string; name: string }[]` instead of importing from `@plannotator/ui/types`. This was intentional — importing `@plannotator/ui` from `@plannotator/shared` would create a circular dependency since `@plannotator/ui` already depends on `@plannotator/shared`. + +--- + +## 2. SKILL.md + +**Planned:** Full narrative-driven skill following the code-simplifier pattern. Role as senior QA engineer, 5-step workflow, JSON schema inline, quality guidelines. + +**Implemented:** 127-line skill at `.agents/skills/checklist/SKILL.md`. Follows the plan: +- Framed as "QA Checklist" with unmistakable QA intent +- Principles section (focus on human verification, be specific, every item is a mini test case, fewer good items) +- 5-step workflow: Gather Context → Decide What Needs Verification → Generate JSON → Launch UI → Respond to Results +- Full JSON schema embedded with field-by-field guidance +- Quality guidelines (5-15 items, verb-first checks, critical only for data loss/security/deploy) + +**Divergence:** The skill description was made more "triggerable" per the skill-creator pattern — includes explicit trigger phrases like "create a checklist", "what should I test", "QA this", "pre-flight check". This wasn't in the original plan but follows best practice. + +--- + +## 3. Server Layer + +**Planned:** `packages/server/checklist.ts` following `annotate.ts` pattern. Endpoints: `/api/checklist`, `/api/feedback`, `/api/image`, `/api/upload`, `/api/draft`. Validation function. Feedback markdown formatter. Storage to `~/.plannotator/checklists/`. + +**Implemented:** 376-line server. All planned endpoints implemented. Validation and feedback formatting both present. + +**Divergences:** +- **Storage not implemented.** The plan called for saving checklists to `~/.plannotator/checklists/{project}/`. The server does not persist checklists to disk on arrival. Draft persistence (via `/api/draft`) is implemented for crash recovery, but no permanent storage. This is a gap — checklist history is not tracked. +- **No `getRepoInfo()` call.** The annotate server calls `getRepoInfo()` for context; the checklist server skips it. Minor — checklist doesn't need repo metadata in the API response. + +--- + +## 4. CLI — `plannotator checklist` + +**Planned:** Fifth subcommand in `apps/hook/server/index.ts`. JSON from CLI arg or `--file` flag. Validation → server start → session register → wait → feedback to stdout. + +**Implemented:** Exactly as planned. 65-line branch added. Supports both `plannotator checklist ''` and `plannotator checklist --file `. Validation errors print to stderr with schema hint. Session registered as `mode: "checklist"`. + +**No divergences.** + +--- + +## 5. UI — Checklist Editor + +**Planned:** Item-centric layout with category groups on left, resizable detail panel on right. Progressive disclosure. Visual states mapped to existing palette. Keyboard shortcuts. Filter bar. Submit states. Component architecture: 7 components, 3 hooks, 1 util. Launch frontend-design skill. + +**Implemented:** 1,976 lines across 12 files in `packages/checklist-editor/`. Frontend-design agent built all components. + +| Component | Planned | Implemented | Lines | +|-----------|---------|-------------|-------| +| `App.tsx` | Main app with data fetch, state, submission | Yes, with demo data fallback | 583 | +| `ChecklistHeader.tsx` | Progress bar, filters, submit | Yes | 240 | +| `ChecklistItem.tsx` | Item card with status, hover actions | Yes | 76 | +| `ChecklistGroup.tsx` | Collapsible category with micro-progress | Yes | 86 | +| `ChecklistDetailPanel.tsx` | Full detail: description, steps, notes, images | Yes, with paste-to-upload | 267 | +| `StatusButton.tsx` | Pass/fail/skip buttons | Yes, includes StatusIcon and QuickActions | 146 | +| `ProgressBar.tsx` | Segmented bar | Yes | 50 | +| `GlobalNotes.tsx` | Collapsible notes | Yes | 41 | +| `useChecklistState.ts` | Core state management | Yes, with keyboard nav | 195 | +| `useChecklistDraft.ts` | Auto-save drafts | Yes, 500ms debounce | 132 | +| `useChecklistProgress.ts` | Derived statistics | Yes, with SubmitState enum | 77 | +| `exportChecklist.ts` | Markdown feedback formatter | Yes | 83 | + +**Divergences:** +- **Paste-to-upload** in ChecklistDetailPanel — not in the plan, but a natural addition following the review editor pattern. Developer can paste screenshots directly into the notes area. +- **Demo data** embedded in App.tsx — plan mentioned "demo/fallback data" but didn't specify content. Implemented as an 8-item auth refactor checklist. +- **Filter bar** implemented in ChecklistHeader — plan specified "pill toggles per status, text search" and both are present. + +--- + +## 6. Harness Integration + +**Planned:** OpenCode event listener + command file. Pi registered command + Node-compatible server. Codex/Factory skill-only. Prerequisite: revisit local notes. + +**Implemented:** + +### OpenCode +- Event listener for `/plannotator-checklist` added to `apps/opencode-plugin/index.ts` (92 lines added) +- Imports checklist server, validates JSON, starts server, sends feedback via `ctx.client.session.prompt()` +- Command file created: `apps/opencode-plugin/commands/plannotator-checklist.md` +- HTML import with Bun text attribute for `checklist.html` + +### Pi +- `/plannotator-checklist` command registered in `apps/pi-extension/index.ts` (59 lines added) +- Node-compatible server added to `apps/pi-extension/server.ts` (244 lines added) +- Includes duplicated `validateChecklist()` and `formatChecklistFeedback()` (necessary since Pi uses jiti/Node, not Bun) +- README updated with new command + +### Codex / Factory +- No changes needed — skill-only, works via `plannotator checklist` bash call + +**Divergences:** +- **Pi server duplication is larger than expected.** The plan noted "Node-compatible checklist server" — the implementation duplicated both validation and feedback formatting into `server.ts` (244 lines). This is consistent with how the existing review and plan servers are duplicated for Pi, but it's a maintenance concern. +- **Local notes were read** by the harness agent as prerequisite, confirming the skill registration approaches. + +--- + +## 7. Build Pipeline + +**Planned:** New `dev:checklist` and `build:checklist` scripts. Update `build:hook` to copy checklist HTML. Update `build:opencode` and `build:pi` to copy checklist HTML. Build order: `build:checklist` → `build:review` → `build:hook` → `build:opencode` / `build:pi`. + +**Implemented:** All 4 `package.json` files updated: +- Root: `dev:checklist`, `build:checklist` added. `build` script updated to include `build:checklist`. +- Hook: copies `../checklist/dist/index.html` to `dist/checklist.html` +- OpenCode: copies `../hook/dist/checklist.html` to `./checklist.html`, added to `files` array +- Pi: copies `../hook/dist/checklist.html` to `checklist.html`, added to `files` array + +**No divergences.** + +--- + +## 8. App Entry (`apps/checklist/`) + +**Planned:** `index.html`, `index.tsx`, `vite.config.ts`, `package.json` following `apps/review/` pattern. + +**Implemented:** Exactly as planned. 4 files. Vite config on port 3002 with single-file build, aliases to `@plannotator/checklist-editor` and `@plannotator/ui`. + +**No divergences.** + +--- + +## Summary of Divergences + +| Area | Divergence | Impact | +|------|-----------|--------| +| Types | Inlined `ImageAttachment` instead of importing | Low — avoids circular dep, same shape | +| Skill | Added trigger phrases in description | Positive — better skill discoverability | +| Server | No persistent storage to `~/.plannotator/checklists/` | Medium — no checklist history tracking yet | +| UI | Added paste-to-upload in detail panel | Positive — better UX for screenshot evidence | +| Pi | Larger duplication than expected (validation + formatting) | Low — consistent with existing Pi pattern | + +--- + +## Not Yet Verified + +The plan's verification checklist (Section 9) has not been executed: +1. Skill → CLI end-to-end +2. UI interaction (pass/fail/skip/notes/images) +3. Feedback loop (stdout markdown) +4. Draft persistence +5. Slash command in Claude Code +6. Validation error messages +7. Remote mode +8. Full build pipeline + +These are manual verification steps for the developer. + +--- + +## File Inventory + +**24 new files, 16 modified files, ~2,500 lines of new code.** + +| Category | New | Modified | +|----------|-----|----------| +| Types | 1 | 0 | +| Skill | 1 | 0 | +| Server | 1 | 2 (sessions, package.json) | +| CLI | 0 | 1 | +| Commands | 2 | 0 | +| UI package | 12 | 0 | +| App entry | 4 | 0 | +| Build | 0 | 4 (package.json files) | +| Harness | 1 | 5 (opencode, pi index/server/readme) | +| Shared | 1 | 1 (package.json) | +| Scope doc | 1 | 0 | +| READMEs | 0 | 4 (from prior branch work) | diff --git a/checklist-review-scope.md b/checklist-review-scope.md new file mode 100644 index 00000000..fd5b534e --- /dev/null +++ b/checklist-review-scope.md @@ -0,0 +1,38 @@ +# Checklist Review — Scope + +**What it is:** A skill that instructs the agent to analyze the current diff/PR and generate a structured checklist of things the developer needs to manually verify — then launches an interactive UI for the developer to work through that checklist and submit feedback back to the agent. + +## Three Layers + +1. **Skill (SKILL.md)** — The agent reads the skill instructions, analyzes the diff in context, and produces a structured JSON checklist. The skill includes a JSON schema spec so the agent knows the exact format. No external validation step needed upfront — if the CLI rejects malformed JSON, the error message tells the agent what to fix. + +2. **CLI command** — `plannotator checklist` (or similar subcommand). Takes the structured JSON as input, saves it to `~/.plannotator/checklists/`, starts a server, opens the browser UI. Same pattern as `plannotator review` and `plannotator annotate`. + +3. **Interactive UI** — A new review surface in the Plannotator suite. The developer works through each checklist item and can: check things off, flag failures with notes, skip items with a reason, ask questions, attach images. Submission isn't approve/deny — it's "submit checklist." The ideal state is all green checks, but the UI supports the full QA spectrum. Exact feature set to be determined during planning with deep consideration of what makes a quality, general-purpose QA workflow. + +## What the Checklist Covers + +Not just code review. The agent generates checklist items across whatever the change touches — UI, API, CLI, backend, ML, integration points. The skill is dynamic — the agent decides what's relevant based on the actual diff, not a static template. A sub-agent will think deeply during planning about what constitutes quality QA that scales generally across project types. + +## Integration with Existing Plannotator + +- Same server architecture (random port, remote mode, VS Code extension compatibility) +- Same storage pattern (`~/.plannotator/checklists/` alongside `plans/`, `drafts/`, `history/`) +- Same image upload/attachment system +- Same cookie-based settings persistence +- Consistent visual language with plan review and code review UIs +- Same feedback-to-agent pipeline (stdout JSON for hooks, event handlers for plugins) + +## Per-Harness Delivery + +- **Claude Code** — skill bundled in plugin, CLI subcommand in hook server, called via bash +- **OpenCode** — skill registered via config hook, tool or event handler for the command +- **Pi** — skill bundled in extension, tool call to launch server +- **Codex/Factory** — skill installed globally, agent calls `plannotator checklist` via bash + +## Not in Scope (Yet) + +- Persistent checklist history/tracking across sessions +- Team-level checklist templates +- CI integration or automated checklist generation without an agent +- Restructuring existing commands into skills diff --git a/checklist-ui-redesign.md b/checklist-ui-redesign.md new file mode 100644 index 00000000..c367b9e4 --- /dev/null +++ b/checklist-ui-redesign.md @@ -0,0 +1,54 @@ +# Checklist UI Redesign + +## Source +QA checklist results from 2026-03-10. User tested the checklist feature against itself. +Saved at: `~/.plannotator/checklists/feat-skills-structure/checklist-feature-qa-verification-2026-03-10-1773177928397.json` + +## Core Problem +The checklist editor was built as its own app (two-panel layout with detail sidebar) instead of adapting the plan review's artifact-centric pattern. It needs to feel like the plan review where the checklist is a **document artifact**, not a dashboard. + +## Required Changes + +### 1. Single-artifact view (like plan review) +- Remove the two-panel layout (list + detail sidebar) +- Checklist renders as a single-column artifact/document +- Items expand/collapse inline on click — show description, steps, reason, files when expanded +- Collapsed state: just the check headline with a transparent cutoff preview +- "Modern and elegant" — smooth expand/collapse animations + +### 2. Annotations for notes (not hidden textareas) +- Use the existing annotation system from plan/review editors for item-specific notes +- Current notes feature is too hidden in the detail panel +- Match the annotation UX patterns across all three apps (plan, review, checklist) + +### 3. Progress bar +- Keep it but make it small/thin +- Position it right above the checklist artifact +- Not a big standalone component + +### 4. Hover states +- Current on-hover actions on list items are too noisy/messy +- Reduce hover surface area significantly +- Pass animation on status change is smooth and good — keep that + +### 5. Keyboard shortcuts +- j/k navigation works but focus/selection behavior is confusing +- Need clearer visual feedback for which item is focused +- Status change via keyboard (p/f/s) needs to feel more direct + +### 6. Copy all annotations +- Add copy-all button like the other apps have +- Basic convenience feature that's missing + +### 7. Global notes +- Reconsider — takes up too much surface area +- May not be needed if annotations work well per-item + +### 8. Skipped verification items +- Submit flow + markdown output (func-3) +- Disk persistence (func-4) — verified save works, --file reopen untested in full +- serve.ts extraction across all 4 servers (integ-1) +- Draft auto-save/restore (integ-2) +- Theme CSS extraction visual check (visual-1) +- Validation error messages (edge-1) +- Partial submit dialog (edge-2) diff --git a/opencode-skills.md b/opencode-skills.md new file mode 100644 index 00000000..59dccdb2 --- /dev/null +++ b/opencode-skills.md @@ -0,0 +1,74 @@ +# OpenCode Skills in Plugins + +OpenCode plugins can bundle and register skills via the `config` hook during `Plugin.init()`. + +## Config Hook + +The config hook receives the full mutable `Config.Info` object: + +```typescript +{ + command: Record // slash commands + skills: { paths: string[], urls: string[] } +} +``` + +A plugin can mutate both: + +```typescript +const myPlugin: Plugin = async (input) => ({ + config: async (config) => { + // Register a slash command + config.command ??= {} + config.command["my-command"] = { + template: "Do something cool", + description: "My custom command", + } + + // Register skill paths/URLs + config.skills ??= {} + config.skills.paths ??= [] + config.skills.paths.push("/path/to/bundled/skills") + }, + tool: { ... } +}) +``` + +A single plugin can bundle all three extension types: +- **Tools** — via the `tool` hook directly +- **Slash commands** — via the config hook mutating `config.command` +- **Skills** — via the config hook mutating `config.skills` (pointing to bundled skill directories or URLs) + +## Real-World Example + +[opencode-snippets](https://github.com/JosXa/opencode-snippets/blob/741189a9/index.ts#L81) by JosXa: + +```typescript +config: async (opencodeConfig) => { + // Register bundled skills + cfg.skills ??= {} + cfg.skills.paths ??= [] + cfg.skills.paths.push(SKILL_DIR) // points to bundled skill/ directory + + // Register slash command + opencodeConfig.command ??= {} + opencodeConfig.command.snippet = { + template: "", + description: "Manage text snippets (add, delete, list, help)", + } +} +``` + +Command execution handled via `command.execute.before` hook. + +## For Plannotator + +The OpenCode plugin (`apps/opencode-plugin/index.ts`) already registers commands and tools. To add skills, add to the config hook: + +```typescript +config.skills ??= {} +config.skills.paths ??= [] +config.skills.paths.push(path.join(__dirname, "skills")) +``` + +Then bundle a `skills/` directory in the npm package alongside the existing code. diff --git a/package.json b/package.json index 35d804a2..ee856e37 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,15 @@ "dev:portal": "bun run --cwd apps/portal dev", "dev:marketing": "bun run --cwd apps/marketing dev", "dev:review": "bun run --cwd apps/review dev", + "dev:checklist": "bun run --cwd apps/checklist dev", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", "build:opencode": "bun run --cwd apps/opencode-plugin build", "build:review": "bun run --cwd apps/review build", - "build:pi": "bun run build:review && bun run build:hook && bun run --cwd apps/pi-extension build", - "build": "bun run build:hook && bun run build:opencode", + "build:checklist": "bun run --cwd apps/checklist build", + "build:pi": "bun run build:review && bun run build:checklist && bun run build:hook && bun run --cwd apps/pi-extension build", + "build": "bun run build:checklist && bun run build:hook && bun run build:opencode", "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", diff --git a/packages/checklist-editor/App.tsx b/packages/checklist-editor/App.tsx new file mode 100644 index 00000000..0da0128a --- /dev/null +++ b/packages/checklist-editor/App.tsx @@ -0,0 +1,767 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; +import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; +import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; +import { CommentPopover } from '@plannotator/ui/components/CommentPopover'; +import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; +import { ChecklistHeader } from './components/ChecklistHeader'; +import { ChecklistGroup } from './components/ChecklistGroup'; +import { ChecklistAnnotationPanel } from './components/ChecklistAnnotationPanel'; +import { ProgressBar } from './components/ProgressBar'; +import { CoverageView } from './components/CoverageView'; +import { PRBalance } from './components/PRBalance'; +import { ViewModeToggle } from './components/ViewModeToggle'; +import { useChecklistState } from './hooks/useChecklistState'; +import { useChecklistProgress } from './hooks/useChecklistProgress'; +import { useChecklistDraft } from './hooks/useChecklistDraft'; +import { useChecklistCoverage } from './hooks/useChecklistCoverage'; +import { exportChecklistResults } from './utils/exportChecklist'; +import { getChecklistWidth, saveChecklistWidth, getChecklistView, saveChecklistView, PLAN_WIDTH_OPTIONS } from '@plannotator/ui/utils/uiPreferences'; +import type { PlanWidth } from '@plannotator/ui/utils/uiPreferences'; +import type { Checklist, ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from './hooks/useChecklistState'; +import type { ChecklistPR, ChecklistViewMode } from '@plannotator/shared/checklist-types'; +import type { ChecklistAutomations } from './components/ChecklistAnnotationPanel'; +import type { ImageAttachment } from '@plannotator/ui/types'; + +// --------------------------------------------------------------------------- +// Demo Data +// --------------------------------------------------------------------------- + +const DEMO_CHECKLIST: Checklist = { + title: 'Authentication Refactor QA Checklist', + summary: 'Verify the OAuth2 migration from session-based auth. Focus on token refresh, CSRF protection, and backward compatibility with existing sessions.', + pr: { + number: 142, + url: 'https://github.com/acme/webapp/pull/142', + title: 'feat: migrate to OAuth2 token-based auth', + branch: 'feat/oauth2-migration', + provider: 'github' as const, + }, + fileDiffs: { + // Modified — smaller touch-ups to existing code + 'src/pages/login.tsx': { hunks: 8, lines: 320, status: 'modified' }, + 'src/lib/api-client.ts': { hunks: 5, lines: 180, status: 'modified' }, + 'src/middleware/auth.ts': { hunks: 4, lines: 140, status: 'modified' }, + 'src/pages/dashboard.tsx': { hunks: 3, lines: 110, status: 'modified' }, + 'src/components/AuthButton.tsx': { hunks: 3, lines: 90, status: 'modified' }, + 'src/middleware/api-key.ts': { hunks: 2, lines: 65, status: 'modified' }, + 'src/routes/api.ts': { hunks: 2, lines: 55, status: 'modified' }, + 'src/components/UserMenu.tsx': { hunks: 2, lines: 50, status: 'modified' }, + 'src/config/auth.ts': { hunks: 1, lines: 30, status: 'modified' }, + 'src/utils/redirect.ts': { hunks: 1, lines: 20, status: 'modified' }, + // New — bulk of the PR is fresh code + 'src/auth/providers.ts': { hunks: 10, lines: 920, status: 'new' }, + 'src/auth/oauth-flow.ts': { hunks: 8, lines: 780, status: 'new' }, + 'src/middleware/session-migration.ts': { hunks: 7, lines: 650, status: 'new' }, + 'src/hooks/useAuth.ts': { hunks: 6, lines: 580, status: 'new' }, + 'src/middleware/csrf.ts': { hunks: 5, lines: 480, status: 'new' }, + 'src/lib/token-store.ts': { hunks: 5, lines: 450, status: 'new' }, + 'src/components/OAuthCallback.tsx': { hunks: 5, lines: 420, status: 'new' }, + 'src/hooks/useTokenRefresh.ts': { hunks: 4, lines: 360, status: 'new' }, + 'src/auth/pkce.ts': { hunks: 4, lines: 340, status: 'new' }, + 'src/components/ErrorMessage.tsx': { hunks: 3, lines: 280, status: 'new' }, + 'src/components/SessionExpired.tsx': { hunks: 3, lines: 240, status: 'new' }, + 'src/auth/token-validator.ts': { hunks: 3, lines: 220, status: 'new' }, + 'src/utils/jwt-decode.ts': { hunks: 2, lines: 180, status: 'new' }, + 'src/types/auth.ts': { hunks: 2, lines: 150, status: 'new' }, + 'src/lib/csrf.ts': { hunks: 2, lines: 130, status: 'new' }, + 'tests/auth/providers.test.ts': { hunks: 5, lines: 480, status: 'new' }, + 'tests/auth/oauth-flow.test.ts': { hunks: 4, lines: 390, status: 'new' }, + 'tests/middleware/csrf.test.ts': { hunks: 3, lines: 310, status: 'new' }, + 'tests/hooks/useAuth.test.ts': { hunks: 3, lines: 280, status: 'new' }, + 'tests/middleware/session-migration.test.ts': { hunks: 2, lines: 220, status: 'new' }, + }, + items: [ + { + id: 'auth-1', + category: 'Security', + check: 'CSRF token validation on all state-changing endpoints', + description: 'The migration replaced session-based CSRF with double-submit cookie pattern. Every POST/PUT/DELETE endpoint must validate the `X-CSRF-Token` header against the `csrf_token` cookie.\n\nCheck that the middleware is applied globally and not just on auth routes.', + steps: [ + 'Open DevTools Network tab', + 'Submit the login form and verify X-CSRF-Token header is present', + 'Try a POST request without the header — should get 403', + 'Verify the csrf_token cookie has SameSite=Strict', + ], + reason: 'CSRF protection is security-critical and automated tests may not catch middleware ordering issues.', + files: ['src/middleware/csrf.ts', 'src/routes/api.ts'], + diffMap: { 'src/middleware/csrf.ts': 4, 'src/routes/api.ts': 3, 'src/lib/csrf.ts': 2 }, + critical: true, + }, + { + id: 'auth-2', + category: 'Security', + check: 'Token refresh handles race conditions', + description: 'When multiple API calls fire simultaneously and the access token is expired, only one refresh request should be sent. Subsequent calls should queue and use the new token.', + steps: [ + 'Log in and wait for the access token to expire (or manually set it to expired)', + 'Trigger 3+ API calls simultaneously (e.g., navigate to dashboard)', + 'Check Network tab — only one /auth/refresh call should appear', + 'All queued requests should succeed with the refreshed token', + ], + reason: 'Race conditions in token refresh can cause cascading 401s and logout the user.', + files: ['src/lib/api-client.ts', 'src/hooks/useAuth.ts'], + diffMap: { 'src/lib/api-client.ts': 5, 'src/hooks/useAuth.ts': 3, 'src/hooks/useTokenRefresh.ts': 3, 'src/lib/token-store.ts': 4 }, + critical: true, + }, + { + id: 'auth-3', + category: 'Functionality', + check: 'Login flow completes successfully', + description: 'Basic end-to-end login with email/password. User should be redirected to their intended destination after authentication.', + steps: [ + 'Navigate to a protected page while logged out', + 'Complete the login form', + 'Verify redirect back to the originally requested page', + 'Check that the user menu shows the correct identity', + ], + reason: 'Core user flow that must work correctly.', + files: ['src/pages/login.tsx', 'src/middleware/auth.ts'], + diffMap: { 'src/pages/login.tsx': 6, 'src/middleware/auth.ts': 4, 'src/utils/redirect.ts': 1, 'src/pages/dashboard.tsx': 3 }, + }, + { + id: 'auth-4', + category: 'Functionality', + check: 'OAuth provider login (Google, GitHub)', + description: 'Social login buttons should initiate the OAuth flow and correctly create or link accounts.', + steps: [ + 'Click "Sign in with Google" and complete the OAuth flow', + 'Verify account is created/linked correctly', + 'Repeat with GitHub provider', + 'Try linking a second provider to an existing account', + ], + reason: 'OAuth flows involve third-party redirects that are difficult to test automatically.', + files: ['src/auth/providers.ts'], + diffMap: { 'src/auth/providers.ts': 6, 'src/pages/login.tsx': 3, 'src/components/OAuthCallback.tsx': 4, 'src/auth/pkce.ts': 2 }, + }, + { + id: 'auth-5', + category: 'Backward Compatibility', + check: 'Existing sessions are migrated transparently', + description: 'Users with active session cookies from the old system should be seamlessly migrated to the new token-based system without being logged out.', + steps: [ + 'Log in using the old build to establish a session cookie', + 'Deploy the new build', + 'Refresh the page — user should remain logged in', + 'Verify the old session cookie is replaced with the new token cookies', + ], + reason: 'A forced logout would affect all active users and is unacceptable for a production migration.', + files: ['src/middleware/session-migration.ts'], + diffMap: { 'src/middleware/session-migration.ts': 5, 'src/middleware/auth.ts': 3, 'src/lib/token-store.ts': 2, 'src/components/SessionExpired.tsx': 2 }, + critical: true, + }, + { + id: 'auth-6', + category: 'Backward Compatibility', + check: 'API keys continue to work unchanged', + description: 'Service accounts using API key authentication should be unaffected by the OAuth migration.', + steps: [ + 'Make an API call with a valid API key', + 'Verify the response is identical to the old behavior', + 'Check that API key auth bypasses the OAuth middleware', + ], + reason: 'Breaking API key auth would disrupt automated integrations.', + files: ['src/middleware/api-key.ts'], + diffMap: { 'src/middleware/api-key.ts': 3, 'src/config/auth.ts': 2, 'src/routes/api.ts': 1 }, + }, + { + id: 'auth-7', + category: 'UI/UX', + check: 'Error states show helpful messages', + description: 'Invalid credentials, expired links, and rate limiting should show user-friendly error messages, not raw error codes.', + steps: [ + 'Try logging in with wrong credentials — check the error message', + 'Try a password reset with an expired link', + 'Trigger rate limiting (5+ failed attempts) and verify the message', + ], + reason: 'Error copy is hard to verify without visual inspection.', + diffMap: { 'src/components/ErrorMessage.tsx': 3, 'src/pages/login.tsx': 2, 'src/components/SessionExpired.tsx': 1 }, + }, + { + id: 'auth-8', + category: 'UI/UX', + check: 'Loading states during auth operations', + description: 'Buttons should show loading spinners and be disabled during login, registration, and token refresh to prevent double-submission.', + steps: [ + 'Click login and observe button state before response arrives', + 'Check that double-clicking does not submit twice', + 'Verify there is no layout shift when the spinner appears', + ], + reason: 'Loading state polish requires visual verification.', + diffMap: { 'src/components/AuthButton.tsx': 4, 'src/lib/api-client.ts': 3, 'src/hooks/useAuth.ts': 2, 'src/components/UserMenu.tsx': 2, 'src/pages/dashboard.tsx': 2 }, + }, + ], +}; + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +const ChecklistApp: React.FC = () => { + const [checklist, setChecklist] = useState(null); + const [origin, setOrigin] = useState(null); + const [initialResults, setInitialResults] = useState(); + const [initialGlobalNotes, setInitialGlobalNotes] = useState(); + const [isLoading, setIsLoading] = useState(true); + + // Fetch checklist data + useEffect(() => { + fetch('/api/checklist') + .then(res => { + if (!res.ok) throw new Error('Not in API mode'); + return res.json(); + }) + .then((data: { checklist: Checklist; origin?: string; initialResults?: ChecklistItemResult[]; initialGlobalNotes?: string[] }) => { + setChecklist(data.checklist); + if (data.origin) setOrigin(data.origin); + if (data.initialResults) setInitialResults(data.initialResults); + if (data.initialGlobalNotes) setInitialGlobalNotes(data.initialGlobalNotes); + }) + .catch(() => { + // Demo mode + setChecklist(DEMO_CHECKLIST); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading || !checklist) { + return ( + +
+
Loading checklist...
+
+
+ ); + } + + return ( + + + + ); +}; + +// --------------------------------------------------------------------------- +// Inner component +// --------------------------------------------------------------------------- + +interface ChecklistAppInnerProps { + checklist: Checklist; + origin: string | null; + initialResults?: ChecklistItemResult[]; + initialGlobalNotes?: string[]; +} + +// Note popover state +interface NotePopoverState { + anchorEl: HTMLElement; + itemId: string | null; // null = global comment +} + +const ChecklistAppInner: React.FC = ({ checklist, origin, initialResults, initialGlobalNotes }) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false); + const [globalNotes, setGlobalNotes] = useState([]); + const [showPartialConfirm, setShowPartialConfirm] = useState(false); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [isPanelOpen, setIsPanelOpen] = useState(true); + const [notePopover, setNotePopover] = useState(null); + const [automations, setAutomations] = useState({ postToPR: false, approveIfAllPass: false }); + const [viewMode, setViewModeRaw] = useState(getChecklistView); + const setViewMode = (mode: ChecklistViewMode) => { setViewModeRaw(mode); saveChecklistView(mode); }; + const [balanceOpen, setBalanceOpen] = useState(false); + const [checklistWidth, setChecklistWidth] = useState(getChecklistWidth); + const documentRef = useRef(null); + const globalCommentButtonRef = useRef(null); + + const state = useChecklistState({ items: checklist.items }); + const { counts, categoryProgress, submitState } = useChecklistProgress(checklist.items, state.results); + const coverageData = useChecklistCoverage(checklist.fileDiffs, checklist.items, state.results); + const hasCoverage = coverageData !== null; + const hasBalance = useMemo( + () => checklist.fileDiffs && Object.values(checklist.fileDiffs).some(v => typeof v === 'object'), + [checklist.fileDiffs], + ); + + const panelResize = useResizablePanel({ + storageKey: 'plannotator-checklist-panel-width', + defaultWidth: 320, + minWidth: 260, + maxWidth: 500, + }); + + // Draft auto-save + const { draftBanner, restoreDraft, dismissDraft } = useChecklistDraft({ + results: state.allResults, + globalNotes, + isApiMode: !!origin, + submitted: !!submitted, + }); + + const handleRestoreDraft = useCallback(() => { + const restored = restoreDraft(); + if (restored) { + state.restoreResults(restored.results); + if (restored.globalNotes) setGlobalNotes(restored.globalNotes); + } + }, [restoreDraft, state.restoreResults]); + + // Restore initial results from saved checklist file (--file flag) + useEffect(() => { + if (initialResults?.length) { + state.restoreResults(initialResults); + } + if (initialGlobalNotes?.length) { + setGlobalNotes(initialGlobalNotes); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // One-time on mount + + const handleChecklistWidthChange = useCallback((width: PlanWidth) => { + setChecklistWidth(width); + saveChecklistWidth(width); + }, []); + + const checklistMaxWidth = useMemo(() => { + const widths: Record = { compact: 832, default: 1040, wide: 1280 }; + return widths[checklistWidth] ?? 832; + }, [checklistWidth]); + + // Set status and auto-collapse the item + const handleSetStatus = useCallback((id: string, status: ChecklistItemStatus) => { + state.setStatus(id, status); + if (status !== 'pending') { + setExpandedItems(prev => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, [state.setStatus]); + + // Toggle item expansion + const handleToggleExpand = useCallback((id: string) => { + setExpandedItems(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + state.selectItem(id); + }, [state.selectItem]); + + // Open note popover for an item + const handleOpenNote = useCallback((anchorEl: HTMLElement, itemId: string) => { + setNotePopover({ anchorEl, itemId }); + }, []); + + // Open global comment popover + const handleOpenGlobalComment = useCallback(() => { + if (globalCommentButtonRef.current) { + setNotePopover({ anchorEl: globalCommentButtonRef.current, itemId: null }); + } + }, []); + + // Handle note popover submit + const handleNoteSubmit = useCallback((text: string, images?: ImageAttachment[]) => { + if (!notePopover) return; + + if (notePopover.itemId === null) { + // Global comment — add as new entry + setGlobalNotes(prev => [...prev, text]); + } else { + // Per-item note — add to array + state.addNote(notePopover.itemId, text); + if (images && images.length > 0) { + state.setImages(notePopover.itemId, images.map(img => ({ path: img.path, name: img.name }))); + } + } + + setNotePopover(null); + }, [notePopover, state.addNote, state.setImages]); + + const handleNoteClose = useCallback(() => { + setNotePopover(null); + }, []); + + // Remove notes + const handleRemoveItemNote = useCallback((id: string, index: number) => { + state.removeNote(id, index); + }, [state.removeNote]); + + const handleRemoveGlobalNote = useCallback((index: number) => { + setGlobalNotes(prev => prev.filter((_, i) => i !== index)); + }, []); + + // Select item from annotation panel (expand + scroll) + const handleSelectFromPanel = useCallback((id: string) => { + setExpandedItems(prev => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + state.selectItem(id); + // Scroll to item + if (documentRef.current) { + const el = documentRef.current.querySelector(`[data-item-id="${id}"]`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, [state.selectItem]); + + // Feedback markdown for copy + const feedbackMarkdown = useMemo( + () => exportChecklistResults(checklist.items, state.results, globalNotes.length > 0 ? globalNotes : undefined), + [checklist.items, state.results, globalNotes], + ); + + // Submit + const handleSubmit = useCallback(async () => { + if (submitState === 'all-pending') return; + + if (submitState === 'partial' && !showPartialConfirm) { + setShowPartialConfirm(true); + return; + } + + setShowPartialConfirm(false); + setIsSubmitting(true); + + try { + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback: feedbackMarkdown, + results: state.allResults, + globalNotes: globalNotes.length > 0 ? globalNotes : undefined, + automations: checklist.pr ? automations : undefined, + }), + }); + + if (res.ok) { + const hasFailures = state.allResults.some(r => r.status === 'failed'); + setSubmitted(hasFailures ? 'feedback' : 'approved'); + } else { + throw new Error('Failed to submit'); + } + } catch (err) { + console.error('Failed to submit checklist:', err); + setIsSubmitting(false); + } + }, [submitState, showPartialConfirm, feedbackMarkdown, state.allResults, checklist.pr, automations]); + + // Keyboard shortcuts — use refs for values that change frequently to avoid + // tearing down/re-attaching the listener on every state change + const stateRef = useRef(state); + stateRef.current = state; + const submittedRef = useRef(submitted); + submittedRef.current = submitted; + const isSubmittingRef = useRef(isSubmitting); + isSubmittingRef.current = isSubmitting; + const hasCoverageRef = useRef(hasCoverage); + hasCoverageRef.current = hasCoverage; + const viewModeRef = useRef(viewMode); + viewModeRef.current = viewMode; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + const isInput = tag === 'INPUT' || tag === 'TEXTAREA'; + const s = stateRef.current; + + // Cmd+Enter to submit (works even in inputs) + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + if (submittedRef.current || isSubmittingRef.current) return; + if (!origin) return; + e.preventDefault(); + handleSubmit(); + return; + } + + // Don't intercept other shortcuts in inputs + if (isInput) return; + + switch (e.key) { + case 'j': { + e.preventDefault(); + const nextId = s.selectNext(); + if (nextId) setExpandedItems(new Set([nextId])); + break; + } + case 'k': { + e.preventDefault(); + const prevId = s.selectPrev(); + if (prevId) setExpandedItems(new Set([prevId])); + break; + } + case 'p': + if (s.selectedItemId) { + e.preventDefault(); + const r = s.getResult(s.selectedItemId); + const next = r.status === 'passed' ? 'pending' : 'passed'; + s.setStatus(s.selectedItemId, next); + if (next !== 'pending') setExpandedItems(prev => { const n = new Set(prev); n.delete(s.selectedItemId!); return n; }); + } + break; + case 'f': + if (s.selectedItemId) { + e.preventDefault(); + const r = s.getResult(s.selectedItemId); + const next = r.status === 'failed' ? 'pending' : 'failed'; + s.setStatus(s.selectedItemId, next); + if (next !== 'pending') setExpandedItems(prev => { const n = new Set(prev); n.delete(s.selectedItemId!); return n; }); + } + break; + case 's': + if (s.selectedItemId) { + e.preventDefault(); + const r = s.getResult(s.selectedItemId); + const next = r.status === 'skipped' ? 'pending' : 'skipped'; + s.setStatus(s.selectedItemId, next); + if (next !== 'pending') setExpandedItems(prev => { const n = new Set(prev); n.delete(s.selectedItemId!); return n; }); + } + break; + case 'n': + if (s.selectedItemId) { + e.preventDefault(); + const itemEl = documentRef.current?.querySelector(`[data-item-id="${s.selectedItemId}"]`); + const noteBtn = itemEl?.querySelector('[title="Add note (n)"]') as HTMLElement | null; + if (noteBtn) { + setNotePopover({ anchorEl: noteBtn, itemId: s.selectedItemId }); + } + } + break; + case 'v': + if (hasCoverageRef.current) { + e.preventDefault(); + setViewMode(viewModeRef.current === 'checklist' ? 'coverage' : 'checklist'); + } + break; + case 'Enter': + if (s.selectedItemId) { + e.preventDefault(); + handleToggleExpand(s.selectedItemId); + } + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [origin, handleSubmit, handleToggleExpand]); + + // Scroll selected item into view + useEffect(() => { + if (!state.selectedItemId || !documentRef.current) return; + const el = documentRef.current.querySelector(`[data-item-id="${state.selectedItemId}"]`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [state.selectedItemId]); + + const agentLabel = origin === 'claude-code' ? 'Claude Code' : origin === 'pi' ? 'Pi' : 'OpenCode'; + + return ( +
+ setIsPanelOpen(p => !p)} + checklistWidth={checklistWidth} + onChecklistWidthChange={handleChecklistWidthChange} + /> + + {/* Draft restore banner */} + + + {/* Main content */} +
+ {/* Checklist document */} +
+
+ {/* Title */} +

{checklist.title}

+ + {/* Summary */} + {checklist.summary && ( +

{checklist.summary}

+ )} + + {/* Toggle + Progress bar */} +
+ {hasCoverage && } + +
+ + {/* PR Balance — collapsible orientation card */} + {hasBalance && checklist.fileDiffs && ( +
+ + {balanceOpen && ( +
+
+ +
+
+ )} +
+ )} + + {/* View content — swaps between checklist items and coverage */} + {viewMode === 'coverage' && coverageData ? ( + + ) : ( +
+ {/* Global comment button — inside the surface, right-aligned */} +
+ +
+ + {state.categories.map(category => { + const items = state.groupedItems.get(category); + const progress = categoryProgress.get(category); + if (!items || !progress) return null; + return ( + + ); + })} +
+ )} +
+
+ + {/* Resize handle + Annotation panel */} + {isPanelOpen && ( + <> + + + + )} +
+ + {/* Comment popover */} + {notePopover && ( + i.id === notePopover.itemId)?.check || '' + : '' + } + isGlobal={notePopover.itemId === null} + initialText="" + onSubmit={handleNoteSubmit} + onClose={handleNoteClose} + /> + )} + + {/* Partial submit confirmation */} + setShowPartialConfirm(false)} + onConfirm={handleSubmit} + title="Partial Results" + message={<>{counts.pending} item{counts.pending !== 1 ? 's' : ''} haven't been reviewed yet. Submit anyway?} + confirmText="Submit Partial Results" + cancelText="Continue Reviewing" + variant="warning" + showCancel + /> + + {/* Completion overlay */} + {origin && ( + + )} + + {/* Demo mode toast */} + {!origin && ( +
+ Demo mode — j/k navigate, p/f/s set status, Enter expand{hasCoverage && <>, v coverage} +
+ )} +
+ ); +}; + +export default ChecklistApp; diff --git a/packages/checklist-editor/components/ChecklistAnnotationPanel.tsx b/packages/checklist-editor/components/ChecklistAnnotationPanel.tsx new file mode 100644 index 00000000..accc1157 --- /dev/null +++ b/packages/checklist-editor/components/ChecklistAnnotationPanel.tsx @@ -0,0 +1,234 @@ +import React, { useState, useRef } from 'react'; +import type { ChecklistItem, ChecklistItemResult } from '../hooks/useChecklistState'; +import type { ChecklistPR } from '@plannotator/shared/checklist-types'; +import { StatusIcon } from './StatusButton'; +import { PRIcon } from './ChecklistHeader'; + +export interface ChecklistAutomations { + postToPR: boolean; + approveIfAllPass: boolean; +} + +interface ChecklistAnnotationPanelProps { + items: ChecklistItem[]; + getResult: (id: string) => ChecklistItemResult; + globalNotes: string[]; + pr?: ChecklistPR; + automations: ChecklistAutomations; + onAutomationsChange: (automations: ChecklistAutomations) => void; + onSelectItem: (id: string) => void; + onRemoveItemNote: (id: string, index: number) => void; + onRemoveGlobalNote: (index: number) => void; + width: number; + feedbackMarkdown: string; +} + +// Provider labels +const PROVIDER_LABELS: Record = { + github: 'GitHub', + gitlab: 'GitLab', + 'azure-devops': 'Azure DevOps', +}; + +export const ChecklistAnnotationPanel: React.FC = ({ + items, + getResult, + globalNotes, + pr, + automations, + onAutomationsChange, + onSelectItem, + onRemoveItemNote, + onRemoveGlobalNote, + width, + feedbackMarkdown, +}) => { + const [copied, setCopied] = useState(false); + const copyTimeoutRef = useRef>(); + + const itemsWithNotes = items.filter(item => { + const result = getResult(item.id); + return (result.notes && result.notes.length > 0) || (result.images && result.images.length > 0); + }); + + // Count all individual notes + const itemNoteCount = itemsWithNotes.reduce((sum, item) => { + const result = getResult(item.id); + return sum + (result.notes?.length || 0); + }, 0); + const noteCount = itemNoteCount + globalNotes.length; + + const handleCopy = async () => { + if (!feedbackMarkdown) return; + try { + await navigator.clipboard.writeText(feedbackMarkdown); + setCopied(true); + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + + return ( + + ); +}; diff --git a/packages/checklist-editor/components/ChecklistGroup.tsx b/packages/checklist-editor/components/ChecklistGroup.tsx new file mode 100644 index 00000000..821ab194 --- /dev/null +++ b/packages/checklist-editor/components/ChecklistGroup.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect, useRef } from 'react'; +import type { ChecklistItem as ChecklistItemType, ChecklistItemResult } from '../hooks/useChecklistState'; +import type { CategoryProgress } from '../hooks/useChecklistProgress'; +import { ChecklistItem } from './ChecklistItem'; +import { StatusIcon } from './StatusButton'; + +const MAX_COMPACT_VISIBLE = 3; + +interface ChecklistGroupProps { + category: string; + items: ChecklistItemType[]; + progress: CategoryProgress; + expandedItems: Set; + selectedItemId: string | null; + onToggleExpand: (id: string) => void; + onOpenNote: (anchorEl: HTMLElement, itemId: string) => void; + getResult: (id: string) => ChecklistItemResult; + onSetStatus: (id: string, status: ChecklistItemResult['status']) => void; +} + +export const ChecklistGroup: React.FC = ({ + category, + items, + progress, + expandedItems, + selectedItemId, + onToggleExpand, + onOpenNote, + getResult, + onSetStatus, +}) => { + const isComplete = progress.reviewed === progress.total && progress.reviewed > 0; + const [manuallyExpanded, setManuallyExpanded] = useState(false); + const wasCompleteRef = useRef(false); + + // Auto-collapse when category becomes complete + useEffect(() => { + if (isComplete && !wasCompleteRef.current) { + setManuallyExpanded(false); + } + wasCompleteRef.current = isComplete; + }, [isComplete]); + + const isCollapsed = isComplete && !manuallyExpanded; + const overflow = items.length - MAX_COMPACT_VISIBLE; + + return ( +
+ {/* Category heading */} +
setManuallyExpanded(prev => !prev) : undefined} + > + {/* Collapse chevron — only when complete */} + {isComplete && ( + + + + )} + + + {category} + + + {/* Micro-progress */} + + {progress.reviewed > 0 && ( + <> + + {progress.reviewed} + + /{progress.total} + + )} + {progress.reviewed === 0 && ( + {progress.total} + )} + + + {/* Completion check */} + {isComplete && progress.failed === 0 && ( + + + + )} + + {/* Failure indicator */} + {progress.failed > 0 && !isComplete && ( + + )} +
+ + {/* Full items — visible when not collapsed */} +
+
+
+ {items.map(item => ( + onToggleExpand(item.id)} + onOpenNote={(anchorEl) => onOpenNote(anchorEl, item.id)} + onSetStatus={status => onSetStatus(item.id, status)} + /> + ))} +
+
+
+ + {/* Compact summary — visible when collapsed */} +
+
+
+
+ {items.slice(0, MAX_COMPACT_VISIBLE).map((item, i) => { + const result = getResult(item.id); + return ( +
+ + {item.check} +
+ ); + })} + + {/* Overflow fade + count */} + {overflow > 0 && ( +
+ +{overflow} more +
+ )} +
+
+
+
+
+ ); +}; diff --git a/packages/checklist-editor/components/ChecklistHeader.tsx b/packages/checklist-editor/components/ChecklistHeader.tsx new file mode 100644 index 00000000..d8b9a16b --- /dev/null +++ b/packages/checklist-editor/components/ChecklistHeader.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; +import { Settings } from '@plannotator/ui/components/Settings'; +import type { PlanWidth } from '@plannotator/ui/utils/uiPreferences'; +import type { StatusCounts, SubmitState } from '../hooks/useChecklistProgress'; +import type { ChecklistPR } from '@plannotator/shared/checklist-types'; + +declare const __APP_VERSION__: string; + +// --------------------------------------------------------------------------- +// Provider icons (compact, monochrome-friendly) +// --------------------------------------------------------------------------- + +// Icons sourced from repo root SVGs (GitHub.svg, GitLab.svg, Azure Devops.svg) +const GitHubIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const GitLabIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + + + +); + +const AzureDevOpsIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + +); + +const ProviderIcon: React.FC<{ provider: ChecklistPR['provider']; className?: string }> = ({ provider, className }) => { + switch (provider) { + case 'github': return ; + case 'gitlab': return ; + case 'azure-devops': return ; + } +}; + +// PR icon (from PR.svg in repo root) +export const PRIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +// --------------------------------------------------------------------------- +// Submit button +// --------------------------------------------------------------------------- + +interface SubmitButtonProps { + submitState: SubmitState; + isSubmitting: boolean; + onClick: () => void; + counts: StatusCounts; +} + +const SubmitButton: React.FC = ({ + submitState, + isSubmitting, + onClick, + counts, +}) => { + if (isSubmitting) { + return ( + + ); + } + + switch (submitState) { + case 'all-pending': + return ( + + ); + case 'partial': + return ( + + ); + case 'all-reviewed-with-failures': + return ( + + ); + case 'all-passed': + return ( + + ); + } +}; + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- + +interface ChecklistHeaderProps { + title: string; + origin: string | null; + pr?: ChecklistPR; + counts: StatusCounts; + submitState: SubmitState; + isSubmitting: boolean; + onSubmit: () => void; + isPanelOpen: boolean; + onTogglePanel: () => void; + checklistWidth: PlanWidth; + onChecklistWidthChange: (width: PlanWidth) => void; +} + +export const ChecklistHeader: React.FC = ({ + title, + origin, + pr, + counts, + submitState, + isSubmitting, + onSubmit, + isPanelOpen, + onTogglePanel, + checklistWidth, + onChecklistWidthChange, +}) => ( +
+ {/* Left side */} +
+ + Plannotator + + + v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} + + + QA Checklist + + {origin && ( + + )} + + {/* PR link replaces title; fallback to title text */} + | + {pr ? ( + + + + #{pr.number} + {pr.branch && ( + <> + · + {pr.branch} + + )} + + ) : ( + + {title} + + )} +
+ + {/* Right side */} +
+ {origin && ( + + )} +
+ + {}} + onIdentityChange={() => {}} + origin={origin} + mode="checklist" + checklistWidth={checklistWidth} + onChecklistWidthChange={onChecklistWidthChange} + /> + {/* Panel toggle */} + +
+
+); diff --git a/packages/checklist-editor/components/ChecklistItem.tsx b/packages/checklist-editor/components/ChecklistItem.tsx new file mode 100644 index 00000000..eded45e7 --- /dev/null +++ b/packages/checklist-editor/components/ChecklistItem.tsx @@ -0,0 +1,236 @@ +import React, { useRef, useCallback } from 'react'; +import type { ChecklistItem as ChecklistItemType, ChecklistItemResult } from '../hooks/useChecklistState'; +import { StatusIcon, QuickActions } from './StatusButton'; + +interface ChecklistItemProps { + item: ChecklistItemType; + result: ChecklistItemResult; + isExpanded: boolean; + isSelected: boolean; + onToggleExpand: () => void; + onOpenNote: (anchorEl: HTMLElement) => void; + onSetStatus: (status: ChecklistItemResult['status']) => void; +} + +export const ChecklistItem: React.FC = ({ + item, + result, + isExpanded, + isSelected, + onToggleExpand, + onOpenNote, + onSetStatus, +}) => { + const noteButtonRef = useRef(null); + + const handleNoteClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (noteButtonRef.current) { + onOpenNote(noteButtonRef.current); + } + }, [onOpenNote]); + + const sectionHeading = "text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1.5"; + + return ( +
+ {/* Top row */} +
+ {/* Status icon */} +
+ +
+ + {/* Content */} +
+ {/* Check headline */} +
+ {item.check} +
+ + {/* Badges row */} +
+ + {item.category} + + {item.critical && ( + Critical + )} +
+
+ + {/* Quick actions + Note button */} +
e.stopPropagation()}> + + +
+
+ + {/* Description preview (collapsed only) */} + {!isExpanded && item.description && ( +
+
+ {item.description} +
+
+ )} + + {/* Expandable body */} +
+
+
+ {/* Description */} + {item.description && ( +
+

Description

+
+ {renderSimpleMarkdown(item.description)} +
+
+ )} + + {/* Verification steps */} + {item.steps.length > 0 && ( +
+

Verification Steps

+
    + {item.steps.map((step, i) => ( +
  1. {step}
  2. + ))} +
+
+ )} + + {/* File references */} + {item.files && item.files.length > 0 && ( +
+

Files

+
+ {item.files.map((file, i) => ( +
+ {file} +
+ ))} +
+
+ )} + + {/* Reason */} + {item.reason && ( +
+

Why Manual Verification

+

{item.reason}

+
+ )} +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Simple markdown renderer for description text +// --------------------------------------------------------------------------- + +function renderInlineMarkdown(text: string): React.ReactNode { + const parts: React.ReactNode[] = []; + let remaining = text; + let key = 0; + + while (remaining.length > 0) { + // Bold: **text** + let match = remaining.match(/^\*\*(.+?)\*\*/); + if (match) { + parts.push({renderInlineMarkdown(match[1])}); + remaining = remaining.slice(match[0].length); + continue; + } + + // Inline code: `code` + match = remaining.match(/^`([^`]+)`/); + if (match) { + parts.push({match[1]}); + remaining = remaining.slice(match[0].length); + continue; + } + + // Plain text — consume up to next special character + match = remaining.match(/^[^*`]+/); + if (match) { + parts.push({match[0]}); + remaining = remaining.slice(match[0].length); + continue; + } + + // Single * or ` that didn't match a pattern — consume one char + parts.push({remaining[0]}); + remaining = remaining.slice(1); + } + + return <>{parts}; +} + +function renderSimpleMarkdown(text: string): React.ReactNode { + const lines = text.split('\n'); + const elements: React.ReactNode[] = []; + let inCode = false; + let codeBlock: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('```')) { + if (inCode) { + elements.push(
{codeBlock.join('\n')}
); + codeBlock = []; + inCode = false; + } else { + inCode = true; + } + continue; + } + + if (inCode) { + codeBlock.push(line); + continue; + } + + if (line.trim() === '') { + elements.push(
); + continue; + } + + elements.push( +

{renderInlineMarkdown(line)}

, + ); + } + + if (inCode && codeBlock.length > 0) { + elements.push(
{codeBlock.join('\n')}
); + } + + return <>{elements}; +} diff --git a/packages/checklist-editor/components/CoverageFileTree.tsx b/packages/checklist-editor/components/CoverageFileTree.tsx new file mode 100644 index 00000000..b0d9fc2b --- /dev/null +++ b/packages/checklist-editor/components/CoverageFileTree.tsx @@ -0,0 +1,179 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { WaffleCells } from './WaffleCells'; +import type { FileTreeNode } from '../hooks/useChecklistCoverage'; + +interface CoverageFileTreeProps { + tree: FileTreeNode[]; +} + +// Collect all directory paths for initial expanded state +function collectDirPaths(nodes: FileTreeNode[]): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.type === 'dir') { + paths.push(node.path); + if (node.children) { + paths.push(...collectDirPaths(node.children)); + } + } + } + return paths; +} + +const FolderIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const FileIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const ChevronIcon: React.FC<{ expanded: boolean; className?: string }> = ({ expanded, className }) => ( + + + +); + +const TreeRow: React.FC<{ + node: FileTreeNode; + depth: number; + expandedDirs: Set; + onToggle: (path: string) => void; +}> = ({ node, depth, expandedDirs, onToggle }) => { + const isDir = node.type === 'dir'; + const isExpanded = isDir && expandedDirs.has(node.path); + const coveredDiffs = node.passedDiffs + node.failedDiffs + node.skippedDiffs; + const percent = node.totalDiffs > 0 + ? Math.round((coveredDiffs / node.totalDiffs) * 100) + : 0; + const isFull = percent === 100 && node.totalDiffs > 0; + + return ( + <> +
isDir && onToggle(node.path)} + > + {/* Chevron for directories */} + {isDir ? ( + + ) : ( + + )} + + {/* Icon */} + {isDir ? ( + + ) : ( + + )} + + {/* Name */} + + {node.name}{isDir ? '/' : ''} + + + {/* Spacer */} + + + {/* Waffle cells */} + + + {/* Percentage */} + 0 + ? 'text-muted-foreground/70' + : 'text-muted-foreground/30' + }`}> + {percent}% + +
+ + {/* Children */} + {isDir && isExpanded && node.children?.map(child => ( + + ))} + + ); +}; + +export const CoverageFileTree: React.FC = ({ tree }) => { + const allDirPaths = useMemo(() => new Set(collectDirPaths(tree)), [tree]); + const [expandedDirs, setExpandedDirs] = useState>(allDirPaths); + + // Sync expanded dirs when tree changes (new dirs should start expanded) + useMemo(() => { + const newDirs = collectDirPaths(tree); + setExpandedDirs(prev => { + const next = new Set(prev); + for (const d of newDirs) { + if (!prev.has(d) && !allDirPaths.has(d)) { + // genuinely new directory + } + next.add(d); + } + return next; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tree]); + + const handleToggle = useCallback((path: string) => { + setExpandedDirs(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + return ( +
+ {tree.map(node => ( + + ))} +
+ ); +}; diff --git a/packages/checklist-editor/components/CoverageTaskList.tsx b/packages/checklist-editor/components/CoverageTaskList.tsx new file mode 100644 index 00000000..eb65c594 --- /dev/null +++ b/packages/checklist-editor/components/CoverageTaskList.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { StatusIcon, StatusButton } from './StatusButton'; +import type { ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from '@plannotator/shared/checklist-types'; + +interface CoverageTaskListProps { + items: ChecklistItem[]; + categories: string[]; + groupedItems: Map; + selectedItemId: string | null; + getResult: (id: string) => ChecklistItemResult; + onSetStatus: (id: string, status: ChecklistItemStatus) => void; + onSelectItem: (id: string) => void; + compact?: boolean; +} + +export const CoverageTaskList: React.FC = ({ + categories, + groupedItems, + selectedItemId, + getResult, + onSetStatus, + onSelectItem, + compact, +}) => { + return ( +
+ {categories.map(category => { + const items = groupedItems.get(category); + if (!items) return null; + + return ( +
+ {/* Category header */} +
+ + {category} + +
+ + {/* Items */} + {items.map(item => { + const result = getResult(item.id); + const isSelected = selectedItemId === item.id; + const diffCount = item.diffMap + ? Object.values(item.diffMap).reduce((a, b) => a + b, 0) + : 0; + + return ( +
onSelectItem(item.id)} + className={`flex items-center gap-2 px-3 py-1.5 cursor-default transition-colors duration-100 ${ + isSelected ? 'bg-muted/50' : 'hover:bg-muted/20' + }`} + > + {/* Status dot */} + + + {/* Check text */} + + {item.check} + + + {/* Diff count badge */} + {diffCount > 0 && ( + + {diffCount} + + )} + + {/* Compact P/F/S actions */} +
e.stopPropagation()}> + {(['passed', 'failed', 'skipped'] as const).map(s => ( + onSetStatus(item.id, result.status === s ? 'pending' : s)} + size={compact ? 'xs' : 'sm'} + /> + ))} +
+
+ ); + })} +
+ ); + })} +
+ ); +}; diff --git a/packages/checklist-editor/components/CoverageView.tsx b/packages/checklist-editor/components/CoverageView.tsx new file mode 100644 index 00000000..abdab5a0 --- /dev/null +++ b/packages/checklist-editor/components/CoverageView.tsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { CoverageFileTree } from './CoverageFileTree'; +import { CoverageTaskList } from './CoverageTaskList'; +import { getCoverageLayout, saveCoverageLayout } from '@plannotator/ui/utils/uiPreferences'; +import type { CoverageLayout } from '@plannotator/ui/utils/uiPreferences'; +import type { CoverageData } from '../hooks/useChecklistCoverage'; +import type { ChecklistItem, ChecklistItemStatus, ChecklistItemResult } from '@plannotator/shared/checklist-types'; + +interface CoverageViewProps { + coverageData: CoverageData; + items: ChecklistItem[]; + categories: string[]; + groupedItems: Map; + selectedItemId: string | null; + getResult: (id: string) => ChecklistItemResult; + onSetStatus: (id: string, status: ChecklistItemStatus) => void; + onSelectItem: (id: string) => void; +} + +const StackedIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + +); + +const SideBySideIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + + +); + +export const CoverageView: React.FC = ({ + coverageData, + items, + categories, + groupedItems, + selectedItemId, + getResult, + onSetStatus, + onSelectItem, +}) => { + const [layout, setLayoutRaw] = useState(getCoverageLayout); + const setLayout = (l: CoverageLayout) => { setLayoutRaw(l); saveCoverageLayout(l); }; + const isSideBySide = layout === 'side-by-side'; + + return ( +
+ {/* Header: global progress + layout toggle */} +
+ + {coverageData.globalCovered} / {coverageData.globalTotal} diffs covered + + 0 + ? 'text-foreground/70' + : 'text-muted-foreground/40' + }`}> + ({coverageData.globalPercent}%) + +
+
+
+ + {/* Layout toggle */} +
+ + +
+
+ + {/* Content: stacked or side-by-side */} +
+ {/* File tree */} +
+ +
+ + {/* Task list */} +
+ +
+
+
+ ); +}; diff --git a/packages/checklist-editor/components/PRBalance.tsx b/packages/checklist-editor/components/PRBalance.tsx new file mode 100644 index 00000000..f191b8f3 --- /dev/null +++ b/packages/checklist-editor/components/PRBalance.tsx @@ -0,0 +1,349 @@ +import React, { useMemo, useState, useRef, useCallback } from 'react'; +import type { FileDiffInfo } from '@plannotator/shared/checklist-types'; + +interface PRBalanceProps { + fileDiffs: Record; +} + +// --- Helpers --- + +interface FileEntry { + file: string; + lines: number; + status: 'new' | 'modified'; +} + +function toEntries(fileDiffs: Record): FileEntry[] { + return Object.entries(fileDiffs).map(([file, val]) => { + if (typeof val === 'number') { + return { file, lines: val, status: 'modified' as const }; + } + return { file, lines: val.lines, status: val.status }; + }); +} + +interface TreemapRect extends FileEntry { + tx: number; ty: number; tw: number; th: number; +} + +function squarify(items: FileEntry[], x: number, y: number, w: number, h: number): TreemapRect[] { + const rects: TreemapRect[] = []; + const total = items.reduce((s, i) => s + i.lines, 0); + if (!total || !items.length) return rects; + + let remaining = [...items].sort((a, b) => b.lines - a.lines); + let cx = x, cy = y, cw = w, ch = h; + + while (remaining.length) { + const isWide = cw >= ch; + const side = isWide ? ch : cw; + const areaLeft = remaining.reduce((s, i) => s + i.lines, 0); + const scale = (cw * ch) / areaLeft; + let row = [remaining[0]]; + let rowArea = remaining[0].lines * scale; + + for (let i = 1; i < remaining.length; i++) { + const newRow = [...row, remaining[i]]; + const newRowArea = rowArea + remaining[i].lines * scale; + const worstNew = newRow.reduce((worst, item) => { + const a = item.lines * scale; + const rl = newRowArea / side; + const is2 = a / rl; + return Math.max(worst, Math.max(rl / is2, is2 / rl)); + }, 0); + const worstOld = row.reduce((worst, item) => { + const a = item.lines * scale; + const rl = rowArea / side; + const is2 = a / rl; + return Math.max(worst, Math.max(rl / is2, is2 / rl)); + }, 0); + if (worstNew <= worstOld) { row = newRow; rowArea = newRowArea; } + else break; + } + + const rowLen = rowArea / side; + let offset = 0; + for (const item of row) { + const itemArea = item.lines * scale; + const itemLen = itemArea / rowLen; + if (isWide) rects.push({ ...item, tx: cx, ty: cy + offset, tw: rowLen, th: itemLen }); + else rects.push({ ...item, tx: cx + offset, ty: cy, tw: itemLen, th: rowLen }); + offset += itemLen; + } + if (isWide) { cx += rowLen; cw -= rowLen; } + else { cy += rowLen; ch -= rowLen; } + remaining = remaining.slice(row.length); + } + + return rects; +} + +// --- Constants --- +const SVG_W = 680; +const MARGIN_L = 30; +const MARGIN_R = 30; +const BEAM_Y = 420; +const BAR_FLOOR = BEAM_Y - 6; +const MAX_BAR_H = 350; +const BIN_TOP = 50; +const BIN_BOT = BEAM_Y - 16; +const BIN_H = BIN_BOT - BIN_TOP; +const BIN_GAP = 24; + +// --- Component --- + +export const PRBalance: React.FC = ({ fileDiffs }) => { + const [collapsed, setCollapsed] = useState(false); + const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null); + const svgRef = useRef(null); + + const entries = useMemo(() => toEntries(fileDiffs), [fileDiffs]); + + // Check if we have enriched data (at least one FileDiffInfo object) + const hasEnrichedData = useMemo( + () => Object.values(fileDiffs).some(v => typeof v === 'object'), + [fileDiffs], + ); + + // If no enriched data, don't render — PR Balance needs lines + status + if (!hasEnrichedData || entries.length === 0) return null; + + // All layout geometry derived from entries in one pass + const layout = useMemo(() => { + // U-shape: modified descending (tallest at left edge), new ascending (tallest at right edge) + const mod = entries.filter(e => e.status === 'modified').sort((a, b) => b.lines - a.lines); + const nw = entries.filter(e => e.status === 'new').sort((a, b) => a.lines - b.lines); + const all = [...mod, ...nw]; + const N = all.length; + const split = mod.length; + const totalMod = mod.reduce((s, f) => s + f.lines, 0); + const totalNew = nw.reduce((s, f) => s + f.lines, 0); + const totalAll = totalMod + totalNew; + const maxLines = Math.max(...all.map(f => f.lines)); + + const chartW = SVG_W - MARGIN_L - MARGIN_R; + const GAP = 2; + const BAR_W = Math.max(2, Math.floor((chartW - GAP * (N - 1)) / N)); + const totalBarsW = BAR_W * N + GAP * (N - 1); + const offsetX = MARGIN_L + (chartW - totalBarsW) / 2; + const divXPos = offsetX + split * (BAR_W + GAP) - GAP / 2; + const modMidX = split > 0 ? offsetX + (split * (BAR_W + GAP) - GAP) / 2 : 0; + const newMidX = nw.length > 0 + ? offsetX + split * (BAR_W + GAP) + ((N - split) * (BAR_W + GAP) - GAP) / 2 + : 0; + + // Center of mass + const masses = all.map((f, i) => ({ x: offsetX + i * (BAR_W + GAP) + BAR_W / 2, m: f.lines })); + const totalM = masses.reduce((s, p) => s + p.m, 0); + const comX = masses.reduce((s, p) => s + p.x * p.m, 0) / totalM; + + // Treemap bins + const leftBinX = MARGIN_L; + const leftBinW = split > 0 ? divXPos - BIN_GAP / 2 - leftBinX : 0; + const rightBinX = divXPos + BIN_GAP / 2; + const rightBinW = SVG_W - MARGIN_R - rightBinX; + + const modTm = squarify(mod, leftBinX, BIN_TOP, leftBinW, BIN_H); + const newTm = squarify(nw, rightBinX, BIN_TOP, rightBinW, BIN_H); + const tmMap = new Map(); + modTm.forEach(t => tmMap.set(t.file + '_mod', t)); + newTm.forEach(t => tmMap.set(t.file + '_new', t)); + + const yTicks = [200, 400, 600, 800].filter(v => BAR_FLOOR - (v / maxLines) * MAX_BAR_H >= 26); + const pctNew = totalAll > 0 ? Math.round(totalNew / totalAll * 100) : 0; + + return { + modified: mod, newFiles: nw, allFiles: all, splitIndex: split, + totalMod, totalNew, maxLines, BAR_W, GAP, offsetX, divXPos, + modMidX, newMidX, comX, leftBinX, leftBinW, rightBinX, rightBinW, + treemapMap: tmMap, yTicks, pctNew, + }; + }, [entries]); + + const { + modified, newFiles, allFiles, splitIndex, + totalMod, totalNew, maxLines, BAR_W, GAP, offsetX, divXPos, + modMidX, newMidX, comX, leftBinX, leftBinW, rightBinX, rightBinW, + treemapMap, yTicks, pctNew, + } = layout; + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!tooltip) return; + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + setTooltip(prev => prev ? { ...prev, x: e.clientX - rect.left + 14, y: e.clientY - rect.top - 40 } : null); + }, [tooltip]); + + const showTip = useCallback((e: React.MouseEvent, content: string) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + setTooltip({ x: e.clientX - rect.left + 14, y: e.clientY - rect.top - 40, content }); + }, []); + + const hideTip = useCallback(() => setTooltip(null), []); + + return ( +
+ {/* Toggle button */} +
+ + + {collapsed ? 'Showing binned mass' : 'Showing individual files'} + +
+ + + {/* Divider line (dashed) */} + {splitIndex > 0 && newFiles.length > 0 && ( + + )} + + {/* Beam line */} + + + {/* Header labels */} + {modified.length > 0 && ( + + Modified · {modified.length} files · {totalMod.toLocaleString()} lines + + )} + {newFiles.length > 0 && ( + + New · {newFiles.length} files · {totalNew.toLocaleString()} lines + + )} + + {/* Y-axis ticks */} + {yTicks.map(v => { + const y = BAR_FLOOR - (v / maxLines) * MAX_BAR_H; + return ( + + + + {v} + + + ); + })} + + {/* Center of mass indicator */} + + + + + + Center of mass + + + {pctNew >= 50 ? `${pctNew}% of weight is new files` : `${100 - pctNew}% of weight is modified files`} + + + + {/* Bin outlines (treemap mode) */} + {modified.length > 0 && ( + + )} + {newFiles.length > 0 && ( + + )} + + {/* Bars / Treemap rects */} + {allFiles.map((f, i) => { + const isMod = i < splitIndex; + const barX = offsetX + i * (BAR_W + GAP); + const barH = (f.lines / maxLines) * MAX_BAR_H; + const barY = BAR_FLOOR - barH; + const key = f.file + (isMod ? '_mod' : '_new'); + const tm = treemapMap.get(key); + const pad = 1.5; + + // Determine position based on mode + const rx = collapsed && tm ? tm.tx + pad : barX; + const ry = collapsed && tm ? tm.ty + pad : barY; + const rw = collapsed && tm ? Math.max(tm.tw - pad * 2, 1) : BAR_W; + const rh = collapsed && tm ? Math.max(tm.th - pad * 2, 1) : barH; + + const colorClass = isMod ? 'fill-blue-500/80' : 'fill-success'; + + return ( + + showTip(e, `${f.file}\n${f.lines} lines · ${isMod ? 'modified' : 'new'}`)} + onMouseLeave={hideTip} + /> + {/* Treemap label (only in collapsed mode for large enough cells) */} + {collapsed && tm && tm.tw > 55 && tm.th > 28 && ( + + + {f.file.length > Math.floor(tm.tw / 7) ? f.file.slice(0, Math.floor(tm.tw / 7) - 2) + '...' : f.file} + + + {f.lines} lines + + + )} + + ); + })} + + + {/* Tooltip */} + {tooltip && ( +
+ {tooltip.content} +
+ )} +
+ ); +}; diff --git a/packages/checklist-editor/components/ProgressBar.tsx b/packages/checklist-editor/components/ProgressBar.tsx new file mode 100644 index 00000000..32379c2d --- /dev/null +++ b/packages/checklist-editor/components/ProgressBar.tsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from 'react'; +import type { StatusCounts } from '../hooks/useChecklistProgress'; + +interface ProgressBarProps { + counts: StatusCounts; + stopped?: boolean; + className?: string; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const mm = String(minutes).padStart(2, '0'); + const ss = String(seconds).padStart(2, '0'); + + if (hours > 0) { + const hh = String(hours).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + } + return `${mm}:${ss}`; +} + +export const ProgressBar: React.FC = ({ counts, stopped, className }) => { + const { passed, failed, skipped, pending, total } = counts; + const [elapsed, setElapsed] = useState(0); + const [startTime] = useState(() => Date.now()); + + useEffect(() => { + if (stopped) return; + const interval = setInterval(() => { + setElapsed(Date.now() - startTime); + }, 1000); + return () => clearInterval(interval); + }, [startTime, stopped]); + + if (total === 0) return null; + + const reviewed = passed + failed + skipped; + const pctValue = Math.round((reviewed / total) * 100); + const pct = (n: number) => `${(n / total) * 100}%`; + + return ( +
+
+ + {formatElapsed(elapsed)} + + + {pctValue}% — {reviewed}/{total} reviewed + +
+
+ {passed > 0 && ( +
+ )} + {failed > 0 && ( +
+ )} + {skipped > 0 && ( +
+ )} + {pending > 0 && ( +
+ )} +
+
+ ); +}; diff --git a/packages/checklist-editor/components/StatusButton.tsx b/packages/checklist-editor/components/StatusButton.tsx new file mode 100644 index 00000000..72e77b7b --- /dev/null +++ b/packages/checklist-editor/components/StatusButton.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import type { ChecklistItemStatus } from '../hooks/useChecklistState'; + +// --------------------------------------------------------------------------- +// Icons +// --------------------------------------------------------------------------- + +const PassIcon: React.FC<{ className?: string }> = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +const FailIcon: React.FC<{ className?: string }> = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +const SkipIcon: React.FC<{ className?: string }> = ({ className = 'w-3.5 h-3.5' }) => ( + + + +); + +// --------------------------------------------------------------------------- +// Status Icon (for display) +// --------------------------------------------------------------------------- + +export const StatusIcon: React.FC<{ status: ChecklistItemStatus; className?: string }> = ({ + status, + className = 'w-4 h-4', +}) => { + switch (status) { + case 'passed': + return ( +
+ +
+ ); + case 'failed': + return ( +
+ +
+ ); + case 'skipped': + return ( +
+ +
+ ); + default: + return ( +
+
+
+ ); + } +}; + +// --------------------------------------------------------------------------- +// Action Buttons +// --------------------------------------------------------------------------- + +interface StatusButtonProps { + status: ChecklistItemStatus; + currentStatus: ChecklistItemStatus; + onClick: () => void; + size?: 'xs' | 'sm' | 'md'; +} + +const CONFIG: Record< + 'passed' | 'failed' | 'skipped', + { label: string; shortcut: string; activeClass: string; hoverClass: string; Icon: React.FC<{ className?: string }> } +> = { + passed: { + label: 'Pass', + shortcut: 'P', + activeClass: 'bg-success text-success-foreground', + hoverClass: 'hover:bg-success/15 hover:text-success', + Icon: PassIcon, + }, + failed: { + label: 'Fail', + shortcut: 'F', + activeClass: 'bg-destructive text-destructive-foreground', + hoverClass: 'hover:bg-destructive/15 hover:text-destructive', + Icon: FailIcon, + }, + skipped: { + label: 'Skip', + shortcut: 'S', + activeClass: 'bg-warning text-warning-foreground', + hoverClass: 'hover:bg-warning/15 hover:text-warning', + Icon: SkipIcon, + }, +}; + +export const StatusButton: React.FC = ({ + status, + currentStatus, + onClick, + size = 'md', +}) => { + if (status === 'pending') return null; + const cfg = CONFIG[status]; + const isActive = currentStatus === status; + const sizeClass = size === 'xs' + ? 'p-1' + : size === 'sm' + ? 'px-1.5 py-0.5 text-[10px] gap-0.5' + : 'px-2.5 py-1.5 text-xs gap-1.5'; + const iconClass = size === 'xs' ? 'w-2.5 h-2.5' : size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'; + + return ( + + ); +}; + +// --------------------------------------------------------------------------- +// Quick Action Buttons (compact row for hover) +// --------------------------------------------------------------------------- + +interface QuickActionsProps { + currentStatus: ChecklistItemStatus; + onSetStatus: (status: ChecklistItemStatus) => void; +} + +export const QuickActions: React.FC = ({ currentStatus, onSetStatus }) => ( +
e.stopPropagation()}> + {(['passed', 'failed', 'skipped'] as const).map(s => ( + onSetStatus(currentStatus === s ? 'pending' : s)} + /> + ))} +
+); diff --git a/packages/checklist-editor/components/ViewModeToggle.tsx b/packages/checklist-editor/components/ViewModeToggle.tsx new file mode 100644 index 00000000..6e14b3b5 --- /dev/null +++ b/packages/checklist-editor/components/ViewModeToggle.tsx @@ -0,0 +1,48 @@ +import React, { useState, useEffect } from 'react'; +import { ToolstripButton } from '@plannotator/ui/components/ToolstripButton'; +import type { ChecklistViewMode } from '@plannotator/shared/checklist-types'; + +interface ViewModeToggleProps { + mode: ChecklistViewMode; + onModeChange: (mode: ChecklistViewMode) => void; +} + +export const ViewModeToggle: React.FC = ({ mode, onModeChange }) => { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + requestAnimationFrame(() => setMounted(true)); + }, []); + + return ( +
+ onModeChange('checklist')} + label="Checklist" + color="primary" + mounted={mounted} + icon={ + + + + } + /> + onModeChange('coverage')} + label="Coverage" + color="secondary" + mounted={mounted} + icon={ + + + + + + + } + /> +
+ ); +}; diff --git a/packages/checklist-editor/components/WaffleCells.tsx b/packages/checklist-editor/components/WaffleCells.tsx new file mode 100644 index 00000000..6aac8ce4 --- /dev/null +++ b/packages/checklist-editor/components/WaffleCells.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +// Cells are allocated in severity order: red (failed) first, then yellow +// (skipped), then green (passed), then gray (pending). When items overlap +// on the same file, the worst outcome takes visual priority — a passing +// check never hides a failure. + +interface WaffleCellsProps { + total: number; + passed: number; + failed: number; + skipped: number; + maxCells?: number; + cellSize?: number; +} + +const CELL_CLASSES = { + failed: 'bg-destructive rounded-[1px] transition-colors duration-200', + skipped: 'bg-warning rounded-[1px] transition-colors duration-200', + passed: 'bg-success rounded-[1px] transition-colors duration-200', + pending: 'bg-muted-foreground/15 rounded-[1px] transition-colors duration-200', +} as const; + +export const WaffleCells: React.FC = ({ + total, + passed, + failed, + skipped, + maxCells = 20, + cellSize = 7, +}) => { + if (total === 0) return null; + + const covered = passed + failed + skipped; + + // When total exceeds maxCells, compress proportionally + let cellCount: number; + let redCount: number; + let yellowCount: number; + let greenCount: number; + + if (total <= maxCells) { + cellCount = total; + redCount = failed; + yellowCount = skipped; + greenCount = passed; + } else { + cellCount = maxCells; + redCount = Math.round((failed / total) * maxCells); + yellowCount = Math.round((skipped / total) * maxCells); + greenCount = Math.round((passed / total) * maxCells); + // Clamp so we don't exceed cellCount due to rounding + const colorTotal = redCount + yellowCount + greenCount; + if (colorTotal > cellCount) { + greenCount = Math.max(0, cellCount - redCount - yellowCount); + } + } + + return ( +
+ {Array.from({ length: cellCount }, (_, i) => { + let cls: string; + if (i < redCount) cls = CELL_CLASSES.failed; + else if (i < redCount + yellowCount) cls = CELL_CLASSES.skipped; + else if (i < redCount + yellowCount + greenCount) cls = CELL_CLASSES.passed; + else cls = CELL_CLASSES.pending; + + return ( +
+ ); + })} +
+ ); +}; diff --git a/packages/checklist-editor/hooks/useChecklistCoverage.ts b/packages/checklist-editor/hooks/useChecklistCoverage.ts new file mode 100644 index 00000000..2ffd5844 --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistCoverage.ts @@ -0,0 +1,184 @@ +import { useMemo } from 'react'; +import type { ChecklistItem, ChecklistItemResult, FileDiffInfo } from '@plannotator/shared/checklist-types'; + +export interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'dir'; + totalDiffs: number; + passedDiffs: number; + failedDiffs: number; + skippedDiffs: number; + children?: FileTreeNode[]; +} + +export interface CoverageData { + tree: FileTreeNode[]; + globalCovered: number; + globalTotal: number; + globalPercent: number; +} + +interface FileCoverageEntry { + total: number; + passed: number; + failed: number; + skipped: number; +} + +// Waffle cells are a diagnostic map, not a progress bar. +// +// Each cell represents a diff hunk. Color shows the verification outcome: +// red = failed (problem found) +// yellow = skipped (acknowledged but unverified) +// green = passed (verified and cleared) +// gray = pending (not yet examined) +// +// When multiple items cover the same file, cells are allocated in severity +// order: red first, then yellow, then green. Each is clamped so the sum +// never exceeds the file's total hunks. This means failed items always +// dominate overlapping coverage — a passing check doesn't hide a failure. + +/** Extract hunk count from a fileDiffs value (number or FileDiffInfo object). */ +function getHunks(val: number | FileDiffInfo): number { + return typeof val === 'number' ? val : val.hunks; +} + +function computeFileCoverage( + fileDiffs: Record, + items: ChecklistItem[], + results: Map, +): Map { + const coverage = new Map(); + + for (const [file, val] of Object.entries(fileDiffs)) { + coverage.set(file, { total: getHunks(val), passed: 0, failed: 0, skipped: 0 }); + } + + for (const item of items) { + if (!item.diffMap) continue; + const result = results.get(item.id); + const status = result?.status ?? 'pending'; + if (status === 'pending') continue; + + for (const [file, hunks] of Object.entries(item.diffMap)) { + const entry = coverage.get(file); + if (!entry) continue; + + if (status === 'passed') entry.passed += hunks; + else if (status === 'failed') entry.failed += hunks; + else if (status === 'skipped') entry.skipped += hunks; + } + } + + // Clamp each bucket so the sum doesn't exceed total. + // Severity order: failed eats into capacity first, then skipped, then passed. + for (const entry of coverage.values()) { + entry.failed = Math.min(entry.failed, entry.total); + entry.skipped = Math.min(entry.skipped, entry.total - entry.failed); + entry.passed = Math.min(entry.passed, entry.total - entry.failed - entry.skipped); + } + + return coverage; +} + +function buildFileTree( + fileCoverage: Map, +): FileTreeNode[] { + const root: FileTreeNode[] = []; + + for (const [filePath, entry] of fileCoverage) { + const segments = filePath.split('/'); + let current = root; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isFile = i === segments.length - 1; + const fullPath = segments.slice(0, i + 1).join('/'); + + let node = current.find(n => n.name === segment); + if (!node) { + node = { + name: segment, + path: fullPath, + type: isFile ? 'file' : 'dir', + totalDiffs: isFile ? entry.total : 0, + passedDiffs: isFile ? entry.passed : 0, + failedDiffs: isFile ? entry.failed : 0, + skippedDiffs: isFile ? entry.skipped : 0, + ...(isFile ? {} : { children: [] }), + }; + current.push(node); + } + + if (isFile) { + node.totalDiffs = entry.total; + node.passedDiffs = entry.passed; + node.failedDiffs = entry.failed; + node.skippedDiffs = entry.skipped; + } else { + current = node.children!; + } + } + } + + function aggregate(nodes: FileTreeNode[]): { total: number; passed: number; failed: number; skipped: number } { + let total = 0, passed = 0, failed = 0, skipped = 0; + for (const node of nodes) { + if (node.type === 'dir' && node.children) { + const child = aggregate(node.children); + node.totalDiffs = child.total; + node.passedDiffs = child.passed; + node.failedDiffs = child.failed; + node.skippedDiffs = child.skipped; + } + total += node.totalDiffs; + passed += node.passedDiffs; + failed += node.failedDiffs; + skipped += node.skippedDiffs; + } + return { total, passed, failed, skipped }; + } + + aggregate(root); + + function sortTree(nodes: FileTreeNode[]) { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.children) sortTree(node.children); + } + } + sortTree(root); + + return root; +} + +export function useChecklistCoverage( + fileDiffs: Record | undefined, + items: ChecklistItem[], + results: Map, +): CoverageData | null { + return useMemo(() => { + if (!fileDiffs || Object.keys(fileDiffs).length === 0) return null; + + const fileCoverage = computeFileCoverage(fileDiffs, items, results); + const tree = buildFileTree(fileCoverage); + + let globalCovered = 0; + let globalTotal = 0; + for (const entry of fileCoverage.values()) { + globalCovered += entry.passed + entry.failed + entry.skipped; + globalTotal += entry.total; + } + + return { + tree, + globalCovered, + globalTotal, + globalPercent: globalTotal > 0 ? Math.round((globalCovered / globalTotal) * 100) : 0, + }; + }, [fileDiffs, items, results]); +} diff --git a/packages/checklist-editor/hooks/useChecklistDraft.ts b/packages/checklist-editor/hooks/useChecklistDraft.ts new file mode 100644 index 00000000..a3ad9190 --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistDraft.ts @@ -0,0 +1,122 @@ +/** + * Auto-save checklist results to the server as a draft. + * + * Follows the pattern from useCodeAnnotationDraft — debounced POST to /api/draft, + * load on mount, restore/dismiss via banner. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { formatTimeAgo } from '@plannotator/ui/utils/timeFormat'; +import type { ChecklistItemResult } from './useChecklistState'; + +const DEBOUNCE_MS = 500; + +interface DraftData { + checklistResults: ChecklistItemResult[]; + globalNotes?: string[] | string; + ts: number; +} + +interface UseChecklistDraftOptions { + results: ChecklistItemResult[]; + globalNotes: string[]; + isApiMode: boolean; + submitted: boolean; +} + +interface UseChecklistDraftResult { + draftBanner: { count: number; timeAgo: string } | null; + restoreDraft: () => { results: ChecklistItemResult[]; globalNotes: string[] } | null; + dismissDraft: () => void; +} + +export function useChecklistDraft({ + results, + globalNotes, + isApiMode, + submitted, +}: UseChecklistDraftOptions): UseChecklistDraftResult { + const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string } | null>(null); + const draftDataRef = useRef(null); + const timerRef = useRef | null>(null); + const hasMountedRef = useRef(false); + + // Load draft on mount + useEffect(() => { + if (!isApiMode) return; + + fetch('/api/draft') + .then(res => { + if (!res.ok) return null; + return res.json(); + }) + .then((data: DraftData | null) => { + if ( + data?.checklistResults && + Array.isArray(data.checklistResults) && + data.checklistResults.length > 0 + ) { + const reviewed = data.checklistResults.filter(r => r.status !== 'pending'); + if (reviewed.length > 0) { + draftDataRef.current = data; + setDraftBanner({ + count: reviewed.length, + timeAgo: formatTimeAgo(data.ts || 0), + }); + } + } + hasMountedRef.current = true; + }) + .catch(() => { + hasMountedRef.current = true; + }); + }, [isApiMode]); + + // Debounced auto-save on result changes + useEffect(() => { + if (!isApiMode || submitted) return; + if (!hasMountedRef.current) return; + + const reviewed = results.filter(r => r.status !== 'pending'); + if (reviewed.length === 0) return; + + if (timerRef.current) clearTimeout(timerRef.current); + + timerRef.current = setTimeout(() => { + const payload: DraftData = { + checklistResults: results, + globalNotes: globalNotes.length > 0 ? globalNotes : undefined, + ts: Date.now(), + }; + + fetch('/api/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).catch(() => {}); + }, DEBOUNCE_MS); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [results, globalNotes, isApiMode, submitted]); + + const restoreDraft = useCallback(() => { + const data = draftDataRef.current; + setDraftBanner(null); + draftDataRef.current = null; + if (!data) return null; + return { + results: data.checklistResults, + globalNotes: Array.isArray(data.globalNotes) ? data.globalNotes : data.globalNotes ? [data.globalNotes] : [], + }; + }, []); + + const dismissDraft = useCallback(() => { + setDraftBanner(null); + draftDataRef.current = null; + fetch('/api/draft', { method: 'DELETE' }).catch(() => {}); + }, []); + + return { draftBanner, restoreDraft, dismissDraft }; +} diff --git a/packages/checklist-editor/hooks/useChecklistProgress.ts b/packages/checklist-editor/hooks/useChecklistProgress.ts new file mode 100644 index 00000000..56b01c82 --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistProgress.ts @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import type { ChecklistItem, ChecklistItemResult, ChecklistItemStatus } from './useChecklistState'; + +export interface StatusCounts { + passed: number; + failed: number; + skipped: number; + pending: number; + total: number; +} + +export interface CategoryProgress { + category: string; + reviewed: number; + total: number; + passed: number; + failed: number; + skipped: number; +} + +export type SubmitState = + | 'all-pending' + | 'partial' + | 'all-reviewed-with-failures' + | 'all-passed'; + +export function useChecklistProgress( + items: ChecklistItem[], + results: Map, +) { + const counts = useMemo((): StatusCounts => { + const c: StatusCounts = { passed: 0, failed: 0, skipped: 0, pending: 0, total: items.length }; + for (const item of items) { + const r = results.get(item.id); + const status: ChecklistItemStatus = r?.status ?? 'pending'; + c[status]++; + } + return c; + }, [items, results]); + + const categoryProgress = useMemo((): Map => { + const map = new Map(); + for (const item of items) { + let cp = map.get(item.category); + if (!cp) { + cp = { category: item.category, reviewed: 0, total: 0, passed: 0, failed: 0, skipped: 0 }; + map.set(item.category, cp); + } + cp.total++; + const r = results.get(item.id); + const status: ChecklistItemStatus = r?.status ?? 'pending'; + if (status !== 'pending') { + cp.reviewed++; + if (status === 'passed') cp.passed++; + if (status === 'failed') cp.failed++; + if (status === 'skipped') cp.skipped++; + } + } + return map; + }, [items, results]); + + const submitState = useMemo((): SubmitState => { + const reviewed = counts.passed + counts.failed + counts.skipped; + if (reviewed === 0) return 'all-pending'; + if (reviewed < items.length) return 'partial'; + if (counts.failed > 0) return 'all-reviewed-with-failures'; + return 'all-passed'; + }, [items.length, counts]); + + return { counts, categoryProgress, submitState }; +} diff --git a/packages/checklist-editor/hooks/useChecklistState.ts b/packages/checklist-editor/hooks/useChecklistState.ts new file mode 100644 index 00000000..3a7237aa --- /dev/null +++ b/packages/checklist-editor/hooks/useChecklistState.ts @@ -0,0 +1,197 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { + Checklist, + ChecklistItem, + ChecklistItemStatus, + ChecklistItemResult, +} from '@plannotator/shared/checklist-types'; + +export type { Checklist, ChecklistItem, ChecklistItemStatus, ChecklistItemResult }; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +interface UseChecklistStateOptions { + items: ChecklistItem[]; +} + +export function useChecklistState({ items }: UseChecklistStateOptions) { + const [results, setResults] = useState>(() => { + const map = new Map(); + for (const item of items) { + map.set(item.id, { id: item.id, status: 'pending' }); + } + return map; + }); + + const [selectedItemId, setSelectedItemId] = useState( + items.length > 0 ? items[0].id : null, + ); + + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + // Categories in order of first appearance + const categories = useMemo(() => { + const seen = new Set(); + const cats: string[] = []; + for (const item of items) { + if (!seen.has(item.category)) { + seen.add(item.category); + cats.push(item.category); + } + } + return cats; + }, [items]); + + // Grouped items + const groupedItems = useMemo(() => { + const map = new Map(); + for (const cat of categories) { + map.set(cat, []); + } + for (const item of items) { + map.get(item.category)!.push(item); + } + return map; + }, [items, categories]); + + // Flat ordered list for keyboard navigation + const flatItemIds = useMemo(() => { + const ids: string[] = []; + for (const cat of categories) { + if (collapsedGroups.has(cat)) continue; + const group = groupedItems.get(cat); + if (group) { + for (const item of group) ids.push(item.id); + } + } + return ids; + }, [categories, groupedItems, collapsedGroups]); + + // --- Actions --- + + const setStatus = useCallback((id: string, status: ChecklistItemStatus) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id); + next.set(id, { ...existing, id, status }); + return next; + }); + }, []); + + const addNote = useCallback((id: string, note: string) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id) || { id, status: 'pending' as const }; + const notes = [...(existing.notes || []), note]; + next.set(id, { ...existing, notes }); + return next; + }); + }, []); + + const removeNote = useCallback((id: string, index: number) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id); + if (!existing?.notes) return prev; + const notes = existing.notes.filter((_, i) => i !== index); + next.set(id, { ...existing, notes: notes.length > 0 ? notes : undefined }); + return next; + }); + }, []); + + const setImages = useCallback((id: string, images: { path: string; name: string }[]) => { + setResults(prev => { + const next = new Map(prev); + const existing = next.get(id) || { id, status: 'pending' as const }; + next.set(id, { ...existing, images: images.length > 0 ? images : undefined }); + return next; + }); + }, []); + + const selectItem = useCallback((id: string | null) => { + setSelectedItemId(id); + }, []); + + const selectNext = useCallback((): string | null => { + if (!selectedItemId) { + if (flatItemIds.length > 0) { + setSelectedItemId(flatItemIds[0]); + return flatItemIds[0]; + } + return null; + } + const idx = flatItemIds.indexOf(selectedItemId); + if (idx < flatItemIds.length - 1) { + setSelectedItemId(flatItemIds[idx + 1]); + return flatItemIds[idx + 1]; + } + return selectedItemId; + }, [selectedItemId, flatItemIds]); + + const selectPrev = useCallback((): string | null => { + if (!selectedItemId) { + if (flatItemIds.length > 0) { + const last = flatItemIds[flatItemIds.length - 1]; + setSelectedItemId(last); + return last; + } + return null; + } + const idx = flatItemIds.indexOf(selectedItemId); + if (idx > 0) { + setSelectedItemId(flatItemIds[idx - 1]); + return flatItemIds[idx - 1]; + } + return selectedItemId; + }, [selectedItemId, flatItemIds]); + + const toggleGroup = useCallback((category: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }, []); + + const getResult = useCallback((id: string): ChecklistItemResult => { + return results.get(id) || { id, status: 'pending' }; + }, [results]); + + const allResults = useMemo(() => Array.from(results.values()), [results]); + + const restoreResults = useCallback((restored: ChecklistItemResult[]) => { + setResults(prev => { + const next = new Map(prev); + for (const r of restored) { + next.set(r.id, r); + } + return next; + }); + }, []); + + return { + results, + allResults, + selectedItemId, + collapsedGroups, + categories, + groupedItems, + flatItemIds, + setStatus, + addNote, + removeNote, + setImages, + selectItem, + selectNext, + selectPrev, + toggleGroup, + getResult, + restoreResults, + }; +} diff --git a/packages/checklist-editor/index.css b/packages/checklist-editor/index.css new file mode 100644 index 00000000..a5c3e0f4 --- /dev/null +++ b/packages/checklist-editor/index.css @@ -0,0 +1,282 @@ +@import "tailwindcss"; + +/* Tell Tailwind where to scan for classes */ +@source "../ui/components/**/*.tsx"; +@source "../ui/hooks/**/*.ts"; +@source "./*.tsx"; +@source "./components/**/*.tsx"; + +@import "../ui/styles/theme.css"; + +/* ===================================================== + QA Checklist Specific Styles + ===================================================== */ + +/* Background grid — matches plan review document area */ +.bg-grid { + background-image: + linear-gradient(to right, oklch(0.32 0.02 260 / 0.5) 1px, transparent 1px), + linear-gradient(to bottom, oklch(0.32 0.02 260 / 0.5) 1px, transparent 1px); + background-size: 24px 24px; +} + +.light .bg-grid { + background-image: + linear-gradient(to right, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px), + linear-gradient(to bottom, oklch(0.90 0.01 260 / 0.6) 1px, transparent 1px); +} + +/* Checklist item — status expressed via background tint, not left border */ +.checklist-item { + transition: background-color 150ms, box-shadow 150ms, border-color 150ms; +} + +.checklist-item.passed { + background: oklch(from var(--success) l c h / 0.06); + border-color: oklch(from var(--success) l c h / 0.2); +} + +.checklist-item.failed { + background: oklch(from var(--destructive) l c h / 0.06); + border-color: oklch(from var(--destructive) l c h / 0.2); +} + +.checklist-item.skipped { + background: oklch(from var(--warning) l c h / 0.06); + border-color: oklch(from var(--warning) l c h / 0.2); +} + +.checklist-item.selected { + box-shadow: + 0 0 0 1px oklch(from var(--primary) l c h / 0.3), + 0 2px 8px -2px rgba(0, 0, 0, 0.12); +} + +/* Critical badge */ +.critical-badge { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--destructive-foreground); + background: var(--destructive); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} + +/* Progress bar segments */ +.progress-segment { + transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Category group header */ +.category-header { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-foreground); +} + +/* Quick action buttons — always visible in card layout */ +.quick-actions { + opacity: 1; +} + +/* Description preview with gradient mask fade */ +.checklist-item-preview { + max-height: 2.5em; + overflow: hidden; + mask-image: linear-gradient(to bottom, black 50%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%); +} + +/* Expandable body — CSS grid trick for smooth expand */ +.checklist-item-body { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 200ms ease; +} + +.checklist-item-body.expanded { + grid-template-rows: 1fr; +} + +.checklist-item-body > div { + min-height: 0; + overflow: hidden; +} + +/* Category group collapse — same CSS grid trick, slightly slower */ +.checklist-group-body { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.checklist-group-body.expanded { + grid-template-rows: 1fr; +} + +.checklist-group-body > div { + min-height: 0; + overflow: hidden; +} + +/* Category group compact summary — inverse of group-body */ +.checklist-group-compact { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.checklist-group-compact.expanded { + grid-template-rows: 1fr; +} + +.checklist-group-compact > div { + min-height: 0; + overflow: hidden; +} + +.compact-summary-inner { + padding: 0.25rem 0.25rem 0.5rem; +} + +.compact-summary-row { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + line-height: 1.4; + color: var(--muted-foreground); + padding: 0.125rem 0; + opacity: 0; + animation: compact-fade-in 200ms ease forwards; +} + +.compact-summary-row span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@keyframes compact-fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 0.7; + transform: translateY(0); + } +} + +.compact-summary-overflow { + display: flex; + align-items: center; + padding-top: 0.125rem; + font-size: 0.6875rem; + font-weight: 500; + color: var(--muted-foreground); + opacity: 0.4; + mask-image: linear-gradient(to bottom, black 30%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%); +} + +/* Document centering — matches plan review centered column */ +.checklist-document { + max-width: 52rem; + margin: 0 auto; + padding: 2rem 1.5rem; +} + + +/* Markdown rendered in description panel */ +.checklist-description { + font-size: 0.875rem; + line-height: 1.6; + color: var(--card-foreground); +} + +.checklist-description code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--muted); + padding: 0.1em 0.35em; + border-radius: 0.25rem; +} + +.checklist-description pre { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.5; + background: oklch(0.13 0.015 260); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + margin: 0.375rem 0; + overflow-x: auto; +} + +.light .checklist-description pre { + background: oklch(0.96 0.005 260); +} + +/* Verification steps list */ +.verification-steps { + counter-reset: step; + list-style: none; + padding: 0; +} + +.verification-steps li { + counter-increment: step; + position: relative; + padding-left: 2rem; + margin-bottom: 0.5rem; + font-size: 0.875rem; + line-height: 1.5; +} + +.verification-steps li::before { + content: counter(step); + position: absolute; + left: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--primary); + background: oklch(from var(--primary) l c h / 0.1); + border-radius: 50%; +} + + +/* Toast animations */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-in-from-bottom-2 { + from { transform: translateY(0.5rem) translateX(-50%); } + to { transform: translateY(0) translateX(-50%); } +} + +.animate-in { + animation-duration: 200ms; + animation-timing-function: ease-out; + animation-fill-mode: both; +} + +.fade-in { + animation-name: fade-in; +} + +.slide-in-from-bottom-2 { + animation-name: slide-in-from-bottom-2; +} diff --git a/packages/checklist-editor/package.json b/packages/checklist-editor/package.json new file mode 100644 index 00000000..99436842 --- /dev/null +++ b/packages/checklist-editor/package.json @@ -0,0 +1,17 @@ +{ + "name": "@plannotator/checklist-editor", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./App.tsx", + "./styles": "./index.css" + }, + "dependencies": { + "@plannotator/shared": "workspace:*", + "@plannotator/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwindcss": "^4.1.18" + } +} diff --git a/packages/checklist-editor/utils/exportChecklist.ts b/packages/checklist-editor/utils/exportChecklist.ts new file mode 100644 index 00000000..4e4c9ebd --- /dev/null +++ b/packages/checklist-editor/utils/exportChecklist.ts @@ -0,0 +1,87 @@ +import type { ChecklistItem, ChecklistItemResult, ChecklistItemStatus } from '../hooks/useChecklistState'; + +const STATUS_ICONS: Record = { + passed: '[PASS]', + failed: '[FAIL]', + skipped: '[SKIP]', + pending: '[----]', +}; + +/** + * Format checklist results as markdown feedback suitable for sending + * to the agent or copying to clipboard. + */ +export function exportChecklistResults( + items: ChecklistItem[], + results: Map, + globalNotes?: string[] | string, +): string { + const lines: string[] = []; + + lines.push('# QA Checklist Results\n'); + + // Summary counts + const counts = { passed: 0, failed: 0, skipped: 0, pending: 0 }; + for (const item of items) { + const r = results.get(item.id); + counts[r?.status ?? 'pending']++; + } + + const total = items.length; + const reviewed = counts.passed + counts.failed + counts.skipped; + lines.push(`**${reviewed}/${total}** items reviewed | ${counts.passed} passed | ${counts.failed} failed | ${counts.skipped} skipped\n`); + + // Group by category + const categories: string[] = []; + const grouped = new Map(); + for (const item of items) { + if (!grouped.has(item.category)) { + categories.push(item.category); + grouped.set(item.category, []); + } + grouped.get(item.category)!.push(item); + } + + for (const category of categories) { + const catItems = grouped.get(category)!; + lines.push(`## ${category}\n`); + + for (const item of catItems) { + const r = results.get(item.id); + const status = r?.status ?? 'pending'; + const icon = STATUS_ICONS[status]; + const critical = item.critical ? ' **[CRITICAL]**' : ''; + + lines.push(`### ${icon} ${item.check}${critical}\n`); + + if (status === 'failed') { + lines.push(`> **Status:** FAILED\n`); + } + + if (r?.notes && r.notes.length > 0) { + for (const note of r.notes) { + lines.push(`**Notes:** ${note}\n`); + } + } + + if (r?.images && r.images.length > 0) { + lines.push('**Evidence:**'); + for (const img of r.images) { + lines.push(`- [${img.name}] ${img.path}`); + } + lines.push(''); + } + } + } + + const notes = Array.isArray(globalNotes) ? globalNotes : globalNotes ? [globalNotes] : []; + if (notes.length > 0) { + lines.push('---\n'); + lines.push(`## Overall Notes\n`); + for (const note of notes) { + lines.push(`- ${note}\n`); + } + } + + return lines.join('\n'); +} diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index b9dd71a5..877e768c 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -6,128 +6,7 @@ @source "./*.tsx"; @source "./components/**/*.tsx"; -:root { - --background: oklch(0.15 0.02 260); - --foreground: oklch(0.90 0.01 260); - --card: oklch(0.22 0.02 260); - --card-foreground: oklch(0.90 0.01 260); - --popover: oklch(0.28 0.025 260); - --popover-foreground: oklch(0.90 0.01 260); - --primary: oklch(0.75 0.18 280); - --primary-foreground: oklch(0.15 0.02 260); - --secondary: oklch(0.65 0.15 180); - --secondary-foreground: oklch(0.15 0.02 260); - --muted: oklch(0.26 0.02 260); - --muted-foreground: oklch(0.72 0.02 260); - --accent: oklch(0.70 0.20 60); - --accent-foreground: oklch(0.15 0.02 260); - --destructive: oklch(0.65 0.20 25); - --destructive-foreground: oklch(0.98 0 0); - --border: oklch(0.35 0.02 260); - --input: oklch(0.26 0.02 260); - --ring: oklch(0.75 0.18 280); - --success: oklch(0.72 0.17 150); - --success-foreground: oklch(0.15 0.02 260); - --warning: oklch(0.75 0.15 85); - --warning-foreground: oklch(0.20 0.02 260); - - --font-sans: 'Inter', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; - --radius: 0.75rem; -} - -.light { - --background: oklch(0.97 0.005 260); - --foreground: oklch(0.18 0.02 260); - --card: oklch(1 0 0); - --card-foreground: oklch(0.18 0.02 260); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.18 0.02 260); - --primary: oklch(0.50 0.25 280); - --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.50 0.18 180); - --secondary-foreground: oklch(1 0 0); - --muted: oklch(0.92 0.01 260); - --muted-foreground: oklch(0.40 0.02 260); - --accent: oklch(0.60 0.22 50); - --accent-foreground: oklch(0.18 0.02 260); - --destructive: oklch(0.50 0.25 25); - --destructive-foreground: oklch(1 0 0); - --border: oklch(0.88 0.01 260); - --input: oklch(0.92 0.01 260); - --ring: oklch(0.50 0.25 280); - --success: oklch(0.45 0.20 150); - --success-foreground: oklch(1 0 0); - --warning: oklch(0.55 0.18 85); - --warning-foreground: oklch(0.18 0.02 260); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); -} - -* { - border-color: var(--border); -} - -body { - font-family: var(--font-sans); - background: var(--background); - color: var(--foreground); - font-feature-settings: "ss01", "ss02", "cv01"; -} - -/* Custom scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } - -/* Selection */ -::selection { - background: oklch(0.75 0.18 280 / 0.3); -} - -/* Smooth transitions */ -* { - transition-property: color, background-color, border-color, box-shadow, opacity, transform; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - -/* Focus states */ -:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} +@import "../ui/styles/theme.css"; /* ===================================================== Code Review Specific Styles diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 286c3817..92717221 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,7 +11,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { startServer } from "./serve"; import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete } from "./shared-handlers"; import { handleDoc } from "./reference-handlers"; @@ -60,9 +60,6 @@ export interface AnnotateServerResult { // --- Server Implementation --- -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - /** * Start the Annotate server * @@ -84,8 +81,6 @@ export async function startAnnotateServer( onReady, } = options; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); const draftKey = contentHash(markdown); // Detect repo info (cached for this session) @@ -103,125 +98,89 @@ export async function startAnnotateServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - port: configuredPort, - - async fetch(req) { - const url = new URL(req.url); - - // API: Get plan content (reuse /api/plan so the plan editor UI works) - if (url.pathname === "/api/plan" && req.method === "GET") { - return Response.json({ - plan: markdown, - origin, - mode: "annotate", - filePath, - sharingEnabled, - shareBaseUrl, - repoInfo, - }); - } - - // API: Serve images (local paths or temp uploads) - if (url.pathname === "/api/image") { - return handleImage(req); - } - - // API: Serve a linked markdown document - // Inject source file's directory as base for relative path resolution - if (url.pathname === "/api/doc" && req.method === "GET") { - if (!url.searchParams.has("base")) { - const docUrl = new URL(req.url); - docUrl.searchParams.set("base", dirname(filePath)); - return handleDoc(new Request(docUrl.toString())); - } - return handleDoc(req); - } - - // API: Upload image -> save to temp -> return path - if (url.pathname === "/api/upload" && req.method === "POST") { - return handleUpload(req); - } - - // API: Annotation draft persistence - if (url.pathname === "/api/draft") { - if (req.method === "POST") return handleDraftSave(req, draftKey); - if (req.method === "DELETE") return handleDraftDelete(draftKey); - return handleDraftLoad(draftKey); - } - - // API: Submit annotation feedback - if (url.pathname === "/api/feedback" && req.method === "POST") { - try { - const body = (await req.json()) as { - feedback: string; - annotations: unknown[]; - }; - - deleteDraft(draftKey); - resolveDecision({ - feedback: body.feedback || "", - annotations: body.annotations || [], - }); - - return Response.json({ ok: true }); - } catch (err) { - const message = - err instanceof Error - ? err.message - : "Failed to process feedback"; - return Response.json({ error: message }, { status: 500 }); - } - } - - // Serve embedded HTML for all other routes (SPA) - return new Response(htmlContent, { - headers: { "Content-Type": "text/html" }, - }); - }, - }); + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); + + // API: Get plan content (reuse /api/plan so the plan editor UI works) + if (url.pathname === "/api/plan" && req.method === "GET") { + return Response.json({ + plan: markdown, + origin, + mode: "annotate", + filePath, + sharingEnabled, + shareBaseUrl, + repoInfo, + }); + } - break; // Success, exit retry loop - } catch (err: unknown) { - const isAddressInUse = - err instanceof Error && err.message.includes("EADDRINUSE"); + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + return handleImage(req); + } - if (isAddressInUse && attempt < MAX_RETRIES) { - await Bun.sleep(RETRY_DELAY_MS); - continue; + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + return handleUpload(req); } - if (isAddressInUse) { - const hint = isRemote - ? " (set PLANNOTATOR_PORT to use different port)" - : ""; - throw new Error( - `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}` - ); + // API: Annotation draft persistence + if (url.pathname === "/api/draft") { + if (req.method === "POST") return handleDraftSave(req, draftKey); + if (req.method === "DELETE") return handleDraftDelete(draftKey); + return handleDraftLoad(draftKey); } - throw err; - } - } + // API: Serve a linked markdown document + // Inject source file's directory as base for relative path resolution + if (url.pathname === "/api/doc" && req.method === "GET") { + if (!url.searchParams.has("base")) { + const docUrl = new URL(req.url); + docUrl.searchParams.set("base", dirname(filePath)); + return handleDoc(new Request(docUrl.toString())); + } + return handleDoc(req); + } - if (!server) { - throw new Error("Failed to start server"); - } + // API: Submit annotation feedback + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as { + feedback: string; + annotations: unknown[]; + }; + + deleteDraft(draftKey); + resolveDecision({ + feedback: body.feedback || "", + annotations: body.annotations || [], + }); + + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to process feedback"; + return Response.json({ error: message }, { status: 500 }); + } + } - const serverUrl = `http://localhost:${server.port}`; + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/checklist.ts b/packages/server/checklist.ts new file mode 100644 index 00000000..3c4bec23 --- /dev/null +++ b/packages/server/checklist.ts @@ -0,0 +1,544 @@ +/** + * Checklist Server + * + * Serves a QA checklist for interactive developer verification. + * The agent produces structured JSON, this server serves it to + * the checklist UI and collects per-item pass/fail/skip results. + * + * Follows the same patterns as annotate.ts (simplest server). + * + * Environment variables: + * PLANNOTATOR_REMOTE - Set to "1" or "true" for remote/devcontainer mode + * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) + */ + +import { homedir } from "os"; +import { join } from "path"; +import { mkdirSync, writeFileSync } from "fs"; +import { startServer } from "./serve"; +import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete } from "./shared-handlers"; +import { contentHash, deleteDraft } from "./draft"; +import type { Checklist, ChecklistItem, ChecklistPR, ChecklistSubmission, ChecklistItemResult } from "@plannotator/shared/checklist-types"; + +// Re-export utilities +export { isRemoteSession, getServerPort } from "./remote"; +export { openBrowser } from "./browser"; +export { handleServerReady as handleChecklistServerReady } from "./shared-handlers"; + +// Re-export types for consumers +export type { Checklist, ChecklistItem, ChecklistPR, ChecklistSubmission, ChecklistItemResult }; + +// --- Types --- + +export interface ChecklistServerOptions { + /** Validated checklist JSON from the agent */ + checklist: Checklist; + /** HTML content to serve for the UI */ + htmlContent: string; + /** Origin identifier for UI customization */ + origin?: "opencode" | "claude-code" | "pi"; + /** Project name for storage scoping */ + project?: string; + /** Pre-existing results to restore (from saved checklist files) */ + initialResults?: ChecklistItemResult[]; + /** Pre-existing global notes to restore (from saved checklist files) */ + initialGlobalNotes?: string[]; + /** Called when server starts with the URL, remote status, and port */ + onReady?: (url: string, isRemote: boolean, port: number) => void; +} + +export interface ChecklistServerResult { + /** The port the server is running on */ + port: number; + /** The full URL to access the server */ + url: string; + /** Whether running in remote mode */ + isRemote: boolean; + /** Wait for user checklist submission */ + waitForDecision: () => Promise; + /** Stop the server */ + stop: () => void; +} + +export interface ChecklistDecision { + /** Formatted markdown feedback for the agent */ + feedback: string; + /** Per-item results */ + results: ChecklistItemResult[]; + /** Path where checklist + results were saved */ + savedTo?: string; + /** Optional agent switch target */ + agentSwitch?: string; +} + +// --- Validation --- + +/** + * Validate a checklist JSON object. + * Returns an array of error messages (empty = valid). + */ +export function validateChecklist(data: unknown): string[] { + const errors: string[] = []; + + if (!data || typeof data !== "object") { + errors.push("Checklist must be a JSON object."); + return errors; + } + + const obj = data as Record; + + if (typeof obj.title !== "string" || !obj.title.trim()) { + errors.push('Missing or empty "title" (string).'); + } + + if (typeof obj.summary !== "string" || !obj.summary.trim()) { + errors.push('Missing or empty "summary" (string).'); + } + + // Validate optional PR field + if (obj.pr !== undefined) { + if (typeof obj.pr !== "object" || obj.pr === null) { + errors.push('"pr" must be an object if provided.'); + } else { + const pr = obj.pr as Record; + if (typeof pr.number !== "number") { + errors.push('pr.number must be a number.'); + } + if (typeof pr.url !== "string" || !pr.url) { + errors.push('pr.url must be a non-empty string.'); + } + const validProviders = ["github", "gitlab", "azure-devops"]; + if (!validProviders.includes(pr.provider as string)) { + errors.push(`pr.provider must be one of: ${validProviders.join(", ")}.`); + } + } + } + + // Validate optional fileDiffs field (accepts number or {hunks, lines, status}) + if (obj.fileDiffs !== undefined) { + if (typeof obj.fileDiffs !== "object" || obj.fileDiffs === null || Array.isArray(obj.fileDiffs)) { + errors.push('"fileDiffs" must be an object mapping file paths to diff info.'); + } else { + for (const [key, val] of Object.entries(obj.fileDiffs as Record)) { + if (typeof val === "number") { + if (val < 1 || !Number.isInteger(val)) { + errors.push(`fileDiffs["${key}"] must be a positive integer.`); + } + } else if (typeof val === "object" && val !== null) { + const info = val as Record; + if (typeof info.hunks !== "number" || info.hunks < 1 || !Number.isInteger(info.hunks)) { + errors.push(`fileDiffs["${key}"].hunks must be a positive integer.`); + } + if (typeof info.lines !== "number" || info.lines < 1 || !Number.isInteger(info.lines)) { + errors.push(`fileDiffs["${key}"].lines must be a positive integer.`); + } + if (info.status !== "new" && info.status !== "modified") { + errors.push(`fileDiffs["${key}"].status must be "new" or "modified".`); + } + } else { + errors.push(`fileDiffs["${key}"] must be a positive integer or {hunks, lines, status} object.`); + } + } + } + } + + if (!Array.isArray(obj.items)) { + errors.push('"items" must be an array.'); + return errors; + } + + if (obj.items.length === 0) { + errors.push('"items" array is empty — include at least one checklist item.'); + } + + for (let i = 0; i < obj.items.length; i++) { + const item = obj.items[i] as Record; + const prefix = `items[${i}]`; + + if (typeof item.id !== "string" || !item.id.trim()) { + errors.push(`${prefix}: missing "id" (string, e.g. "func-1").`); + } + + if (typeof item.category !== "string" || !item.category.trim()) { + errors.push(`${prefix}: missing "category" (string, e.g. "functional").`); + } + + if (typeof item.check !== "string" || !item.check.trim()) { + errors.push(`${prefix}: missing "check" (imperative verb phrase).`); + } + + if (typeof item.description !== "string" || !item.description.trim()) { + errors.push(`${prefix}: missing "description" (markdown narrative).`); + } + + if (!Array.isArray(item.steps) || item.steps.length === 0) { + errors.push(`${prefix}: "steps" must be a non-empty array of strings.`); + } + + if (typeof item.reason !== "string" || !item.reason.trim()) { + errors.push(`${prefix}: missing "reason" (why manual verification is needed).`); + } + + // Validate optional diffMap + if (item.diffMap !== undefined) { + if (typeof item.diffMap !== "object" || item.diffMap === null || Array.isArray(item.diffMap)) { + errors.push(`${prefix}: "diffMap" must be an object mapping file paths to hunk counts.`); + } else { + for (const [key, val] of Object.entries(item.diffMap as Record)) { + if (typeof val !== "number" || val < 1 || !Number.isInteger(val)) { + errors.push(`${prefix}: diffMap["${key}"] must be a positive integer.`); + } + } + } + } + } + + return errors; +} + +// --- Feedback Formatting --- + +/** + * Format checklist results as markdown for the agent. + */ +export function formatChecklistFeedback( + checklist: Checklist, + results: ChecklistItemResult[], + globalNotes?: string[] | string, + automations?: { postToPR?: boolean; approveIfAllPass?: boolean }, +): string { + const resultMap = new Map(results.map((r) => [r.id, r])); + + let passed = 0; + let failed = 0; + let skipped = 0; + let pending = 0; + + for (const item of checklist.items) { + const result = resultMap.get(item.id); + if (result?.status === "passed") passed++; + else if (result?.status === "failed") failed++; + else if (result?.status === "skipped") skipped++; + else pending++; + } + + const lines: string[] = []; + + lines.push("# QA Checklist Results"); + lines.push(""); + lines.push("## Summary"); + lines.push(`- **Title**: ${checklist.title}`); + lines.push(`- **Total**: ${checklist.items.length} items`); + lines.push(`- **Passed**: ${passed} | **Failed**: ${failed} | **Skipped**: ${skipped}${pending > 0 ? ` | **Pending**: ${pending}` : ""}`); + lines.push(""); + + // Failed items — full detail + const failedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "failed" + ); + if (failedItems.length > 0) { + lines.push("## Failed Items"); + lines.push(""); + for (const item of failedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: FAILED`); + lines.push(`**Category**: ${item.category}`); + if (item.critical) lines.push(`**Critical**: yes`); + if (item.files?.length) lines.push(`**Files**: ${item.files.join(", ")}`); + const itemNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of itemNotes) { + lines.push(`**Developer notes**: ${note}`); + } + if (result.images?.length) { + for (const img of result.images) { + lines.push(`[${img.name}] ${img.path}`); + } + } + lines.push(""); + } + } + + // Skipped items + const skippedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "skipped" + ); + if (skippedItems.length > 0) { + lines.push("## Skipped Items"); + lines.push(""); + for (const item of skippedItems) { + const result = resultMap.get(item.id)!; + lines.push(`### ${item.id}: ${item.check}`); + lines.push(`**Status**: SKIPPED`); + const skipNotes = Array.isArray(result.notes) ? result.notes : result.notes ? [result.notes] : []; + for (const note of skipNotes) { + lines.push(`**Reason**: ${note}`); + } + lines.push(""); + } + } + + // Passed items — compact + const passedItems = checklist.items.filter( + (item) => resultMap.get(item.id)?.status === "passed" + ); + if (passedItems.length > 0) { + lines.push("## Passed Items"); + lines.push(""); + for (const item of passedItems) { + const result = resultMap.get(item.id); + const passNotes = Array.isArray(result?.notes) ? result.notes : result?.notes ? [result.notes] : []; + const notes = passNotes.length > 0 ? ` — ${passNotes.join('; ')}` : ""; + lines.push(`- [PASS] ${item.id}: ${item.check}${notes}`); + } + lines.push(""); + } + + // Global notes + const notes = Array.isArray(globalNotes) ? globalNotes : globalNotes ? [globalNotes] : []; + if (notes.length > 0) { + lines.push("## Developer Comments"); + lines.push(""); + for (const note of notes) { + lines.push(`> ${note.trim().replace(/\n/g, "\n> ")}`); + lines.push(""); + } + } + + // Automation instructions + if (automations && checklist.pr) { + const pr = checklist.pr; + const hasAutomations = automations.postToPR || automations.approveIfAllPass; + + if (hasAutomations) { + lines.push("## Automations"); + lines.push(""); + + if (automations.postToPR) { + if (pr.provider === "github") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`gh\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr comment ${pr.number} --body ''`); + lines.push("```"); + lines.push("If `gh` is not available, inform the developer to install the GitHub CLI (`brew install gh` or https://cli.github.com)."); + } else if (pr.provider === "gitlab") { + lines.push("**Post results to MR**: The developer requested that you post these checklist results as a comment on the merge request."); + lines.push(`Use the \`glab\` CLI to post a note to MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr note ${pr.number} --message ''`); + lines.push("```"); + lines.push("If `glab` is not available, inform the developer to install the GitLab CLI (`brew install glab` or https://gitlab.com/gitlab-org/cli)."); + } else if (pr.provider === "azure-devops") { + lines.push("**Post results to PR**: The developer requested that you post these checklist results as a comment on the pull request."); + lines.push(`Use the \`az\` CLI to post a comment to PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr update --id ${pr.number} --description ''`); + lines.push("```"); + lines.push("If `az` is not available, inform the developer to install Azure CLI (`brew install azure-cli` or https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)."); + } + lines.push(""); + } + + if (automations.approveIfAllPass && failed === 0 && skipped === 0 && pending === 0) { + if (pr.provider === "github") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`gh\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`gh pr review ${pr.number} --approve --body 'QA checklist passed (${passed}/${passed} items)'`); + lines.push("```"); + } else if (pr.provider === "gitlab") { + lines.push("**Approve MR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`glab\` CLI to approve MR !${pr.number}:`); + lines.push("```bash"); + lines.push(`glab mr approve ${pr.number}`); + lines.push("```"); + } else if (pr.provider === "azure-devops") { + lines.push("**Approve PR**: All checklist items passed. The developer requested auto-approval."); + lines.push(`Use the \`az\` CLI to approve PR #${pr.number}:`); + lines.push("```bash"); + lines.push(`az repos pr set-vote --id ${pr.number} --vote approve`); + lines.push("```"); + } + lines.push(""); + } else if (automations.approveIfAllPass && (failed > 0 || skipped > 0 || pending > 0)) { + lines.push("**Approve PR**: Skipped — not all items passed. Fix the failed/skipped items and re-run the checklist."); + lines.push(""); + } + } + } + + return lines.join("\n"); +} + +// --- Storage --- + +/** + * Save a completed checklist (original + results) to disk. + * Returns the path to the saved file. + * + * Structure: ~/.plannotator/checklists/{project}/{slug}.json + * The saved file contains the full checklist JSON plus results, + * so it can be reopened via `plannotator checklist --file `. + */ +function saveChecklistResults( + checklist: Checklist, + results: ChecklistItemResult[], + globalNotes: string[] | string | undefined, + project: string, +): string { + const dir = join(homedir(), ".plannotator", "checklists", project); + mkdirSync(dir, { recursive: true }); + + const date = new Date().toISOString().split("T")[0]; + const slug = checklist.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + const timestamp = Date.now(); + const filename = `${slug}-${date}-${timestamp}.json`; + const filePath = join(dir, filename); + + writeFileSync(filePath, JSON.stringify({ + checklist, + results, + globalNotes, + submittedAt: new Date().toISOString(), + project, + }, null, 2)); + + return filePath; +} + +// --- Server Implementation --- + +/** + * Start the Checklist server + * + * Handles: + * - Remote detection and port configuration + * - API routes (/api/checklist, /api/feedback) + * - Port conflict retries + */ +export async function startChecklistServer( + options: ChecklistServerOptions +): Promise { + const { + checklist, + htmlContent, + origin, + project = "_unknown", + initialResults, + initialGlobalNotes, + onReady, + } = options; + + const draftKey = contentHash(JSON.stringify(checklist)); + + // Decision promise + let resolveDecision: (result: ChecklistDecision) => void; + const decisionPromise = new Promise((resolve) => { + resolveDecision = resolve; + }); + + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); + + // API: Get checklist data + if (url.pathname === "/api/checklist" && req.method === "GET") { + return Response.json({ + checklist, + origin, + mode: "checklist", + ...(initialResults && { initialResults }), + ...(initialGlobalNotes && { initialGlobalNotes }), + }); + } + + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + return handleImage(req); + } + + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + return handleUpload(req); + } + + // API: Checklist draft persistence + if (url.pathname === "/api/draft") { + if (req.method === "POST") return handleDraftSave(req, draftKey); + if (req.method === "DELETE") return handleDraftDelete(draftKey); + return handleDraftLoad(draftKey); + } + + // API: Submit checklist results + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as ChecklistSubmission & { + agentSwitch?: string; + }; + + deleteDraft(draftKey); + + const results = body.results || []; + + // Save to disk + let savedTo: string | undefined; + try { + savedTo = saveChecklistResults( + checklist, + results, + body.globalNotes, + project, + ); + } catch { + // Non-fatal — feedback still goes to agent + } + + const feedback = formatChecklistFeedback( + checklist, + results, + body.globalNotes, + body.automations, + ); + + resolveDecision({ + feedback, + results, + savedTo, + agentSwitch: body.agentSwitch, + }); + + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to process checklist submission"; + return Response.json({ error: message }, { status: 500 }); + } + } + + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + + // Notify caller that server is ready + if (onReady) { + onReady(serverUrl, isRemote, port); + } + + return { + port, + url: serverUrl, + isRemote, + waitForDecision: () => decisionPromise, + stop: () => server.stop(), + }; +} diff --git a/packages/server/index.ts b/packages/server/index.ts index e8fe38d7..35bd78ad 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -10,7 +10,7 @@ */ import { resolve } from "path"; -import { isRemoteSession, getServerPort } from "./remote"; +import { startServer } from "./serve"; import { openEditorDiff } from "./ide"; import { saveToObsidian, @@ -90,9 +90,6 @@ export interface ServerResult { // --- Server Implementation --- -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - /** * Start the Plannotator server * @@ -107,8 +104,6 @@ export async function startPlannotatorServer( ): Promise { const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady } = options; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); const draftKey = contentHash(plan); const editorAnnotations = createEditorAnnotationHandler(); @@ -132,7 +127,6 @@ export async function startPlannotatorServer( project, }; - // Decision promise let resolveDecision: (result: { approved: boolean; @@ -151,19 +145,12 @@ export async function startPlannotatorServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - port: configuredPort, + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); - async fetch(req) { - const url = new URL(req.url); - - // API: Get a specific plan version from history - if (url.pathname === "/api/plan/version") { + // API: Get a specific plan version from history + if (url.pathname === "/api/plan/version") { const vParam = url.searchParams.get("v"); if (!vParam) { return new Response("Missing v parameter", { status: 400 }); @@ -426,41 +413,16 @@ export async function startPlannotatorServer( return new Response(htmlContent, { headers: { "Content-Type": "text/html" }, }); - }, - }); - - break; // Success, exit retry loop - } catch (err: unknown) { - const isAddressInUse = - err instanceof Error && err.message.includes("EADDRINUSE"); - - if (isAddressInUse && attempt < MAX_RETRIES) { - await Bun.sleep(RETRY_DELAY_MS); - continue; - } - - if (isAddressInUse) { - const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : ""; - throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`); - } - - throw err; - } - } - - if (!server) { - throw new Error("Failed to start server"); - } - - const serverUrl = `http://localhost:${server.port}`; + }, + }); // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/package.json b/packages/server/package.json index dc1a6045..5664cec6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -9,6 +9,7 @@ ".": "./index.ts", "./review": "./review.ts", "./annotate": "./annotate.ts", + "./checklist": "./checklist.ts", "./remote": "./remote.ts", "./browser": "./browser.ts", "./storage": "./storage.ts", diff --git a/packages/server/review.ts b/packages/server/review.ts index bb16c9f9..9452fcc9 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,7 +9,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerPort } from "./remote"; +import { startServer } from "./serve"; import { type DiffType, type GitContext, runGitDiff, getFileContentsForDiff, gitAddFile, gitResetFile, parseWorktreeDiffType, validateFilePath } from "./git"; import { getRepoInfo } from "./repo"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, type OpencodeClient } from "./shared-handlers"; @@ -68,9 +68,6 @@ export interface ReviewServerResult { // --- Server Implementation --- -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - /** * Start the Code Review server * @@ -93,9 +90,6 @@ export async function startReviewServer( let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); - // Detect repo info (cached for this session) const repoInfo = await getRepoInfo(); @@ -113,211 +107,179 @@ export async function startReviewServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - port: configuredPort, - - async fetch(req) { - const url = new URL(req.url); - - // API: Get diff content - if (url.pathname === "/api/diff" && req.method === "GET") { - return Response.json({ - rawPatch: currentPatch, - gitRef: currentGitRef, - origin, - diffType: currentDiffType, - gitContext, - sharingEnabled, - shareBaseUrl, - repoInfo, - ...(currentError && { error: currentError }), - }); - } + const { server, port, url: serverUrl, isRemote } = await startServer({ + fetch: async (req) => { + const url = new URL(req.url); + + // API: Get diff content + if (url.pathname === "/api/diff" && req.method === "GET") { + return Response.json({ + rawPatch: currentPatch, + gitRef: currentGitRef, + origin, + diffType: currentDiffType, + gitContext, + sharingEnabled, + shareBaseUrl, + repoInfo, + ...(currentError && { error: currentError }), + }); + } - // API: Switch diff type - if (url.pathname === "/api/diff/switch" && req.method === "POST") { - try { - const body = (await req.json()) as { diffType: DiffType }; - let newDiffType = body.diffType; - - if (!newDiffType) { - return Response.json( - { error: "Missing diffType" }, - { status: 400 } - ); - } - - const defaultBranch = gitContext?.defaultBranch || "main"; - - // Run the new diff - const result = await runGitDiff(newDiffType, defaultBranch); - - // Update state - currentPatch = result.patch; - currentGitRef = result.label; - currentDiffType = newDiffType; - currentError = result.error; - - return Response.json({ - rawPatch: currentPatch, - gitRef: currentGitRef, - diffType: currentDiffType, - ...(currentError && { error: currentError }), - }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to switch diff"; - return Response.json({ error: message }, { status: 500 }); - } - } + // API: Switch diff type + if (url.pathname === "/api/diff/switch" && req.method === "POST") { + try { + const body = (await req.json()) as { diffType: DiffType }; + let newDiffType = body.diffType; - // API: Get file content for expandable diff context - if (url.pathname === "/api/file-content" && req.method === "GET") { - const filePath = url.searchParams.get("path"); - if (!filePath) { - return Response.json({ error: "Missing path" }, { status: 400 }); - } - try { validateFilePath(filePath); } catch { - return Response.json({ error: "Invalid path" }, { status: 400 }); - } - const oldPath = url.searchParams.get("oldPath") || undefined; - if (oldPath) { - try { validateFilePath(oldPath); } catch { - return Response.json({ error: "Invalid path" }, { status: 400 }); - } - } - const defaultBranch = gitContext?.defaultBranch || "main"; - const result = await getFileContentsForDiff( - currentDiffType, - defaultBranch, - filePath, - oldPath, + if (!newDiffType) { + return Response.json( + { error: "Missing diffType" }, + { status: 400 } ); - return Response.json(result); } - // API: Git add / reset (stage / unstage) a file - if (url.pathname === "/api/git-add" && req.method === "POST") { - try { - const body = (await req.json()) as { filePath: string; undo?: boolean }; - if (!body.filePath) { - return Response.json({ error: "Missing filePath" }, { status: 400 }); - } - - // Determine cwd for worktree support - let cwd: string | undefined; - if (currentDiffType.startsWith("worktree:")) { - const parsed = parseWorktreeDiffType(currentDiffType); - if (parsed) cwd = parsed.path; - } - - if (body.undo) { - await gitResetFile(body.filePath, cwd); - } else { - await gitAddFile(body.filePath, cwd); - } - - return Response.json({ ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to git add"; - return Response.json({ error: message }, { status: 500 }); - } - } + const defaultBranch = gitContext?.defaultBranch || "main"; - // API: Serve images (local paths or temp uploads) - if (url.pathname === "/api/image") { - return handleImage(req); - } + // Run the new diff + const result = await runGitDiff(newDiffType, defaultBranch); + + // Update state + currentPatch = result.patch; + currentGitRef = result.label; + currentDiffType = newDiffType; + currentError = result.error; + + return Response.json({ + rawPatch: currentPatch, + gitRef: currentGitRef, + diffType: currentDiffType, + ...(currentError && { error: currentError }), + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to switch diff"; + return Response.json({ error: message }, { status: 500 }); + } + } - // API: Upload image -> save to temp -> return path - if (url.pathname === "/api/upload" && req.method === "POST") { - return handleUpload(req); + // API: Get file content for expandable diff context + if (url.pathname === "/api/file-content" && req.method === "GET") { + const filePath = url.searchParams.get("path"); + if (!filePath) { + return Response.json({ error: "Missing path" }, { status: 400 }); + } + try { validateFilePath(filePath); } catch { + return Response.json({ error: "Invalid path" }, { status: 400 }); + } + const oldPath = url.searchParams.get("oldPath") || undefined; + if (oldPath) { + try { validateFilePath(oldPath); } catch { + return Response.json({ error: "Invalid path" }, { status: 400 }); } + } + const defaultBranch = gitContext?.defaultBranch || "main"; + const result = await getFileContentsForDiff( + currentDiffType, + defaultBranch, + filePath, + oldPath, + ); + return Response.json(result); + } - // API: Get available agents (OpenCode only) - if (url.pathname === "/api/agents") { - return handleAgents(options.opencodeClient); + // API: Git add / reset (stage / unstage) a file + if (url.pathname === "/api/git-add" && req.method === "POST") { + try { + const body = (await req.json()) as { filePath: string; undo?: boolean }; + if (!body.filePath) { + return Response.json({ error: "Missing filePath" }, { status: 400 }); } - // API: Annotation draft persistence - if (url.pathname === "/api/draft") { - if (req.method === "POST") return handleDraftSave(req, draftKey); - if (req.method === "DELETE") return handleDraftDelete(draftKey); - return handleDraftLoad(draftKey); + // Determine cwd for worktree support + let cwd: string | undefined; + if (currentDiffType.startsWith("worktree:")) { + const parsed = parseWorktreeDiffType(currentDiffType); + if (parsed) cwd = parsed.path; } - // API: Editor annotations (VS Code extension) - const editorResponse = await editorAnnotations.handle(req, url); - if (editorResponse) return editorResponse; - - // API: Submit review feedback - if (url.pathname === "/api/feedback" && req.method === "POST") { - try { - const body = (await req.json()) as { - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - }; - - deleteDraft(draftKey); - resolveDecision({ - feedback: body.feedback || "", - annotations: body.annotations || [], - agentSwitch: body.agentSwitch, - }); - - return Response.json({ ok: true }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to process feedback"; - return Response.json({ error: message }, { status: 500 }); - } + if (body.undo) { + await gitResetFile(body.filePath, cwd); + } else { + await gitAddFile(body.filePath, cwd); } - // Serve embedded HTML for all other routes (SPA) - return new Response(htmlContent, { - headers: { "Content-Type": "text/html" }, - }); - }, - }); + return Response.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to git add"; + return Response.json({ error: message }, { status: 500 }); + } + } - break; // Success, exit retry loop - } catch (err: unknown) { - const isAddressInUse = - err instanceof Error && err.message.includes("EADDRINUSE"); + // API: Serve images (local paths or temp uploads) + if (url.pathname === "/api/image") { + return handleImage(req); + } - if (isAddressInUse && attempt < MAX_RETRIES) { - await Bun.sleep(RETRY_DELAY_MS); - continue; + // API: Upload image -> save to temp -> return path + if (url.pathname === "/api/upload" && req.method === "POST") { + return handleUpload(req); } - if (isAddressInUse) { - const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : ""; - throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`); + // API: Get available agents (OpenCode only) + if (url.pathname === "/api/agents") { + return handleAgents(options.opencodeClient); } - throw err; - } - } + // API: Annotation draft persistence + if (url.pathname === "/api/draft") { + if (req.method === "POST") return handleDraftSave(req, draftKey); + if (req.method === "DELETE") return handleDraftDelete(draftKey); + return handleDraftLoad(draftKey); + } - if (!server) { - throw new Error("Failed to start server"); - } + // API: Editor annotations (VS Code extension) + const editorResponse = await editorAnnotations.handle(req, url); + if (editorResponse) return editorResponse; + + // API: Submit review feedback + if (url.pathname === "/api/feedback" && req.method === "POST") { + try { + const body = (await req.json()) as { + feedback: string; + annotations: unknown[]; + agentSwitch?: string; + }; + + deleteDraft(draftKey); + resolveDecision({ + feedback: body.feedback || "", + annotations: body.annotations || [], + agentSwitch: body.agentSwitch, + }); + + return Response.json({ ok: true }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to process feedback"; + return Response.json({ error: message }, { status: 500 }); + } + } - const serverUrl = `http://localhost:${server.port}`; + // Serve embedded HTML for all other routes (SPA) + return new Response(htmlContent, { + headers: { "Content-Type": "text/html" }, + }); + }, + }); // Notify caller that server is ready if (onReady) { - onReady(serverUrl, isRemote, server.port); + onReady(serverUrl, isRemote, port); } return { - port: server.port, + port, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, diff --git a/packages/server/serve.ts b/packages/server/serve.ts new file mode 100644 index 00000000..f1566c3b --- /dev/null +++ b/packages/server/serve.ts @@ -0,0 +1,84 @@ +/** + * Shared Bun.serve() wrapper with port-conflict retry logic. + * + * Every Plannotator server (plan, review, annotate, checklist) needs the same + * bootstrap: try a port, retry on EADDRINUSE, give up after N attempts. + * This module extracts that boilerplate so each server only supplies its fetch handler. + */ + +import { isRemoteSession, getServerPort } from "./remote"; + +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 500; + +export interface StartServerOptions { + /** The request handler — the only thing that varies between servers. */ + fetch: (req: Request) => Response | Promise; +} + +export interface StartServerResult { + /** The underlying Bun server instance. */ + server: ReturnType; + /** The port the server is listening on. */ + port: number; + /** Full URL (http://localhost:{port}). */ + url: string; + /** Whether running in remote/devcontainer mode. */ + isRemote: boolean; +} + +/** + * Start a Bun HTTP server with automatic port-conflict retries. + * + * Retries up to 5 times with 500ms delay when the port is in use. + * Uses the standard Plannotator port logic (random locally, fixed in remote mode). + */ +export async function startServer( + options: StartServerOptions, +): Promise { + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + + let server: ReturnType | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + server = Bun.serve({ + port: configuredPort, + fetch: options.fetch, + }); + + break; // Success + } catch (err: unknown) { + const isAddressInUse = + err instanceof Error && err.message.includes("EADDRINUSE"); + + if (isAddressInUse && attempt < MAX_RETRIES) { + await Bun.sleep(RETRY_DELAY_MS); + continue; + } + + if (isAddressInUse) { + const hint = isRemote + ? " (set PLANNOTATOR_PORT to use different port)" + : ""; + throw new Error( + `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`, + ); + } + + throw err; + } + } + + if (!server) { + throw new Error("Failed to start server"); + } + + return { + server, + port: server.port, + url: `http://localhost:${server.port}`, + isRemote, + }; +} diff --git a/packages/server/sessions.ts b/packages/server/sessions.ts index 78fddc8e..bc2ee120 100644 --- a/packages/server/sessions.ts +++ b/packages/server/sessions.ts @@ -20,7 +20,7 @@ export interface SessionInfo { pid: number; port: number; url: string; - mode: "plan" | "review" | "annotate"; + mode: "plan" | "review" | "annotate" | "checklist"; project: string; startedAt: string; label: string; diff --git a/packages/shared/checklist-types.ts b/packages/shared/checklist-types.ts new file mode 100644 index 00000000..dd1dfd76 --- /dev/null +++ b/packages/shared/checklist-types.ts @@ -0,0 +1,90 @@ +// --- Agent-Produced Checklist --- + +export interface ChecklistItem { + /** Category-prefixed ID, e.g. "func-1", "sec-2" */ + id: string; + /** Free-form category label, e.g. "visual", "security", "api-contract" */ + category: string; + /** Imperative verb phrase: "Verify that..." */ + check: string; + /** Markdown narrative: what changed, what could go wrong, expected behavior */ + description: string; + /** Ordered instructions for conducting the verification */ + steps: string[]; + /** Why manual verification is needed (not automatable) */ + reason: string; + /** Related file paths from the diff */ + files?: string[]; + /** Maps file path → number of diff hunks this item covers. + * Paths must be keys in Checklist.fileDiffs. + * Multiple items can cover the same hunks (many-to-many). */ + diffMap?: Record; + /** True if failure means data loss, security breach, or broken deploy */ + critical?: boolean; +} + +/** Pull/merge request reference for linking checklist to a PR */ +export interface ChecklistPR { + /** PR/MR number */ + number: number; + /** Full URL to the PR/MR */ + url: string; + /** PR/MR title */ + title?: string; + /** Source branch name */ + branch?: string; + /** Git hosting provider */ + provider: "github" | "gitlab" | "azure-devops"; +} + +/** Per-file diff metadata. Hunks drive coverage; lines + status drive PR Balance. */ +export interface FileDiffInfo { + /** Number of diff hunks (@@-delimited sections) */ + hunks: number; + /** Total lines changed (added + removed) */ + lines: number; + /** Whether the file is newly added or modified */ + status: "new" | "modified"; +} + +export interface Checklist { + /** Short title for the checklist */ + title: string; + /** One paragraph: what changed and why manual verification matters */ + summary: string; + /** The verification items */ + items: ChecklistItem[]; + /** Optional associated pull/merge request */ + pr?: ChecklistPR; + /** Per-file diff metadata (relative to repo root). + * Presence enables coverage toggle + PR Balance visualization. + * Values can be a number (legacy: hunk count only) or FileDiffInfo object. */ + fileDiffs?: Record; +} + +// --- Developer Response --- + +export type ChecklistItemStatus = "passed" | "failed" | "skipped" | "pending"; + +export interface ChecklistItemResult { + /** Matches the original item ID */ + id: string; + status: ChecklistItemStatus; + /** Developer notes (failure details, skip reason, questions) */ + notes?: string[]; + /** Screenshot evidence */ + images?: { path: string; name: string }[]; +} + +export type ChecklistViewMode = 'checklist' | 'coverage'; + +export interface ChecklistSubmission { + results: ChecklistItemResult[]; + /** Overall notes from the developer */ + globalNotes?: string[]; + /** Automation flags selected by the developer */ + automations?: { + postToPR?: boolean; + approveIfAllPass?: boolean; + }; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 8101468c..d1e32ce6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -4,6 +4,7 @@ "private": true, "exports": { "./compress": "./compress.ts", - "./crypto": "./crypto.ts" + "./crypto": "./crypto.ts", + "./checklist-types": "./checklist-types.ts" } } diff --git a/packages/ui/components/AnnotationToolstrip.tsx b/packages/ui/components/AnnotationToolstrip.tsx index e94018be..aa67ed02 100644 --- a/packages/ui/components/AnnotationToolstrip.tsx +++ b/packages/ui/components/AnnotationToolstrip.tsx @@ -1,6 +1,7 @@ -import React, { useState, useRef, useLayoutEffect, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import type { EditorMode, InputMethod } from '../types'; import { TaterSpritePullup } from './TaterSpritePullup'; +import { ToolstripButton } from './ToolstripButton'; interface AnnotationToolstripProps { inputMethod: InputMethod; @@ -191,122 +192,3 @@ export const AnnotationToolstrip: React.FC = ({ ); }; - -/* ─── Color system ─── */ - -const colorStyles = { - primary: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-primary/80 bg-primary/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, - secondary: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-secondary/80 bg-secondary/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, - accent: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-accent/80 bg-accent/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, - destructive: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-destructive/80 bg-destructive/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, - warning: { - active: 'bg-background text-foreground shadow-sm', - hover: 'text-amber-500/80 bg-amber-500/8', - inactive: 'text-muted-foreground hover:text-foreground', - }, -} as const; - -type ButtonColor = keyof typeof colorStyles; - -/* ─── Constants ─── */ - -const ICON_SIZE = 28; // collapsed button width (px) -const H_PAD = 10; // horizontal padding when expanded (px) — matches px-2.5 -const GAP = 6; // gap between icon and label (px) — matches gap-1.5 -const ICON_INNER = 14; // icon element width (px) -const DURATION = 180; // transition ms - -/* ─── Button ─── */ - -const ToolstripButton: React.FC<{ - active: boolean; - onClick: () => void; - icon: React.ReactNode; - label: string; - color: ButtonColor; - mounted: boolean; -}> = ({ active, onClick, icon, label, color, mounted }) => { - const [hovered, setHovered] = useState(false); - const [labelWidth, setLabelWidth] = useState(0); - const measureRef = useRef(null); - const styles = colorStyles[color]; - const [isTouchDevice] = useState(() => 'ontouchstart' in window || navigator.maxTouchPoints > 0); - - // Measure label text width synchronously before first paint - useLayoutEffect(() => { - if (measureRef.current) { - setLabelWidth(measureRef.current.offsetWidth); - } - }, [label]); - - const expanded = active || hovered || isTouchDevice; - const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD; - const currentWidth = expanded ? expandedWidth : ICON_SIZE; - - const colorClass = active - ? styles.active - : hovered - ? styles.hover - : styles.inactive; - - const transition = mounted - ? `width ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease` - : 'none'; - - const innerTransition = mounted - ? `padding-left ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` - : 'none'; - - return ( - - ); -}; diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 4e58a1fa..1cc3e8e8 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -57,15 +57,18 @@ interface SettingsProps { onTaterModeChange: (enabled: boolean) => void; onIdentityChange?: (oldIdentity: string, newIdentity: string) => void; origin?: 'claude-code' | 'opencode' | 'pi' | null; - /** Mode determines which settings are shown. 'plan' shows all, 'review' shows only identity + agent switching */ - mode?: 'plan' | 'review'; + /** Mode determines which settings are shown. 'plan' shows all, 'review' shows only identity + agent switching, 'checklist' shows display width */ + mode?: 'plan' | 'review' | 'checklist'; onUIPreferencesChange?: (prefs: UIPreferences) => void; + /** Checklist display width (separate from plan width) */ + checklistWidth?: PlanWidth; + onChecklistWidthChange?: (width: PlanWidth) => void; /** Externally controlled open state (for mobile menu integration) */ externalOpen?: boolean; onExternalClose?: () => void; } -export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose }) => { +export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, checklistWidth, onChecklistWidthChange, externalOpen, onExternalClose }) => { const [showDialog, setShowDialog] = useState(false); const [activeTab, setActiveTab] = useState('general'); const [identity, setIdentity] = useState(''); @@ -94,8 +97,10 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const mainTabs = useMemo(() => { const t: { id: SettingsTab; label: string }[] = [{ id: 'general', label: 'General' }]; - if (mode === 'plan') { + if (mode === 'plan' || mode === 'checklist') { t.push({ id: 'display', label: 'Display' }); + } + if (mode === 'plan') { t.push({ id: 'saving', label: 'Saving' }); t.push({ id: 'labels', label: 'Labels' }); } @@ -482,6 +487,38 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange {/* === DISPLAY TAB === */} {activeTab === 'display' && ( <> + {/* Checklist Width (checklist mode only) */} + {mode === 'checklist' && checklistWidth && onChecklistWidthChange && ( +
+
+
Checklist Width
+
+ Maximum width of the checklist document +
+
+
+ {PLAN_WIDTH_OPTIONS.map((opt) => ( + + ))} +
+
+ {(PLAN_WIDTH_OPTIONS.find(o => o.id === checklistWidth) ?? PLAN_WIDTH_OPTIONS[0]).px}px — {(PLAN_WIDTH_OPTIONS.find(o => o.id === checklistWidth) ?? PLAN_WIDTH_OPTIONS[0]).hint} +
+
+ )} + + {/* Plan-only display settings */} + {mode === 'plan' && (<> {/* Auto-open Sidebar */}
@@ -657,6 +694,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange />
+ )} )} diff --git a/packages/ui/components/ToolstripButton.tsx b/packages/ui/components/ToolstripButton.tsx new file mode 100644 index 00000000..0db48ba6 --- /dev/null +++ b/packages/ui/components/ToolstripButton.tsx @@ -0,0 +1,116 @@ +import React, { useState, useRef, useLayoutEffect } from 'react'; + +/* ─── Color system ─── */ + +export const toolstripColorStyles = { + primary: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-primary/80 bg-primary/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, + secondary: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-secondary/80 bg-secondary/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, + accent: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-accent/80 bg-accent/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, + destructive: { + active: 'bg-background text-foreground shadow-sm', + hover: 'text-destructive/80 bg-destructive/8', + inactive: 'text-muted-foreground hover:text-foreground', + }, +} as const; + +export type ToolstripButtonColor = keyof typeof toolstripColorStyles; + +/* ─── Constants ─── */ + +export const ICON_SIZE = 28; // collapsed button width (px) +const H_PAD = 10; // horizontal padding when expanded (px) — matches px-2.5 +const GAP = 6; // gap between icon and label (px) — matches gap-1.5 +const ICON_INNER = 14; // icon element width (px) +export const DURATION = 180; // transition ms + +/* ─── Button ─── */ + +export interface ToolstripButtonProps { + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: string; + color: ToolstripButtonColor; + mounted: boolean; +} + +export const ToolstripButton: React.FC = ({ active, onClick, icon, label, color, mounted }) => { + const [hovered, setHovered] = useState(false); + const [labelWidth, setLabelWidth] = useState(0); + const measureRef = useRef(null); + const styles = toolstripColorStyles[color]; + + // Measure label text width synchronously before first paint + useLayoutEffect(() => { + if (measureRef.current) { + setLabelWidth(measureRef.current.offsetWidth); + } + }, [label]); + + const expanded = active || hovered; + const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD; + const currentWidth = expanded ? expandedWidth : ICON_SIZE; + + const colorClass = active + ? styles.active + : hovered + ? styles.hover + : styles.inactive; + + const transition = mounted + ? `width ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease` + : 'none'; + + const innerTransition = mounted + ? `padding-left ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)` + : 'none'; + + return ( + + ); +}; diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css new file mode 100644 index 00000000..ecc994a9 --- /dev/null +++ b/packages/ui/styles/theme.css @@ -0,0 +1,129 @@ +/** + * Plannotator shared design tokens and global styles. + * + * Imported by each editor's index.css after @import "tailwindcss" and @source directives. + * Contains: OKLch color tokens (dark + light), Tailwind @theme mappings, and global resets. + */ + +:root { + --background: oklch(0.15 0.02 260); + --foreground: oklch(0.90 0.01 260); + --card: oklch(0.22 0.02 260); + --card-foreground: oklch(0.90 0.01 260); + --popover: oklch(0.28 0.025 260); + --popover-foreground: oklch(0.90 0.01 260); + --primary: oklch(0.75 0.18 280); + --primary-foreground: oklch(0.15 0.02 260); + --secondary: oklch(0.65 0.15 180); + --secondary-foreground: oklch(0.15 0.02 260); + --muted: oklch(0.26 0.02 260); + --muted-foreground: oklch(0.72 0.02 260); + --accent: oklch(0.70 0.20 60); + --accent-foreground: oklch(0.15 0.02 260); + --destructive: oklch(0.65 0.20 25); + --destructive-foreground: oklch(0.98 0 0); + --border: oklch(0.35 0.02 260); + --input: oklch(0.26 0.02 260); + --ring: oklch(0.75 0.18 280); + --success: oklch(0.72 0.17 150); + --success-foreground: oklch(0.15 0.02 260); + --warning: oklch(0.75 0.15 85); + --warning-foreground: oklch(0.20 0.02 260); + + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --radius: 0.75rem; +} + +.light { + --background: oklch(0.97 0.005 260); + --foreground: oklch(0.18 0.02 260); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.02 260); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.18 0.02 260); + --primary: oklch(0.50 0.25 280); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.50 0.18 180); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.92 0.01 260); + --muted-foreground: oklch(0.40 0.02 260); + --accent: oklch(0.60 0.22 50); + --accent-foreground: oklch(0.18 0.02 260); + --destructive: oklch(0.50 0.25 25); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.88 0.01 260); + --input: oklch(0.92 0.01 260); + --ring: oklch(0.50 0.25 280); + --success: oklch(0.45 0.20 150); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.55 0.18 85); + --warning-foreground: oklch(0.18 0.02 260); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +* { + border-color: var(--border); +} + +body { + font-family: var(--font-sans); + background: var(--background); + color: var(--foreground); + font-feature-settings: "ss01", "ss02", "cv01"; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +/* Selection */ +::selection { + background: oklch(0.75 0.18 280 / 0.3); +} + +/* Smooth transitions */ +* { + transition-property: color, background-color, border-color, box-shadow, opacity, transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Focus states */ +:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} diff --git a/packages/ui/utils/timeFormat.ts b/packages/ui/utils/timeFormat.ts new file mode 100644 index 00000000..01e0de13 --- /dev/null +++ b/packages/ui/utils/timeFormat.ts @@ -0,0 +1,13 @@ +/** + * Format a timestamp as a human-readable relative time string. + */ +export function formatTimeAgo(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days !== 1 ? 's' : ''} ago`; +} diff --git a/packages/ui/utils/uiPreferences.ts b/packages/ui/utils/uiPreferences.ts index 9670d2c2..3143631c 100644 --- a/packages/ui/utils/uiPreferences.ts +++ b/packages/ui/utils/uiPreferences.ts @@ -3,6 +3,11 @@ import { storage } from './storage'; const STORAGE_KEY_TOC = 'plannotator-toc-enabled'; const STORAGE_KEY_STICKY_ACTIONS = 'plannotator-sticky-actions-enabled'; const STORAGE_KEY_PLAN_WIDTH = 'plannotator-plan-width'; +const STORAGE_KEY_CHECKLIST_WIDTH = 'plannotator-checklist-width'; +const STORAGE_KEY_CHECKLIST_VIEW = 'plannotator-checklist-view'; +const STORAGE_KEY_COVERAGE_LAYOUT = 'plannotator-coverage-layout'; + +export type CoverageLayout = 'stacked' | 'side-by-side'; export type PlanWidth = 'compact' | 'default' | 'wide'; @@ -32,3 +37,31 @@ export function saveUIPreferences(prefs: UIPreferences): void { storage.setItem(STORAGE_KEY_STICKY_ACTIONS, String(prefs.stickyActionsEnabled)); storage.setItem(STORAGE_KEY_PLAN_WIDTH, prefs.planWidth); } + +// Checklist has its own width setting (same options, separate cookie) +export function getChecklistWidth(): PlanWidth { + const w = storage.getItem(STORAGE_KEY_CHECKLIST_WIDTH); + return (w === 'compact' || w === 'default' || w === 'wide') ? w : 'compact'; +} + +export function saveChecklistWidth(width: PlanWidth): void { + storage.setItem(STORAGE_KEY_CHECKLIST_WIDTH, width); +} + +export function getChecklistView(): 'checklist' | 'coverage' { + const v = storage.getItem(STORAGE_KEY_CHECKLIST_VIEW); + return v === 'coverage' ? 'coverage' : 'checklist'; +} + +export function saveChecklistView(mode: 'checklist' | 'coverage'): void { + storage.setItem(STORAGE_KEY_CHECKLIST_VIEW, mode); +} + +export function getCoverageLayout(): CoverageLayout { + const v = storage.getItem(STORAGE_KEY_COVERAGE_LAYOUT); + return v === 'stacked' ? 'stacked' : 'side-by-side'; +} + +export function saveCoverageLayout(layout: CoverageLayout): void { + storage.setItem(STORAGE_KEY_COVERAGE_LAYOUT, layout); +} diff --git a/pi-skills.md b/pi-skills.md new file mode 100644 index 00000000..16241e71 --- /dev/null +++ b/pi-skills.md @@ -0,0 +1,40 @@ +# Pi Skills in Extensions + +Pi extensions can bundle skills alongside extension code via `package.json`: + +```json +{ + "pi": { + "extensions": ["./"], + "skills": ["./skills"] + } +} +``` + +When a user runs `pi install npm:@plannotator/pi-extension`, both the extension and skills are loaded. + +## Discovery Locations (in order) +- Global: `~/.pi/agent/skills/` +- Project: `.pi/skills/` (up to git root) +- Packages: `skills/` dir or `pi.skills` entries in `package.json` +- Settings / CLI flags + +## Skills vs Extensions +- **Skills**: Markdown (SKILL.md), model reads & follows instructions, stateless, workflows/prompts +- **Extensions**: TypeScript, code runs in the system, can maintain state, tools/events/UI/state machines + +## Invocation +- **Automatic**: Descriptions in system prompt, model reads full SKILL.md via read tool when task matches +- **Manual**: `/skill:name [args]` +- **Hidden**: `disable-model-invocation: true` in frontmatter (slash command only) + +## Structure in pi-extension +``` +apps/pi-extension/ +├── index.ts +├── server.ts +├── package.json # pi.skills: ["./skills"] +└── skills/ + └── checklist-review/ + └── SKILL.md +``` diff --git a/prototypes/file_tree_waffle_coverage.html b/prototypes/file_tree_waffle_coverage.html new file mode 100644 index 00000000..de0e645b --- /dev/null +++ b/prototypes/file_tree_waffle_coverage.html @@ -0,0 +1,254 @@ + + + +
+
+
+
0%
+
+
+ Untested diff + Covered diff +
+
+
+
+

QA tasks

+
+
+
+
+ + diff --git a/prototypes/pr_balance_waffle_v3.html b/prototypes/pr_balance_waffle_v3.html new file mode 100644 index 00000000..796d13bd --- /dev/null +++ b/prototypes/pr_balance_waffle_v3.html @@ -0,0 +1,308 @@ + + + +
+
+ + Showing individual files +
+ +
+
+ + + diff --git a/prototypes/qa-surface-area-coverage.zip b/prototypes/qa-surface-area-coverage.zip new file mode 100644 index 00000000..a04b0566 Binary files /dev/null and b/prototypes/qa-surface-area-coverage.zip differ diff --git a/skills-architecture.md b/skills-architecture.md new file mode 100644 index 00000000..87a52141 --- /dev/null +++ b/skills-architecture.md @@ -0,0 +1,22 @@ +# Skills Architecture + +## Canonical Location +- `.agents/skills//SKILL.md` (Agent Skills standard, repo root) +- First skill: `checklist-review` + +## Distribution Model +- Full-suite harnesses bundle skills with their plugin/extension install +- Skills-only harnesses (Codex, Factory) use `npx skills add backnotprop/plannotator` + +## Per-Harness Bundling +- **Claude Code**: Plugin bundles skills in plugin `skills/` dir +- **OpenCode**: npm plugin bundles skills in package +- **Pi**: Extension bundles skills via `pi.skills` in `package.json` — see [pi-skills.md](pi-skills.md) +- **Codex**: `npx skills add backnotprop/plannotator` +- **Factory**: `npx skills add backnotprop/plannotator` + +## Vercel `skills` CLI +- `npx skills add backnotprop/plannotator` +- Scans `.agents/skills/`, `skills/`, `.claude/skills/`, plugin manifests +- Auto-detects installed agents, symlinks skills to each agent's skill dir +- Supports 41+ agents