diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c5abe7bd..75975709 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -301,8 +301,8 @@ jobs: harnesses/claude-code/bundle/skillify-worker.js \ harnesses/codex/bundle/capture.js \ harnesses/codex/bundle/session-start.js \ - cursor/bundle/capture.js \ - cursor/bundle/session-start.js \ + harnesses/cursor/bundle/capture.js \ + harnesses/cursor/bundle/session-start.js \ harnesses/hermes/bundle/capture.js \ harnesses/hermes/bundle/session-start.js \ mcp/bundle/server.js \ @@ -424,8 +424,8 @@ jobs: harnesses/claude-code/bundle/skillify-worker.js \ harnesses/codex/bundle/capture.js \ harnesses/codex/bundle/session-start.js \ - cursor/bundle/capture.js \ - cursor/bundle/session-start.js \ + harnesses/cursor/bundle/capture.js \ + harnesses/cursor/bundle/session-start.js \ harnesses/hermes/bundle/capture.js \ harnesses/hermes/bundle/session-start.js \ mcp/bundle/server.js \ diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ece3faef..36532b4c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -116,7 +116,7 @@ jobs: bundle \ harnesses/claude-code/bundle \ harnesses/codex/bundle \ - cursor/bundle \ + harnesses/cursor/bundle \ harnesses/hermes/bundle \ mcp/bundle \ harnesses/pi/bundle @@ -135,7 +135,7 @@ jobs: bundle \ harnesses/claude-code/bundle \ harnesses/codex/bundle \ - cursor/bundle \ + harnesses/cursor/bundle \ harnesses/hermes/bundle \ mcp/bundle \ harnesses/pi/bundle diff --git a/.gitignore b/.gitignore index 1f06d755..37e59ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,12 +26,12 @@ deploy-to-cache.sh bundle/ harnesses/claude-code/bundle/ harnesses/codex/bundle/ -cursor/bundle/ +harnesses/cursor/bundle/ harnesses/hermes/bundle/ mcp/bundle/ harnesses/pi/bundle/ evals/ .cursor/ cursor_hivemind_environment_variables_c.md -cursor-extension/node_modules/ -cursor-extension/dist/ \ No newline at end of file +harnesses/cursor/extension/node_modules/ +harnesses/cursor/extension/dist/ \ No newline at end of file diff --git a/README.md b/README.md index 60005624..71efeb3c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ hivemind status | **Claude Code** | Marketplace plugin | ✅ | ✅ | | **OpenClaw** | Native extension | ✅ | ✅ | | **Codex** | Hooks (`hooks.json`) | ✅ | ✅ | -| **Cursor** | Hooks (`hooks.json` 1.7+) | ✅ | ✅ | +| **Cursor** | Hooks (`hooks.json` 1.7+) + optional VS Code extension | ✅ | ✅ | | **Hermes Agent** | Shell hooks (`config.yaml`) + skill + MCP server | ✅ | ✅ | | **pi** | Extension API (`pi.on(...)`) + skill + AGENTS.md | ✅ | ✅ | @@ -195,13 +195,46 @@ Choose **`2. Trust all and continue`** — otherwise the hooks won't run and hiv
Cursor (1.7+) -The unified installer wires six lifecycle events in `~/.cursor/hooks.json`: sessionStart, beforeSubmitPrompt, postToolUse, afterAgentResponse, stop, sessionEnd. Hooks fork a Node bundle at `~/.cursor/hivemind/bundle/` per event. Restart Cursor after install to load. +Hivemind integrates with Cursor in two layers: **hooks** (required for capture/recall) and an optional **editor extension** (health, login, dashboard). + +#### Hooks (required) + +The unified installer wires seven lifecycle events in `~/.cursor/hooks.json`: `sessionStart`, `beforeSubmitPrompt`, `preToolUse` (Shell matcher), `postToolUse`, `afterAgentResponse`, `stop`, and `sessionEnd`. Each hook runs a Node script from `~/.cursor/hivemind/bundle/` (copied from the npm package's `harnesses/cursor/bundle/` at install time). Restart Cursor after install so it loads the new hooks. ```bash hivemind cursor install +# or +hivemind install --only cursor ``` -Auto-capture is enabled the same way as Claude Code / Codex / OpenClaw. +Auto-capture, auto-recall, rules injection, skill auto-pull, codebase graph builds, and session summaries work the same way as Claude Code / Codex / OpenClaw. Session summaries call `cursor-agent --print`; install the Cursor CLI and sign in so wiki summaries are not empty placeholders. + +#### Editor extension (optional) + +The first-party **Hivemind for Cursor** extension adds a status bar health indicator, command-palette actions, and an in-editor dashboard (KPIs, settings, sessions, codebase graph, rules, skill sync). It can wire hooks and log in without leaving the editor. + +From a clone of this repo (after `npm run build` at the repo root): + +```bash +cd harnesses/cursor/extension +npm install +npm run compile +``` + +Then open the `harnesses/cursor/extension/` folder in Cursor/VS Code and press **F5** to launch an Extension Development Host, or package a VSIX with `npx vsce package` and install it. + +| Command | What it does | +|---------|----------------| +| **Hivemind: Run Onboarding** | Wire hooks, prompt login, refresh status | +| **Hivemind: Log In / Log Out** | Browser device flow or API key | +| **Hivemind: Show Status** | Health detail (CLI, cursor-agent, hooks, login) | +| **Hivemind: Wire / Refresh Hooks** | Copy bundle to `~/.cursor/hivemind/` and merge `hooks.json` | +| **Hivemind: Open Dashboard** | Webview: KPIs, graph, rules, skills | +| **Hivemind: Open Logs** | Output channel + wiki-worker log tail | + +Skillify auto-pull fans symlinks into `~/.cursor/skills-cursor/` (global) and `/.cursor/skills/` (project). The extension keeps those links in sync with `~/.claude/skills/` when you open a workspace. + +Full extension docs: **[harnesses/cursor/extension/README.md](harnesses/cursor/extension/README.md)**.
@@ -435,10 +468,19 @@ Setup, BYOC, agent integrations, or workflow. Come ask in the community: git clone https://github.com/activeloopai/hivemind.git cd hivemind npm install -npm run build # tsc + esbuild → harnesses/claude-code/bundle/ + harnesses/codex/bundle/ + cursor/bundle/ + harnesses/openclaw/dist/ + mcp/bundle/ + bundle/cli.js +npm run build # tsc + esbuild → harnesses/claude-code/bundle/ + harnesses/codex/bundle/ + harnesses/cursor/bundle/ + harnesses/openclaw/dist/ + mcp/bundle/ + bundle/cli.js npm test # vitest ``` +**Cursor extension** (optional; lives in `harnesses/cursor/extension/`): + +```bash +npm run build # hooks bundle → harnesses/cursor/bundle/ (required before F5 or Wire Hooks from source) +cd harnesses/cursor/extension && npm install && npm run compile +``` + +Press **F5** with the `harnesses/cursor/extension/` folder open to run the extension in a dev host. See **[harnesses/cursor/extension/README.md](harnesses/cursor/extension/README.md)**. + Test locally with Claude Code: ```bash diff --git a/cursor-extension/README.md b/cursor-extension/README.md deleted file mode 100644 index 22eb8522..00000000 --- a/cursor-extension/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Hivemind for Cursor - -VS Code / Cursor extension that surfaces Hivemind health, authentication, dashboard KPIs, codebase graph, rules, and skill sync inside the editor. - -## Development - -```bash -cd cursor-extension -npm install -npm run watch # or npm run compile -``` - -Press F5 in VS Code with the extension folder open, or install the VSIX after packaging. - -## Features - -- Status bar health indicator (CLI, cursor-agent, hooks, login) -- One-click hook auto-wiring to `~/.cursor/hooks.json` -- Browser and API-key Hivemind login -- Dashboard webview: KPIs, settings, sessions, graph, rules, skills -- Cursor skill symlink bridge (`~/.cursor/skills-cursor/` and project `.cursor/skills/`) - -## Requirements - -- Hivemind CLI on PATH -- `cursor-agent` for session summaries -- Built hivemind bundle at `harnesses/cursor/bundle/` (run `npm run build` in repo root) diff --git a/cursor-extension/scripts/load-dashboard.mjs b/cursor-extension/scripts/load-dashboard.mjs deleted file mode 100644 index 59e2ad4e..00000000 --- a/cursor-extension/scripts/load-dashboard.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { loadDashboardData } from "../../src/dashboard/data.js"; -import { readUsageRecords } from "../../src/notifications/usage-tracker.js"; - -const cwd = process.argv[2] || process.cwd(); -const data = await loadDashboardData({ cwd }); -console.log(JSON.stringify(data)); diff --git a/cursor-extension/src/bridge/skill-sync.ts b/cursor-extension/src/bridge/skill-sync.ts deleted file mode 100644 index 8f87861e..00000000 --- a/cursor-extension/src/bridge/skill-sync.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; -import * as vscode from "vscode"; -import type { SkillSyncResult, SkillSyncState } from "../types/health"; - -interface PulledEntry { - dirName: string; - install: "global" | "project"; - installRoot: string; - symlinks: string[]; -} - -interface PulledManifest { - version: number; - entries: PulledEntry[]; -} - -function canonicalSkillsRoot(): string { - return join(homedir(), ".claude", "skills"); -} - -function skillifyStateDir(): string { - const override = process.env.HIVEMIND_STATE_DIR?.trim(); - return override && override.length > 0 ? override : join(homedir(), ".deeplake", "state", "skillify"); -} - -function manifestPath(): string { - return join(skillifyStateDir(), "pulled.json"); -} - -function cursorInstalled(home: string = homedir()): boolean { - return existsSync(join(home, ".cursor")); -} - -export function detectCursorSkillsRoots(projectRoot?: string, home: string = homedir()): string[] { - if (!cursorInstalled(home)) return []; - const roots = [join(home, ".cursor", "skills-cursor")]; - if (projectRoot) roots.push(join(projectRoot, ".cursor", "skills")); - return roots; -} - -function fanOutSymlinks(canonicalDir: string, dirName: string, agentRoots: string[]): string[] { - const out: string[] = []; - for (const root of agentRoots) { - const link = join(root, dirName); - let existing; - try { - existing = lstatSync(link); - } catch { - existing = null; - } - if (existing) { - if (!existing.isSymbolicLink()) continue; - let current: string | null; - try { - current = readlinkSync(link); - } catch { - current = null; - } - if (current === canonicalDir) { - out.push(link); - continue; - } - try { - unlinkSync(link); - } catch { - continue; - } - } - try { - mkdirSync(dirname(link), { recursive: true }); - symlinkSync(canonicalDir, link, "dir"); - out.push(link); - } catch { - /* best-effort */ - } - } - return out; -} - -function listCanonicalSkillDirs(skillsRoot: string): string[] { - if (!existsSync(skillsRoot)) return []; - return readdirSync(skillsRoot).filter((name) => { - if (!name.includes("--")) return false; - try { - return lstatSync(join(skillsRoot, name)).isDirectory(); - } catch { - return false; - } - }); -} - -function loadManifest(): PulledManifest { - const path = manifestPath(); - if (!existsSync(path)) return { version: 1, entries: [] }; - try { - const parsed = JSON.parse(readFileSync(path, "utf-8")) as PulledManifest; - if (!Array.isArray(parsed.entries)) return { version: 1, entries: [] }; - return parsed; - } catch { - return { version: 1, entries: [] }; - } -} - -function writeManifest(manifest: PulledManifest): void { - const path = manifestPath(); - mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.tmp.${process.pid}`; - writeFileSync(tmp, JSON.stringify(manifest, null, 2)); - try { - unlinkSync(path); - } catch { - /* may not exist */ - } - try { - renameSync(tmp, path); - } catch { - writeFileSync(path, JSON.stringify(manifest, null, 2)); - } -} - -function mergeSymlinks(entry: PulledEntry, fresh: string[]): void { - const merged = [...new Set([...entry.symlinks, ...fresh])].sort(); - const prior = [...entry.symlinks].sort(); - if (merged.length === prior.length && merged.every((v, i) => v === prior[i])) return; - const manifest = loadManifest(); - const idx = manifest.entries.findIndex( - (e) => e.dirName === entry.dirName && e.installRoot === entry.installRoot, - ); - if (idx >= 0) manifest.entries[idx] = { ...entry, symlinks: merged }; - try { - writeManifest(manifest); - } catch { - /* best-effort */ - } -} - -/** Sync canonical pulled skills into Cursor global and project skill directories. */ -export function syncSkillsToCursor(projectRoot?: string): SkillSyncState { - const skillsRoot = canonicalSkillsRoot(); - const roots = detectCursorSkillsRoots(projectRoot); - const results: SkillSyncResult[] = []; - const dirs = listCanonicalSkillDirs(skillsRoot); - - if (roots.length === 0) { - return { - lastSyncAt: new Date().toISOString(), - results: dirs.map((dirName) => ({ - skillName: dirName, - status: "skipped", - reason: "Cursor not detected or no Cursor skill roots", - })), - syncedCount: 0, - skippedCount: dirs.length, - erroredCount: 0, - }; - } - - let synced = 0; - let skipped = 0; - let errored = 0; - - for (const dirName of dirs) { - const canonicalDir = join(skillsRoot, dirName); - const links = fanOutSymlinks(canonicalDir, dirName, roots); - if (links.length === 0) { - errored++; - results.push({ - skillName: dirName, - status: "errored", - reason: "Could not create Cursor symlinks (conflict or permission)", - }); - continue; - } - if (links.length < roots.length) { - skipped++; - results.push({ - skillName: dirName, - status: "skipped", - path: links[0], - reason: `Synced to ${links.length}/${roots.length} Cursor roots`, - }); - } else { - synced++; - results.push({ - skillName: dirName, - status: "synced", - path: links.join(", "), - }); - } - } - - return { - lastSyncAt: new Date().toISOString(), - results, - syncedCount: synced, - skippedCount: skipped, - erroredCount: errored, - }; -} - -/** Backfill Cursor symlinks for skills already recorded in the pull manifest. */ -export function backfillCursorLinks(projectRoot?: string): number { - const manifest = loadManifest(); - const cursorRoots = detectCursorSkillsRoots(projectRoot); - if (cursorRoots.length === 0) return 0; - - let updated = 0; - for (const entry of manifest.entries) { - const canonical = join(entry.installRoot, entry.dirName); - if (!existsSync(canonical)) continue; - const roots = - entry.install === "project" && projectRoot - ? cursorRoots.filter((p) => p.startsWith(projectRoot)) - : cursorRoots.filter((p) => p.includes("skills-cursor")); - const fresh = fanOutSymlinks(canonical, entry.dirName, roots); - if (fresh.length === 0) continue; - mergeSymlinks(entry, fresh); - updated++; - } - return updated; -} - -/** List local skill directory names available for promotion UI. */ -export function listLocalSkillsForPromoter(): Array<{ dirName: string; scope: "global" | "project"; path: string }> { - const out: Array<{ dirName: string; scope: "global" | "project"; path: string }> = []; - const globalRoot = canonicalSkillsRoot(); - for (const dirName of listCanonicalSkillDirs(globalRoot)) { - out.push({ dirName, scope: "global", path: join(globalRoot, dirName) }); - } - const workspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspace) { - const projectRoot = join(workspace, ".claude", "skills"); - for (const dirName of listCanonicalSkillDirs(projectRoot)) { - out.push({ dirName, scope: "project", path: join(projectRoot, dirName) }); - } - } - return out; -} - -export function skillDirLabel(dirName: string): string { - const idx = dirName.lastIndexOf("--"); - if (idx <= 0) return dirName; - return `${dirName.slice(0, idx)} (${dirName.slice(idx + 2)})`; -} - -export function basenameSkill(dirName: string): string { - return basename(dirName); -} diff --git a/cursor-extension/src/webview/data-bridge.ts b/cursor-extension/src/webview/data-bridge.ts deleted file mode 100644 index 95169322..00000000 --- a/cursor-extension/src/webview/data-bridge.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import { credentialsPath } from "../utils/paths"; -import { readJson } from "../utils/fs-json"; - -const BYTES_PER_TOKEN = 4; -const SAVINGS_MULTIPLIER = 1.7; - -export interface DashboardKpis { - tokensSaved: number | null; - tokensSource: "org" | "local" | "none"; - skillsCreated: number; - memorySearches: number; - sessionsCount: number | null; - userTokensSaved: number | null; - fetchedAt?: string; -} - -export interface DashboardGraphSummary { - commitSha: string | null; - snapshotPath: string; - nodeCount: number; - edgeCount: number; - snapshot: unknown; -} - -export interface DashboardDataEnvelope { - repoKey: string; - repoProject: string; - generatedAt: string; - kpis: DashboardKpis; - graph: DashboardGraphSummary | null; -} - -export interface RecentSession { - sessionId: string; - endedAt: string; - memorySearchCount: number; -} - -function statsFilePath(): string { - return join(homedir(), ".deeplake", "usage-stats.jsonl"); -} - -function readUsageRecords(): Array<{ endedAt: string; sessionId: string; memorySearchBytes: number; memorySearchCount: number }> { - try { - if (!existsSync(statsFilePath())) return []; - const out: Array<{ endedAt: string; sessionId: string; memorySearchBytes: number; memorySearchCount: number }> = []; - for (const line of readFileSync(statsFilePath(), "utf-8").split("\n")) { - if (!line.trim()) continue; - try { - const rec = JSON.parse(line) as Partial<{ endedAt: string; sessionId: string; memorySearchBytes: number; memorySearchCount: number }>; - if (rec.endedAt && rec.sessionId) { - out.push({ - endedAt: rec.endedAt, - sessionId: rec.sessionId, - memorySearchBytes: rec.memorySearchBytes ?? 0, - memorySearchCount: rec.memorySearchCount ?? 0, - }); - } - } catch { - /* skip bad line */ - } - } - return out; - } catch { - return []; - } -} - -function countSkills(userName?: string): number { - const skillsRoot = join(homedir(), ".claude", "skills"); - if (!existsSync(skillsRoot)) return 0; - let count = 0; - for (const name of readdirSync(skillsRoot)) { - if (!name.includes("--")) continue; - if (userName && !name.endsWith(`--${userName}`)) continue; - count++; - } - return count; -} - -function bytesToSavedTokens(bytes: number): number { - if (!Number.isFinite(bytes) || bytes <= 0) return 0; - return (SAVINGS_MULTIPLIER - 1) * (bytes / BYTES_PER_TOKEN); -} - -function deriveProjectKey(cwd: string): { key: string; project: string } { - try { - const out = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" }).trim(); - const key = Buffer.from(out).toString("hex").slice(0, 16); - return { key, project: basename(out) }; - } catch { - const key = Buffer.from(cwd).toString("hex").slice(0, 16); - return { key, project: basename(cwd) }; - } -} - -function graphsRoot(): string { - return process.env.HIVEMIND_GRAPHS_HOME ?? join(homedir(), ".hivemind", "graphs"); -} - -function resolveSnapshot(repoDir: string): DashboardGraphSummary | null { - const snapshotsDir = join(repoDir, "snapshots"); - if (!existsSync(snapshotsDir)) return null; - let snapshotPath: string | null = null; - const pointer = join(repoDir, "latest-commit.txt"); - if (existsSync(pointer)) { - const sha = readFileSync(pointer, "utf-8").trim(); - const candidate = join(snapshotsDir, `${sha}.json`); - if (sha && existsSync(candidate)) snapshotPath = candidate; - } - if (!snapshotPath) { - const candidates = readdirSync(snapshotsDir) - .filter((n) => n.endsWith(".json")) - .map((n) => ({ full: join(snapshotsDir, n), mtime: statSync(join(snapshotsDir, n)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime); - if (candidates[0]) snapshotPath = candidates[0].full; - } - if (!snapshotPath) return null; - try { - const parsed = JSON.parse(readFileSync(snapshotPath, "utf-8")) as { - nodes?: unknown[]; - links?: unknown[]; - graph?: { commit_sha?: string | null }; - }; - if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.links)) return null; - return { - commitSha: parsed.graph?.commit_sha ?? null, - snapshotPath, - nodeCount: parsed.nodes.length, - edgeCount: parsed.links.length, - snapshot: parsed, - }; - } catch { - return null; - } -} - -export async function loadDashboardData(cwd: string): Promise { - const { key: repoKey, project: repoProject } = deriveProjectKey(cwd); - const creds = readJson<{ userName?: string }>(credentialsPath()); - const records = readUsageRecords(); - const localBytes = records.reduce((s, r) => s + r.memorySearchBytes, 0); - const localCount = records.reduce((s, r) => s + r.memorySearchCount, 0); - const skillsCreated = countSkills(creds?.userName); - - let kpis: DashboardKpis; - if (records.length === 0 && !creds) { - kpis = { - tokensSaved: null, - tokensSource: "none", - skillsCreated, - memorySearches: 0, - sessionsCount: null, - userTokensSaved: null, - fetchedAt: new Date().toISOString(), - }; - } else { - const saved = bytesToSavedTokens(localBytes); - kpis = { - tokensSaved: saved, - tokensSource: "local", - skillsCreated, - memorySearches: localCount, - sessionsCount: records.length, - userTokensSaved: saved, - fetchedAt: new Date().toISOString(), - }; - } - - const graph = resolveSnapshot(join(graphsRoot(), repoKey)); - return { - repoKey, - repoProject, - generatedAt: new Date().toISOString(), - kpis, - graph, - }; -} - -export async function loadRecentSessions(_cwd: string): Promise { - return readUsageRecords() - .slice(-20) - .reverse() - .map((r) => ({ - sessionId: r.sessionId, - endedAt: r.endedAt, - memorySearchCount: r.memorySearchCount, - })); -} - -export async function runHivemindCli(args: string[], cwd: string): Promise<{ ok: boolean; stdout: string; stderr: string }> { - try { - const stdout = execFileSync("hivemind", args, { - encoding: "utf-8", - cwd, - timeout: 300_000, - env: { ...process.env }, - }); - return { ok: true, stdout, stderr: "" }; - } catch (err: unknown) { - const e = err as { stdout?: string; stderr?: string; message?: string }; - return { - ok: false, - stdout: e.stdout ?? "", - stderr: e.stderr ?? e.message ?? "Command failed", - }; - } -} diff --git a/cursor-extension/src/webview/html/dashboard-shell.ts b/cursor-extension/src/webview/html/dashboard-shell.ts deleted file mode 100644 index 125dc58f..00000000 --- a/cursor-extension/src/webview/html/dashboard-shell.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { randomBytes } from "node:crypto"; -import * as vscode from "vscode"; - -const D3_CDN = "https://d3js.org/d3.v7.min.js"; - -function getNonce(): string { - return randomBytes(16).toString("hex"); -} - -function cspSource(webview: vscode.Webview): string { - return webview.cspSource; -} - -/** Self-contained dashboard HTML for panel or sidebar webview. */ -export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.Uri): string { - const nonce = getNonce(); - const csp = cspSource(webview); - - return ` - - - - - - Hivemind Dashboard - - - -
-

Hivemind

- -
- -
-
-
-

-
-
-

Org & health

-

-

-
- - -
-

-

Embeddings

-
- Loading… - -
-

Codebase graph

- -

-

Skill sync

- -

-
-
-
    - -
    -
    -
    - - -
    - - -

    -
    -
    -
    - - -
    -
      -
      -
      -

      Local skills available under Claude/Cursor skill directories. Use "Promote to team" to share a skill so teammates pull it on their next session.

      -
      -

      -
      -
      - - - -`; -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 00bb5509..36fe7f61 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ | **Claude Code** | Marketplace plugin | `SessionStart` · `UserPromptSubmit` · `PreToolUse` · `PostToolUse` · `Stop` · `SubagentStop` · `SessionEnd` | | **Codex** | `~/.codex/hooks.json` | `SessionStart` · `UserPromptSubmit` · `PreToolUse(Bash)` · `PostToolUse` · `Stop` | | **OpenClaw** | Native extension at `~/.openclaw/extensions/hivemind/` | `agent_end` capture · `before_agent_start` recall · contracted tools (`hivemind_search`/`read`/`index`) | -| **Cursor (1.7+)** | `~/.cursor/hooks.json` | `sessionStart` · `beforeSubmitPrompt` · `postToolUse` · `afterAgentResponse` · `stop` · `sessionEnd` | +| **Cursor (1.7+)** | `~/.cursor/hooks.json` + optional `harnesses/cursor/extension/` | `sessionStart` · `beforeSubmitPrompt` · `preToolUse(Shell)` · `postToolUse` · `afterAgentResponse` · `stop` · `sessionEnd` | | **Hermes** | Skill at `~/.hermes/skills/hivemind-memory/` | recall via grep on `~/.deeplake/memory/` | | **pi** | `~/.pi/agent/AGENTS.md` + skill | recall via grep on `~/.deeplake/memory/` | @@ -27,7 +27,9 @@ hivemind/ │ └── cli/ ← unified `hivemind install` CLI + per-agent installers ├── harnesses/claude-code/ ← Claude Code plugin source (marketplace-distributed) ├── harnesses/codex/ ← Codex plugin build output (npm-distributed) -├── cursor/ ← Cursor plugin build output (npm-distributed) +├── harnesses/cursor/ ← Cursor hooks bundle + VS Code extension source +│ ├── bundle/ ← hook scripts (npm → ~/.cursor/hivemind/bundle/) +│ └── extension/ ← editor extension (status bar, dashboard, hook wiring UI) ├── harnesses/hermes/ ← Hermes plugin build output (npm-distributed) ├── mcp/ ← MCP server build output (shared by Hermes + future MCP clients) ├── harnesses/openclaw/ ← OpenClaw plugin source + build output (ClawHub-distributed) diff --git a/docs/SKILLIFY.md b/docs/SKILLIFY.md index 6d5e9f3c..b3896b8a 100644 --- a/docs/SKILLIFY.md +++ b/docs/SKILLIFY.md @@ -86,7 +86,7 @@ Every supported agent (Claude Code, Codex, Cursor, Hermes, pi) auto-runs the equ There is no throttle window. File writes inside `runPull` are idempotent (skipped when the local SKILL.md version is at-or-newer than remote), symlink fan-out is `lstat`-checked, and manifest writes are dedup'd — so the per-call cost is one SQL round-trip plus a handful of `existsSync` syscalls when nothing has changed. Bounded by a 5-second timeout so a slow Deeplake never blocks SessionStart. All failures (network, missing table, auth) are swallowed silently and the session starts regardless. -The pull writes canonically to `~/.claude/skills/--/SKILL.md` and fans out symlinks into every detected non-Claude agent skill root (`~/.agents/skills/`, `~/.hermes/skills/`, `~/.pi/agent/skills/`) so Codex / Hermes / pi discover the same skill without an extra copy on disk. Symlink targets are recorded per-entry in the manifest, so `unpull` reverses the fan-out without rescanning the filesystem. +The pull writes canonically to `~/.claude/skills/--/SKILL.md` and fans out symlinks into every detected non-Claude agent skill root (`~/.agents/skills/`, `~/.hermes/skills/`, `~/.pi/agent/skills/`, `~/.cursor/skills-cursor/`, and `/.cursor/skills/` when Cursor is installed) so Codex / Hermes / pi / Cursor discover the same skill without an extra copy on disk. Symlink targets are recorded per-entry in the manifest, so `unpull` reverses the fan-out without rescanning the filesystem. The **Hivemind for Cursor** extension (`harnesses/cursor/extension/`) re-syncs Cursor skill roots on workspace open when developing from a monorepo checkout. | Env var | Default | Effect | |------------------------------------|---------|-----------------------------------------| diff --git a/docs/SUMMARIES.md b/docs/SUMMARIES.md index 26c0ebf3..0b02c0ec 100644 --- a/docs/SUMMARIES.md +++ b/docs/SUMMARIES.md @@ -21,7 +21,7 @@ A per-session JSON sidecar at `~/.claude/hooks/summary-state/.json` t 1. The wiki worker queries the `sessions` table for every event tied to that session. 2. It builds a structured prompt asking the host agent's CLI to extract entities, decisions, files modified, open questions, etc. -3. It shells out to that agent's CLI (`claude -p`, `codex exec`, `pi --print`, …) with the prompt — never a separate API key, the agent's existing credentials are used. +3. It shells out to that agent's CLI (`claude -p`, `codex exec`, `cursor-agent --print`, `pi --print`, …) with the prompt — never a separate API key, the agent's existing credentials are used. 4. The generated markdown is uploaded to the `memory` table at `/summaries//.md`. The shared embedding daemon produces the 768-dim `summary_embedding` so the summary is recallable via semantic search. A lock file at `~/.claude/hooks/summary-state/.lock` prevents two workers from running concurrently for the same session. @@ -40,3 +40,7 @@ A lock file at `~/.claude/hooks/summary-state/.lock` prevents two wor | `HIVEMIND_CAPTURE=false` | unset | Disable both capture and summary generation | For pi specifically, the wiki worker is bundled separately at `~/.pi/agent/hivemind/wiki-worker.js` (deposited by `hivemind pi install`). The other agents ship the wiki worker inside their per-agent plugin bundle. + +### Cursor notes + +Summaries on Cursor require **`cursor-agent`** on `PATH` and a logged-in Cursor CLI session. Failures are logged to `~/.deeplake/wiki-worker.log` and do not block the agent. The **Hivemind for Cursor** extension surfaces `cursor-agent` and login health in the status bar; see [harnesses/cursor/extension/README.md](../harnesses/cursor/extension/README.md). diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 9ae1f5ba..61ce608a 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -204,7 +204,7 @@ await build({ bundle: true, platform: "node", format: "esm", - outdir: "cursor/bundle", + outdir: "harnesses/cursor/bundle", external: [ "node:*", "node-liblzma", @@ -231,9 +231,9 @@ await build({ }); for (const h of cursorAll) { - chmodSync(`cursor/bundle/${h.out}.js`, 0o755); + chmodSync(`harnesses/cursor/bundle/${h.out}.js`, 0o755); } -writeFileSync("cursor/bundle/package.json", esmPackageJson); +writeFileSync("harnesses/cursor/bundle/package.json", esmPackageJson); // Hermes Agent bundle (auto-capture via on_session_start / pre_llm_call / // post_tool_call / post_llm_call / on_session_end). diff --git a/harnesses/cursor/.gitkeep b/harnesses/cursor/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/cursor-extension/.vscodeignore b/harnesses/cursor/extension/.vscodeignore similarity index 100% rename from cursor-extension/.vscodeignore rename to harnesses/cursor/extension/.vscodeignore diff --git a/harnesses/cursor/extension/README.md b/harnesses/cursor/extension/README.md new file mode 100644 index 00000000..e9783047 --- /dev/null +++ b/harnesses/cursor/extension/README.md @@ -0,0 +1,116 @@ +# Hivemind for Cursor + +First-party VS Code / Cursor extension: health checks, login, hook wiring, dashboard, codebase graph, rules, and Cursor skill sync. Works alongside the hooks integration installed by `hivemind cursor install` (see the main [README](../../../README.md#cursor-17)). + +## What you get + +| Surface | Purpose | +|---------|---------| +| **Status bar** | Four-dimension health: Hivemind CLI, `cursor-agent`, login, hooks wired | +| **Onboarding** | Wire hooks, log in, reload when `hooks.json` changes | +| **Dashboard webview** | KPIs, Hivemind settings, recent sessions, codebase graph, rules, skill sync | +| **Skill bridge** | Symlinks from `~/.claude/skills/` into Cursor skill roots on workspace open | + +Hooks (capture, recall, skillify, graph, summaries) still run from `~/.cursor/hivemind/bundle/`. The extension provisions that bundle and merges `~/.cursor/hooks.json`; it does not replace the hook scripts. + +## Requirements + +- **Hivemind CLI** on `PATH` (`npm i -g @deeplake/hivemind`) +- **Cursor 1.7+** with the hooks API enabled +- **`cursor-agent`** on `PATH` and logged in (session wiki summaries; skillify gate on Cursor-only machines) +- **Hook bundle** at `~/.cursor/hivemind/bundle/` (from `hivemind cursor install`, extension **Wire Hooks**, or a dev build below) + +When developing from this monorepo, the extension copies the bundle from **`harnesses/cursor/bundle/`** (output of `npm run build` at the repo root), not from npm. + +## Install + +### From source (development) + +```bash +# repo root — build hook scripts first +npm install +npm run build + +cd harnesses/cursor/extension +npm install +npm run compile +``` + +Open the `harnesses/cursor/extension/` folder in Cursor or VS Code, press **F5** (Extension Development Host), then run **Hivemind: Run Onboarding** in the new window. + +### VSIX (local install) + +```bash +cd harnesses/cursor/extension +npm install +npm run compile +npx @vscode/vsce package +``` + +Install the generated `.vsix` via **Extensions: Install from VSIX…**. + +## Commands + +| Command | Title | +|---------|-------| +| `hivemind.runOnboarding` | Hivemind: Run Onboarding | +| `hivemind.login` | Hivemind: Log In | +| `hivemind.logout` | Hivemind: Log Out | +| `hivemind.showStatus` | Hivemind: Show Status | +| `hivemind.wireHooks` | Hivemind: Wire / Refresh Hooks | +| `hivemind.openLogs` | Hivemind: Open Logs | +| `hivemind.openDashboard` | Hivemind: Open Dashboard | + +Activity bar: **Hivemind** container with a **Dashboard** webview. + +## Health check dimensions + +1. **Hivemind CLI** — `hivemind` on PATH and version probe +2. **cursor-agent** — binary on PATH (summaries + skillify gate) +3. **cursor-agent login** — auth state for headless summary generation +4. **Hooks wired** — all seven events present in `~/.cursor/hooks.json`, bundle at `~/.cursor/hivemind/bundle/` + +Status **stale** when hooks reference an older bundle version than installed; run **Wire / Refresh Hooks**. + +## Skill paths (Cursor) + +Hivemind writes skills canonically under `~/.claude/skills/` and fans symlinks to: + +- `~/.cursor/skills-cursor/` (global) +- `/.cursor/skills/` (project) + +The extension runs a sync on activation so Cursor discovers the same skills as Claude Code after `hivemind skillify pull`. Details: [docs/SKILLIFY.md](../../../docs/SKILLIFY.md). + +## Development + +```bash +npm run watch # webpack watch → dist/extension.js +npm run lint # tsc --noEmit +``` + +Layout: + +```text +harnesses/cursor/ +├── bundle/ # hook scripts (npm run build at repo root) +└── extension/ + ├── src/ + │ ├── extension.ts # activation, status bar, onboarding + │ ├── auth/ # device flow, API key, safe URL handling + │ ├── health/ # D1–D4 checks, hook merge / bundle copy + │ ├── statusbar/ # poller, commands, detail view + │ ├── webview/ # dashboard shell + data bridge + │ ├── graph/ # snapshot load, editor sync, impact overlay + │ └── bridge/ # Cursor skill symlink sync + ├── media/icon.svg + └── dist/extension.js # webpack output (published entry) +``` + +Architecture notes (hooks + session context): [library/knowledge/private/frontend/cursor-extension-architecture.md](../../../library/knowledge/private/frontend/cursor-extension-architecture.md). + +## Troubleshooting + +- **Hooks need review in Cursor** — Trust the Hivemind hook commands after install or re-wire; otherwise capture stays off. +- **Bundle missing** — Run `npm run build` in the repo root, then **Wire / Refresh Hooks**, or `hivemind cursor install` from a published npm package. +- **Empty session summaries** — Install `cursor-agent`, sign in, and confirm **Show Status** reports login OK. Check **Open Logs** for `wiki-worker.log` tail. +- **Reload after wiring** — Cursor only picks up `hooks.json` changes after a window reload; onboarding offers **Reload Window** when needed. diff --git a/cursor-extension/media/icon.svg b/harnesses/cursor/extension/media/icon.svg similarity index 100% rename from cursor-extension/media/icon.svg rename to harnesses/cursor/extension/media/icon.svg diff --git a/cursor-extension/package-lock.json b/harnesses/cursor/extension/package-lock.json similarity index 100% rename from cursor-extension/package-lock.json rename to harnesses/cursor/extension/package-lock.json diff --git a/cursor-extension/package.json b/harnesses/cursor/extension/package.json similarity index 94% rename from cursor-extension/package.json rename to harnesses/cursor/extension/package.json index 28eb198e..439da3a7 100644 --- a/cursor-extension/package.json +++ b/harnesses/cursor/extension/package.json @@ -36,6 +36,10 @@ "command": "hivemind.wireHooks", "title": "Hivemind: Wire / Refresh Hooks" }, + { + "command": "hivemind.unwireHooks", + "title": "Hivemind: Unwire Hooks" + }, { "command": "hivemind.openLogs", "title": "Hivemind: Open Logs" diff --git a/harnesses/cursor/extension/scripts/load-dashboard.mjs b/harnesses/cursor/extension/scripts/load-dashboard.mjs new file mode 100644 index 00000000..1966ba15 --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-dashboard.mjs @@ -0,0 +1,5 @@ +import { loadDashboardData } from "../../../../src/dashboard/data.js"; + +const cwd = process.argv[2] || process.cwd(); +const data = await loadDashboardData({ cwd }); +console.log(JSON.stringify(data)); diff --git a/harnesses/cursor/extension/scripts/load-rules.mjs b/harnesses/cursor/extension/scripts/load-rules.mjs new file mode 100644 index 00000000..c7208bca --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-rules.mjs @@ -0,0 +1,36 @@ +import { loadConfig } from "../../../../src/config.js"; +import { DeeplakeApi } from "../../../../src/api/deeplake-api.js"; +import { listRules } from "../../../../src/rules/read.js"; + +const status = process.argv[2] || "active"; +const limit = parseInt(process.argv[3] || "10", 10); + +const config = loadConfig(); +if (!config) { + console.log(JSON.stringify({ + loggedOut: true, + rules: [], + message: "Log in with `hivemind login` to manage team rules.", + })); + process.exit(0); +} + +const api = new DeeplakeApi( + config.token, + config.apiUrl, + config.orgId, + config.workspaceId, + config.skillsTableName, +); +const query = (sql) => api.query(sql); +const rows = await listRules(query, config.rulesTableName, { status, limit }); +console.log(JSON.stringify({ + loggedOut: false, + rules: rows.map((r) => ({ + id: r.rule_id, + status: r.status, + version: r.version, + author: r.assigned_by, + text: r.text, + })), +})); diff --git a/harnesses/cursor/extension/scripts/load-session-summary.mjs b/harnesses/cursor/extension/scripts/load-session-summary.mjs new file mode 100644 index 00000000..011c4866 --- /dev/null +++ b/harnesses/cursor/extension/scripts/load-session-summary.mjs @@ -0,0 +1,99 @@ +import { readFileSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { loadCredentials } from "../../../../src/commands/auth-creds.js"; +import { loadConfig } from "../../../../src/config.js"; +import { DeeplakeApi } from "../../../../src/deeplake-api.js"; +import { sqlStr, sqlIdent } from "../../../../src/utils/sql.js"; + +const sessionId = process.argv[2]; +const userArg = process.argv[3] ?? ""; + +if (!sessionId || !/^[a-zA-Z0-9_-]{1,128}$/.test(sessionId)) { + console.log(JSON.stringify({ text: null, source: "invalid", message: "Invalid session id." })); + process.exit(0); +} + +function localSummaryPath(userName) { + if (!userName || userName.includes("/") || userName.includes("\\") || userName.includes("..")) { + return null; + } + return join(homedir(), ".deeplake", "memory", "summaries", userName, `${sessionId}.md`); +} + +function readLocal(userName) { + const path = localSummaryPath(userName); + if (!path || !existsSync(path)) return null; + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +async function readRemote(creds) { + if (!creds?.token || !creds.userName || !creds.orgId) return { text: null, unreachable: false }; + const cfg = loadConfig(); + let table; + try { + table = sqlIdent(cfg?.tableName ?? "memory"); + } catch { + return { text: null, unreachable: false }; + } + const api = new DeeplakeApi( + creds.token, + creds.apiUrl ?? "https://api.deeplake.ai", + creds.orgId, + creds.workspaceId ?? "default", + table, + ); + const vpath = `/summaries/${creds.userName}/${sessionId}.md`; + try { + const rows = await Promise.race([ + api.query( + `SELECT summary FROM "${table}" WHERE path = '${sqlStr(vpath)}' AND author = '${sqlStr(creds.userName)}' ` + + `AND summary <> '' ORDER BY last_update_date DESC LIMIT 1`, + ), + new Promise((resolve) => setTimeout(() => resolve(null), 4000)), + ]); + if (!rows || rows.length === 0) return { text: null, unreachable: false }; + const summary = rows[0]?.summary; + return { text: typeof summary === "string" && summary.trim() ? summary : null, unreachable: false }; + } catch { + return { text: null, unreachable: true }; + } +} + +const creds = loadCredentials(); +const userName = userArg || creds?.userName || "unknown"; + +const remote = await readRemote(creds); +if (remote.text) { + console.log(JSON.stringify({ text: remote.text, source: "remote", message: null })); + process.exit(0); +} + +const local = readLocal(userName); +if (local) { + console.log(JSON.stringify({ text: local, source: "local", message: null })); + process.exit(0); +} + +if (remote.unreachable) { + console.log( + JSON.stringify({ + text: null, + source: "unreachable", + message: "Memory table unreachable. Showing no summary until connectivity returns.", + }), + ); + process.exit(0); +} + +console.log( + JSON.stringify({ + text: null, + source: "missing", + message: `No summary found for session ${sessionId}.`, + }), +); diff --git a/cursor-extension/scripts/load-sessions.mjs b/harnesses/cursor/extension/scripts/load-sessions.mjs similarity index 56% rename from cursor-extension/scripts/load-sessions.mjs rename to harnesses/cursor/extension/scripts/load-sessions.mjs index 2291f679..305c3a8e 100644 --- a/cursor-extension/scripts/load-sessions.mjs +++ b/harnesses/cursor/extension/scripts/load-sessions.mjs @@ -1,9 +1,10 @@ -import { readUsageRecords } from "../../src/notifications/usage-tracker.js"; +import { readUsageRecords } from "../../../../src/notifications/usage-tracker.js"; -const cwd = process.argv[2] || process.cwd(); const records = readUsageRecords().slice(-20).reverse(); console.log(JSON.stringify(records.map((r) => ({ sessionId: r.sessionId, endedAt: r.endedAt, memorySearchCount: r.memorySearchCount, + project: r.project ?? null, + hadRecall: (r.memorySearchCount ?? 0) > 0, })))); diff --git a/cursor-extension/src/auth/api-key.ts b/harnesses/cursor/extension/src/auth/api-key.ts similarity index 100% rename from cursor-extension/src/auth/api-key.ts rename to harnesses/cursor/extension/src/auth/api-key.ts diff --git a/cursor-extension/src/auth/detector.ts b/harnesses/cursor/extension/src/auth/detector.ts similarity index 84% rename from cursor-extension/src/auth/detector.ts rename to harnesses/cursor/extension/src/auth/detector.ts index c30022c4..0589a6a9 100644 --- a/cursor-extension/src/auth/detector.ts +++ b/harnesses/cursor/extension/src/auth/detector.ts @@ -25,7 +25,7 @@ export function isCredentialFilePresent(): boolean { return existsSync(credentialsPath()) && loadStoredCredentials() !== null; } -async function validateCredentialsOnline(creds: StoredCredentials): Promise { +async function validateCredentialsOnline(creds: StoredCredentials): Promise<"online" | "logged_out" | "offline"> { const apiUrl = sanitizeApiUrl(creds.apiUrl, DEFAULT_API_URL); try { const resp = await fetch(`${apiUrl}/me`, { @@ -35,9 +35,10 @@ async function validateCredentialsOnline(creds: StoredCredentials): Promise { const identity = creds.userName ?? creds.orgName ?? creds.orgId; const online = await validateCredentialsOnline(creds); - if (!online) { + if (online === "offline") { return { state: "unknown_offline", identity, @@ -68,6 +69,16 @@ export async function detectAuthState(): Promise { cursorAgentMessage, }; } + if (online === "logged_out") { + return { + state: "logged_out", + identity, + orgName: creds.orgName, + workspaceId: creds.workspaceId, + cursorAgentLoggedIn, + cursorAgentMessage, + }; + } return { state: "logged_in", diff --git a/cursor-extension/src/auth/device-flow.ts b/harnesses/cursor/extension/src/auth/device-flow.ts similarity index 100% rename from cursor-extension/src/auth/device-flow.ts rename to harnesses/cursor/extension/src/auth/device-flow.ts diff --git a/cursor-extension/src/auth/index.ts b/harnesses/cursor/extension/src/auth/index.ts similarity index 100% rename from cursor-extension/src/auth/index.ts rename to harnesses/cursor/extension/src/auth/index.ts diff --git a/cursor-extension/src/auth/logout.ts b/harnesses/cursor/extension/src/auth/logout.ts similarity index 100% rename from cursor-extension/src/auth/logout.ts rename to harnesses/cursor/extension/src/auth/logout.ts diff --git a/cursor-extension/src/auth/safe-url.ts b/harnesses/cursor/extension/src/auth/safe-url.ts similarity index 100% rename from cursor-extension/src/auth/safe-url.ts rename to harnesses/cursor/extension/src/auth/safe-url.ts diff --git a/cursor-extension/src/bridge/auto-sync.ts b/harnesses/cursor/extension/src/bridge/auto-sync.ts similarity index 100% rename from cursor-extension/src/bridge/auto-sync.ts rename to harnesses/cursor/extension/src/bridge/auto-sync.ts diff --git a/harnesses/cursor/extension/src/bridge/skill-sync.ts b/harnesses/cursor/extension/src/bridge/skill-sync.ts new file mode 100644 index 00000000..7d584a13 --- /dev/null +++ b/harnesses/cursor/extension/src/bridge/skill-sync.ts @@ -0,0 +1,340 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import * as vscode from "vscode"; +import type { SkillSyncResult, SkillSyncState } from "../types/health"; + +function canonicalSkillsRoot(): string { + return join(homedir(), ".claude", "skills"); +} + +function agentRoots(projectRoot?: string): string[] { + // Mirrors src/skillify/agent-roots.ts detectAgentSkillsRoots(). + const home = homedir(); + const canonicalRoot = canonicalSkillsRoot(); + const out: string[] = []; + const codexInstalled = existsSync(join(home, ".codex")); + const piInstalled = existsSync(join(home, ".pi", "agent")); + const hermesInstalled = existsSync(join(home, ".hermes")); + const cursorInstalled = existsSync(join(home, ".cursor")); + + if (codexInstalled || piInstalled) out.push(join(home, ".agents", "skills")); + if (hermesInstalled) out.push(join(home, ".hermes", "skills")); + if (piInstalled) out.push(join(home, ".pi", "agent", "skills")); + if (cursorInstalled) { + out.push(join(home, ".cursor", "skills-cursor")); + if (projectRoot) out.push(join(projectRoot, ".cursor", "skills")); + } + return out.filter((p) => p !== canonicalRoot); +} + +function fanOutSymlinks(canonicalDir: string, dirName: string, agentRootsList: string[]): string[] { + const out: string[] = []; + for (const root of agentRootsList) { + const link = join(root, dirName); + let existing; + try { + existing = lstatSync(link); + } catch { + existing = null; + } + if (existing) { + if (!existing.isSymbolicLink()) continue; + let current: string | null; + try { + current = readlinkSync(link); + } catch { + current = null; + } + if (current === canonicalDir) { + out.push(link); + continue; + } + try { + unlinkSync(link); + } catch { + continue; + } + } + try { + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(canonicalDir, link, "dir"); + out.push(link); + } catch { + /* best-effort */ + } + } + return out; +} + +function fanOutWithConflicts(canonicalDir: string, dirName: string, roots: string[]): { links: string[]; conflicts: string[] } { + const links = fanOutSymlinks(canonicalDir, dirName, roots); + const conflicts: string[] = []; + for (const root of roots) { + const link = join(root, dirName); + if (links.includes(link)) continue; + try { + const st = lstatSync(link); + if (!st.isSymbolicLink()) conflicts.push(link); + } catch { + /* permission or missing — not a user-file conflict */ + } + } + return { links, conflicts }; +} + +function listPulledSkillDirs(skillsRoot: string): string[] { + if (!existsSync(skillsRoot)) return []; + return readdirSync(skillsRoot).filter((name) => { + if (!name.includes("--")) return false; + try { + return lstatSync(join(skillsRoot, name)).isDirectory(); + } catch { + return false; + } + }); +} + +function listMinedSkillNames(projectRoot?: string): string[] { + const stateDir = join(homedir(), ".deeplake", "state", "skillify"); + const names = new Set(); + if (existsSync(stateDir)) { + for (const file of readdirSync(stateDir)) { + if (!file.endsWith(".json") || file === "config.json" || file === "pulled.json") continue; + try { + const state = JSON.parse(readFileSync(join(stateDir, file), "utf-8")) as { + skillsGenerated?: string[]; + project?: string; + }; + if (projectRoot && state.project) { + const base = basename(projectRoot); + if (state.project !== base && !projectRoot.includes(state.project)) continue; + } + for (const n of state.skillsGenerated ?? []) { + if (typeof n === "string" && n.length > 0) names.add(n); + } + } catch { + /* ignore */ + } + } + } + if (projectRoot) { + const projectSkills = join(projectRoot, ".claude", "skills"); + if (existsSync(projectSkills)) { + for (const name of readdirSync(projectSkills)) { + if (name.includes("--")) continue; + if (existsSync(join(projectSkills, name, "SKILL.md"))) names.add(name); + } + } + } + return [...names].sort(); +} + +function readSkillShareScope(skillPath: string): "me" | "team" | "unknown" { + try { + const text = readFileSync(join(skillPath, "SKILL.md"), "utf-8"); + const m = text.match(/^scope:\s*(me|team)\s*$/m); + if (m) return m[1] as "me" | "team"; + } catch { + /* ignore */ + } + return "unknown"; +} + +function parseDirName(dirName: string): { name: string; author: string } { + const idx = dirName.lastIndexOf("--"); + if (idx <= 0) return { name: dirName, author: "" }; + return { name: dirName.slice(0, idx), author: dirName.slice(idx + 2) }; +} + +interface PulledEntry { + dirName: string; + name: string; + author: string; + install: "global" | "project"; + installRoot: string; + symlinks: string[]; +} + +interface PulledManifest { + version: 1; + entries: PulledEntry[]; +} + +function manifestPath(): string { + return join(homedir(), ".deeplake", "state", "skillify", "pulled.json"); +} + +function loadManifest(): PulledManifest { + const path = manifestPath(); + if (!existsSync(path)) return { version: 1, entries: [] }; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as PulledManifest; + if (parsed.version === 1 && Array.isArray(parsed.entries)) return parsed; + } catch { + /* ignore */ + } + return { version: 1, entries: [] }; +} + +function writeManifest(manifest: PulledManifest): void { + const path = manifestPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); +} + +function mergeSymlinksIntoManifest( + install: "global" | "project", + installRoot: string, + dirName: string, + freshLinks: string[], +): void { + if (freshLinks.length === 0) return; + const manifest = loadManifest(); + const existing = manifest.entries.find( + (e) => e.install === install && e.installRoot === installRoot && e.dirName === dirName, + ); + const parsed = parseDirName(dirName); + const symlinks = [...new Set([...(existing?.symlinks ?? []), ...freshLinks])].sort(); + const next = { + dirName, + name: existing?.name ?? parsed.name, + author: existing?.author ?? parsed.author, + install, + installRoot, + symlinks, + }; + const idx = manifest.entries.findIndex( + (e) => e.install === install && e.installRoot === installRoot && e.dirName === dirName, + ); + if (idx >= 0) manifest.entries[idx] = { ...manifest.entries[idx]!, ...next }; + else manifest.entries.push(next); + writeManifest(manifest); +} + +/** Sync canonical pulled skills into agent skill directories (incl. Cursor). */ +export function syncSkillsToCursor(projectRoot?: string): SkillSyncState { + const skillsRoot = canonicalSkillsRoot(); + const roots = agentRoots(projectRoot); + const results: SkillSyncResult[] = []; + const dirs = listPulledSkillDirs(skillsRoot); + + if (roots.length === 0) { + return { + lastSyncAt: new Date().toISOString(), + results: dirs.map((dirName) => ({ + skillName: dirName, + status: "skipped", + reason: "No agent skill roots detected", + })), + syncedCount: 0, + skippedCount: dirs.length, + erroredCount: 0, + }; + } + + let synced = 0; + let skipped = 0; + let errored = 0; + + for (const dirName of dirs) { + const canonicalDir = join(skillsRoot, dirName); + const { links, conflicts } = fanOutWithConflicts(canonicalDir, dirName, roots); + if (links.length === 0) { + errored++; + results.push({ + skillName: dirName, + status: "errored", + reason: conflicts.length > 0 + ? `Blocked by existing file at ${conflicts[0]}` + : "Could not create symlinks (permission or filesystem error)", + }); + continue; + } + if (links.length < roots.length || conflicts.length > 0) { + errored++; + const conflictNote = conflicts.length > 0 ? `; conflict at ${conflicts.join(", ")}` : ""; + results.push({ + skillName: dirName, + status: "errored", + path: links.join(", "), + reason: `Partial reach: ${links.length}/${roots.length} roots${conflictNote}`, + }); + } else { + synced++; + results.push({ + skillName: dirName, + status: "synced", + path: links.join(", "), + }); + } + mergeSymlinksIntoManifest("global", skillsRoot, dirName, links); + } + + return { + lastSyncAt: new Date().toISOString(), + results, + syncedCount: synced, + skippedCount: skipped, + erroredCount: errored, + }; +} + +/** Backfill agent symlinks for skills already recorded in the pull manifest. */ +export function backfillCursorLinks(projectRoot?: string): number { + const manifest = loadManifest(); + const roots = agentRoots(projectRoot); + if (roots.length === 0) return 0; + + let updated = 0; + for (const entry of manifest.entries) { + const canonical = join(entry.installRoot, entry.dirName); + if (!existsSync(canonical)) continue; + const { links } = fanOutWithConflicts(canonical, entry.dirName, roots); + if (links.length === 0) continue; + mergeSymlinksIntoManifest(entry.install, entry.installRoot, entry.dirName, links); + updated++; + } + return updated; +} + +/** List locally mined skills for the promoter pane (not pulled --author dirs). */ +export function listLocalSkillsForPromoter(): Array<{ + dirName: string; + scope: "global" | "project"; + path: string; + shareScope: "me" | "team" | "unknown"; +}> { + const out: Array<{ dirName: string; scope: "global" | "project"; path: string; shareScope: "me" | "team" | "unknown" }> = []; + const workspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const globalRoot = canonicalSkillsRoot(); + + for (const name of listMinedSkillNames(workspace)) { + const projectPath = workspace ? join(workspace, ".claude", "skills", name) : ""; + const globalPath = join(globalRoot, name); + if (workspace && existsSync(join(projectPath, "SKILL.md"))) { + out.push({ + dirName: name, + scope: "project", + path: projectPath, + shareScope: readSkillShareScope(projectPath), + }); + } else if (existsSync(join(globalPath, "SKILL.md"))) { + out.push({ + dirName: name, + scope: "global", + path: globalPath, + shareScope: readSkillShareScope(globalPath), + }); + } + } + return out; +} + +export function skillDirLabel(dirName: string): string { + return dirName; +} + +export function basenameSkill(dirName: string): string { + return basename(dirName); +} diff --git a/cursor-extension/src/extension.ts b/harnesses/cursor/extension/src/extension.ts similarity index 90% rename from cursor-extension/src/extension.ts rename to harnesses/cursor/extension/src/extension.ts index 8058c983..fb2fc870 100644 --- a/cursor-extension/src/extension.ts +++ b/harnesses/cursor/extension/src/extension.ts @@ -1,10 +1,12 @@ import * as vscode from "vscode"; +import { join } from "node:path"; import { HealthPoller } from "./statusbar/poller"; import { getStatusBarPresentation } from "./statusbar/indicator"; import { registerHivemindCommands } from "./statusbar/commands"; import { showStatusDetail } from "./statusbar/detail-view"; import { DashboardPanel, registerDashboardWebview } from "./webview/DashboardPanel"; import { runAutoSyncOnActivation } from "./bridge/auto-sync"; +import { setBundledExtensionSrc } from "./health"; import { logSafe } from "./utils/output"; import type { StatusSnapshot } from "./types/health"; @@ -13,6 +15,7 @@ let poller: HealthPoller | undefined; export function activate(context: vscode.ExtensionContext): void { logSafe("Hivemind extension activating…"); + setBundledExtensionSrc(join(context.extensionUri.fsPath, "bundle")); poller = new HealthPoller(); statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); @@ -20,6 +23,8 @@ export function activate(context: vscode.ExtensionContext): void { statusBarItem.show(); context.subscriptions.push(statusBarItem); + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const updateBar = (snap: StatusSnapshot): void => { const pres = getStatusBarPresentation(snap.barState); statusBarItem!.text = pres.text; @@ -29,6 +34,7 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push( poller.onUpdate((snap) => updateBar(snap)), + vscode.commands.registerCommand("hivemind.pollHealthNow", () => poller!.pollOnce(workspaceRoot)), ...registerHivemindCommands( () => poller!.pollOnce(workspaceRoot), () => { @@ -42,7 +48,6 @@ export function activate(context: vscode.ExtensionContext): void { registerDashboardWebview(context); - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; void runAutoSyncOnActivation(workspaceRoot); poller.start(); diff --git a/cursor-extension/src/graph/editor-sync.ts b/harnesses/cursor/extension/src/graph/editor-sync.ts similarity index 100% rename from cursor-extension/src/graph/editor-sync.ts rename to harnesses/cursor/extension/src/graph/editor-sync.ts diff --git a/cursor-extension/src/graph/impact-overlay.ts b/harnesses/cursor/extension/src/graph/impact-overlay.ts similarity index 100% rename from cursor-extension/src/graph/impact-overlay.ts rename to harnesses/cursor/extension/src/graph/impact-overlay.ts diff --git a/cursor-extension/src/graph/snapshot-loader.ts b/harnesses/cursor/extension/src/graph/snapshot-loader.ts similarity index 100% rename from cursor-extension/src/graph/snapshot-loader.ts rename to harnesses/cursor/extension/src/graph/snapshot-loader.ts diff --git a/cursor-extension/src/graph/types.ts b/harnesses/cursor/extension/src/graph/types.ts similarity index 100% rename from cursor-extension/src/graph/types.ts rename to harnesses/cursor/extension/src/graph/types.ts diff --git a/cursor-extension/src/health/checker.ts b/harnesses/cursor/extension/src/health/checker.ts similarity index 92% rename from cursor-extension/src/health/checker.ts rename to harnesses/cursor/extension/src/health/checker.ts index 3d01d4a9..9285196f 100644 --- a/cursor-extension/src/health/checker.ts +++ b/harnesses/cursor/extension/src/health/checker.ts @@ -6,6 +6,7 @@ import type { HealthDimension, HealthResult } from "../types/health"; import { cursorBundleDir, cursorHooksPath, + cursorPluginDir, hivemindCursorBundleSrc, } from "../utils/paths"; import { readJson } from "../utils/fs-json"; @@ -101,7 +102,7 @@ function readExtensionVersion(): string { } function readBundleVersion(): string | undefined { - const stamp = join(cursorBundleDir(), ".version"); + const stamp = join(cursorPluginDir(), ".hivemind_version"); if (!existsSync(stamp)) return undefined; try { return readFileSync(stamp, "utf-8").trim() || undefined; @@ -172,13 +173,22 @@ function checkCursorAgentLogin(): HealthDimension { message: "cursor-agent is logged in.", }; } catch (err: unknown) { + const execErr = err as { status?: number; code?: string; message?: string }; + if (execErr.code === "ENOENT") { + return { + id: "d3", + label: "cursor-agent login", + status: "missing", + message: "cursor-agent binary disappeared between detection and status check.", + }; + } + const exitCode = typeof execErr.status === "number" ? execErr.status : undefined; const msg = err instanceof Error ? err.message : String(err); const loggedOut = - msg.includes("not logged") || - msg.includes("Not logged") || - msg.includes("login") || - msg.includes("401") || - msg.includes("ENOENT"); + exitCode === 401 || + exitCode === 403 || + /not logged/i.test(msg) || + /login required/i.test(msg); return { id: "d3", label: "cursor-agent login", @@ -263,25 +273,26 @@ export async function runHealthCheck(): Promise { const bundleVersion = readBundleVersion() ?? readExtensionVersion(); const srcBundle = hivemindCursorBundleSrc(); const srcPresent = existsSync(join(srcBundle, "capture.js")); + const provisionedPresent = bundlePresent; const d1 = checkHivemindCli(); const d2 = checkCursorAgentCli(); const d3 = checkCursorAgentLogin(); const { dimension: d4, wiredVersion } = checkHooksWired(bundlePresent ? bundleVersion : undefined); - if (!bundlePresent && !srcPresent) { + if (!provisionedPresent && !srcPresent) { d4.status = "error"; d4.message = "Hook bundle missing at ~/.cursor/hivemind/bundle/. Run hivemind cursor install or Wire Hooks after building."; } const dimensions = [d1, d2, d3, d4]; const summariesDisabled = d2.status !== "ok" || d3.status !== "ok"; - const allHealthy = dimensions.every((d) => d.status === "ok") && bundlePresent; + const allHealthy = dimensions.every((d) => d.status === "ok") && provisionedPresent; return { checkedAt: new Date().toISOString(), dimensions, - bundlePresent, + bundlePresent: provisionedPresent, bundleVersion, wiredVersion, allHealthy, diff --git a/cursor-extension/src/health/index.ts b/harnesses/cursor/extension/src/health/index.ts similarity index 59% rename from cursor-extension/src/health/index.ts rename to harnesses/cursor/extension/src/health/index.ts index 24a68e58..46282c98 100644 --- a/cursor-extension/src/health/index.ts +++ b/harnesses/cursor/extension/src/health/index.ts @@ -1,3 +1,3 @@ export { runHealthCheck, getHivemindInstallCommand } from "./checker"; -export { autoWireHooks, unwireHooks } from "./wirings"; +export { autoWireHooks, unwireHooks, setBundledExtensionSrc } from "./wirings"; export type { WireResult } from "./wirings"; diff --git a/cursor-extension/src/health/wirings.ts b/harnesses/cursor/extension/src/health/wirings.ts similarity index 64% rename from cursor-extension/src/health/wirings.ts rename to harnesses/cursor/extension/src/health/wirings.ts index 376c4c6b..3f4368e2 100644 --- a/cursor-extension/src/health/wirings.ts +++ b/harnesses/cursor/extension/src/health/wirings.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { buildHookConfig, isHivemindEntry } from "./checker"; import { @@ -58,19 +58,65 @@ export function stripHooksFromConfig(existing: Record | null): return existing; } +function bundleAlreadyProvisioned(src: string, dest: string): boolean { + const destCapture = join(dest, "capture.js"); + if (!existsSync(destCapture)) return false; + const srcCapture = join(src, "capture.js"); + if (!existsSync(srcCapture)) return true; + try { + return statSync(destCapture).mtimeMs >= statSync(srcCapture).mtimeMs; + } catch { + return false; + } +} + +let bundledExtensionSrc: string | undefined; + +/** Called once at extension activate so marketplace installs can provision + * the hook bundle shipped inside the VSIX. */ +export function setBundledExtensionSrc(src: string | undefined): void { + bundledExtensionSrc = src; +} + +function resolveBundleSource(): { src: string; ok: boolean; message: string } { + const monorepoSrc = hivemindCursorBundleSrc(); + if (existsSync(join(monorepoSrc, "capture.js"))) { + return { src: monorepoSrc, ok: true, message: "Bundle provisioned from monorepo source." }; + } + if (bundledExtensionSrc && existsSync(join(bundledExtensionSrc, "capture.js"))) { + return { src: bundledExtensionSrc, ok: true, message: "Bundle provisioned from extension package." }; + } + const dest = cursorBundleDir(); + if (existsSync(join(dest, "capture.js"))) { + return { src: dest, ok: true, message: "Using existing CLI-provisioned bundle." }; + } + return { + src: monorepoSrc, + ok: false, + message: `Cursor bundle missing. Run 'hivemind cursor install' or build harnesses/cursor/bundle in the hivemind repo.`, + }; +} + function provisionBundle(): { ok: boolean; message: string } { - const src = hivemindCursorBundleSrc(); - if (!existsSync(join(src, "capture.js"))) { - return { - ok: false, - message: `Cursor bundle missing at ${src}. Run 'npm run build' in the hivemind repo first.`, - }; + const resolved = resolveBundleSource(); + if (!resolved.ok) { + return { ok: false, message: resolved.message }; } + + const dest = cursorBundleDir(); + if (resolved.src === dest) { + return { ok: true, message: resolved.message }; + } + + if (bundleAlreadyProvisioned(resolved.src, dest)) { + return { ok: true, message: "Bundle already up to date." }; + } + mkdirSync(cursorPluginDir(), { recursive: true }); - cpSync(src, cursorBundleDir(), { recursive: true, force: true }); + cpSync(resolved.src, dest, { recursive: true, force: true }); const version = readExtensionVersion(); - writeFileSync(join(cursorPluginDir(), ".version"), version + "\n"); - return { ok: true, message: "Bundle provisioned." }; + writeFileSync(join(cursorPluginDir(), ".hivemind_version"), `${version}\n`, "utf-8"); + return { ok: true, message: resolved.message }; } export async function autoWireHooks(): Promise { diff --git a/cursor-extension/src/statusbar/commands.ts b/harnesses/cursor/extension/src/statusbar/commands.ts similarity index 79% rename from cursor-extension/src/statusbar/commands.ts rename to harnesses/cursor/extension/src/statusbar/commands.ts index b87dd705..5bb324d6 100644 --- a/cursor-extension/src/statusbar/commands.ts +++ b/harnesses/cursor/extension/src/statusbar/commands.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import type { StatusSnapshot } from "../types/health"; -import { autoWireHooks } from "../health"; +import { autoWireHooks, unwireHooks, setBundledExtensionSrc } from "../health"; import { promptLoginMethod, logout } from "../auth"; import { getOutputChannel } from "../utils/output"; import { wikiWorkerLogPath } from "../utils/paths"; @@ -46,6 +46,18 @@ export function openLogsCommand(): void { } } +export async function unwireHooksCommand(): Promise { + const result = await unwireHooks(); + if (result.ok) { + const actions = result.reloadRequired ? ["Reload Window"] : []; + await vscode.window.showInformationMessage(result.message, ...actions).then((c) => { + if (c === "Reload Window") void vscode.commands.executeCommand("workbench.action.reloadWindow"); + }); + } else { + await vscode.window.showErrorMessage(result.message); + } +} + export function registerHivemindCommands( poll: () => Promise, showDetail: () => void, @@ -56,6 +68,7 @@ export function registerHivemindCommands( vscode.commands.registerCommand("hivemind.logout", () => logout().then(() => poll())), vscode.commands.registerCommand("hivemind.showStatus", showDetail), vscode.commands.registerCommand("hivemind.wireHooks", wireHooksCommand), + vscode.commands.registerCommand("hivemind.unwireHooks", unwireHooksCommand), vscode.commands.registerCommand("hivemind.openLogs", openLogsCommand), ]; } diff --git a/cursor-extension/src/statusbar/detail-view.ts b/harnesses/cursor/extension/src/statusbar/detail-view.ts similarity index 100% rename from cursor-extension/src/statusbar/detail-view.ts rename to harnesses/cursor/extension/src/statusbar/detail-view.ts diff --git a/cursor-extension/src/statusbar/indicator.ts b/harnesses/cursor/extension/src/statusbar/indicator.ts similarity index 94% rename from cursor-extension/src/statusbar/indicator.ts rename to harnesses/cursor/extension/src/statusbar/indicator.ts index 2020b1cd..c7d5c8a8 100644 --- a/cursor-extension/src/statusbar/indicator.ts +++ b/harnesses/cursor/extension/src/statusbar/indicator.ts @@ -18,8 +18,7 @@ export function composeStatusBarState(health: HealthResult, auth: AuthState, ski const summaryImpaired = d2?.status !== "ok" || d3?.status === "logged_out"; if (summaryImpaired) return "degraded"; - // Skill-sync failures degrade the status: team skills are not reaching the Cursor agent. - if (skillSync && skillSync.erroredCount > 0) return "degraded"; + // Skill-sync health is surfaced in the dashboard, not the status bar (PRD-002c / PRD-005a). if (health.allHealthy && auth.state === "logged_in") return "healthy"; if (auth.state === "logged_in" && !health.allHealthy) return "degraded"; diff --git a/cursor-extension/src/statusbar/poller.ts b/harnesses/cursor/extension/src/statusbar/poller.ts similarity index 88% rename from cursor-extension/src/statusbar/poller.ts rename to harnesses/cursor/extension/src/statusbar/poller.ts index 1f8d7289..746807d3 100644 --- a/cursor-extension/src/statusbar/poller.ts +++ b/harnesses/cursor/extension/src/statusbar/poller.ts @@ -39,10 +39,12 @@ export class HealthPoller { const health = await runHealthCheck(); const auth = await detectAuthState(); let skillSync; - try { - skillSync = syncSkillsToCursor(projectRoot); - } catch { - /* best-effort; never block the poll */ + if (process.env.HIVEMIND_AUTOPULL_DISABLED !== "1") { + try { + skillSync = syncSkillsToCursor(projectRoot); + } catch { + /* best-effort; never block the poll */ + } } const snap = buildSnapshot(health, auth, skillSync); this.lastSnapshot = snap; diff --git a/cursor-extension/src/types/health.ts b/harnesses/cursor/extension/src/types/health.ts similarity index 100% rename from cursor-extension/src/types/health.ts rename to harnesses/cursor/extension/src/types/health.ts diff --git a/cursor-extension/src/utils/fs-json.ts b/harnesses/cursor/extension/src/utils/fs-json.ts similarity index 100% rename from cursor-extension/src/utils/fs-json.ts rename to harnesses/cursor/extension/src/utils/fs-json.ts diff --git a/cursor-extension/src/utils/output.ts b/harnesses/cursor/extension/src/utils/output.ts similarity index 100% rename from cursor-extension/src/utils/output.ts rename to harnesses/cursor/extension/src/utils/output.ts diff --git a/cursor-extension/src/utils/paths.ts b/harnesses/cursor/extension/src/utils/paths.ts similarity index 95% rename from cursor-extension/src/utils/paths.ts rename to harnesses/cursor/extension/src/utils/paths.ts index b42ca1bc..3a957f40 100644 --- a/cursor-extension/src/utils/paths.ts +++ b/harnesses/cursor/extension/src/utils/paths.ts @@ -36,7 +36,7 @@ export function hivemindGraphsHome(): string { } export function monorepoRoot(): string { - return join(__dirname, "..", "..", ".."); + return join(__dirname, "..", "..", "..", ".."); } export function hivemindCursorBundleSrc(): string { diff --git a/cursor-extension/src/webview/DashboardPanel.ts b/harnesses/cursor/extension/src/webview/DashboardPanel.ts similarity index 63% rename from cursor-extension/src/webview/DashboardPanel.ts rename to harnesses/cursor/extension/src/webview/DashboardPanel.ts index 5e17c7b4..7b7c34de 100644 --- a/cursor-extension/src/webview/DashboardPanel.ts +++ b/harnesses/cursor/extension/src/webview/DashboardPanel.ts @@ -1,9 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; import * as vscode from "vscode"; import { backfillCursorLinks, listLocalSkillsForPromoter, skillDirLabel, syncSkillsToCursor } from "../bridge/skill-sync"; -import { detectAuthState, formatIdentity, loadStoredCredentials } from "../auth"; +import { detectAuthState, formatIdentity } from "../auth"; import { runHealthCheck } from "../health/checker"; import { computeImpactOverlay } from "../graph/impact-overlay"; import { openNodeInEditor, startEditorToGraphSync, type EditorGraphSyncHandle } from "../graph/editor-sync"; @@ -11,7 +8,7 @@ import { loadGraphSnapshotFromEnvelope, parseGraphSnapshot } from "../graph/snap import type { GraphSnapshot } from "../graph/types"; import { logError } from "../utils/output"; import { getDashboardHtml } from "./html/dashboard-shell"; -import { loadDashboardData, loadRecentSessions, runHivemindCli } from "./data-bridge"; +import { loadDashboardData, loadRecentSessions, loadRulesList, loadSessionSummary, runHivemindCli, runHivemindCliAsync, invalidateOrgStatsCache } from "./data-bridge"; type DashboardPane = "kpi" | "settings" | "sessions" | "graph" | "rules" | "skills"; @@ -26,6 +23,8 @@ interface WebviewInboundMessage { nodeId?: string; dirName?: string; orgName?: string; + workspaceName?: string; + rulesStatus?: string; } interface ParsedRule { @@ -36,25 +35,21 @@ interface ParsedRule { text: string; } +async function triggerHealthPoll(): Promise { + try { + await vscode.commands.executeCommand("hivemind.pollHealthNow"); + } catch { + /* optional command */ + } +} + function workspaceRoot(): string { return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); } const SESSION_ID_RE = /^[a-zA-Z0-9_-]{1,128}$/; -function readSessionSummary(sessionId: string): string | null { - if (!SESSION_ID_RE.test(sessionId)) return null; - const creds = loadStoredCredentials(); - const user = creds?.userName ?? "unknown"; - if (user.includes("/") || user.includes("\\") || user.includes("..")) return null; - const path = join(homedir(), ".deeplake", "memory", "summaries", user, `${sessionId}.md`); - if (!existsSync(path)) return null; - try { - return readFileSync(path, "utf-8"); - } catch { - return null; - } -} +const PROMOTED_STEPS_KEY = "hivemind.promotedSteps"; function extractNextSteps(summary: string): string[] { const match = NEXT_STEPS_SECTION_RE.exec(summary); @@ -68,30 +63,23 @@ function extractNextSteps(summary: string): string[] { .filter((l) => l.length > 0 && !l.startsWith("#")); } -function parseRulesList(stdout: string): ParsedRule[] { - const rules: ParsedRule[] = []; - for (const line of stdout.split(/\r?\n/)) { - const m = line.match(/^\[(active|done)\]\s+(\S+)\s+v(\d+)\s+(\S+)\s+(.+)$/); - if (!m) continue; - rules.push({ - status: m[1]!, - id: m[2]!, - version: parseInt(m[3]!, 10), - author: m[4]!, - text: m[5]!, - }); - } - return rules; -} - class DashboardController { private editorSync: EditorGraphSyncHandle | undefined; private snapshot: GraphSnapshot | null = null; + private rulesStatusFilter = "active"; + private promotedSteps: Set; + private refreshInFlight = false; + private lastDashboardEnvelope: Awaited> | null = null; + private impactWatcher: vscode.FileSystemWatcher | undefined; + private impactDebounce: ReturnType | undefined; constructor( private readonly webview: vscode.Webview, private readonly disposables: vscode.Disposable[], + private readonly memento: vscode.Memento, ) { + const stored = memento.get(PROMOTED_STEPS_KEY, []); + this.promotedSteps = new Set(stored); webview.options = { enableScripts: true }; webview.html = getDashboardHtml(webview, vscode.Uri.file(__dirname)); @@ -102,8 +90,14 @@ class DashboardController { ); } + private persistPromotedSteps(): void { + void this.memento.update(PROMOTED_STEPS_KEY, [...this.promotedSteps]); + } + dispose(): void { this.editorSync?.dispose(); + if (this.impactDebounce) clearTimeout(this.impactDebounce); + this.impactWatcher?.dispose(); } async refreshAll(): Promise { @@ -123,19 +117,28 @@ class DashboardController { switch (msg.type) { case "ready": case "refresh": - await this.refreshAll(); + if (this.refreshInFlight) break; + this.refreshInFlight = true; + try { + await this.refreshAll(); + } finally { + this.refreshInFlight = false; + } break; case "setPane": if (msg.pane === "graph" && this.snapshot) this.ensureEditorSync(); break; case "openSession": - if (msg.sessionId) { - const text = readSessionSummary(msg.sessionId); - const nextSteps = text ? extractNextSteps(text) : []; + if (msg.sessionId && SESSION_ID_RE.test(msg.sessionId)) { + const summary = await loadSessionSummary(msg.sessionId, workspaceRoot()); + const nextSteps = summary.text ? extractNextSteps(summary.text) : []; this.post({ type: "sessionSummary", - text: text ?? `No summary file found for session ${msg.sessionId}.`, + text: summary.text ?? summary.message ?? `No summary found for session ${msg.sessionId}.`, + degradedHint: summary.degradedHint, + summarySource: summary.source, nextSteps, + promotedSteps: [...this.promotedSteps], }); } break; @@ -145,6 +148,7 @@ class DashboardController { const cmd = enabled ? ["embeddings", "disable"] : ["embeddings", "enable"]; const result = await runHivemindCli(cmd, workspaceRoot()); await this.pushSettings(); + await triggerHealthPoll(); this.post({ type: "actionResult", target: "embeddings", @@ -154,17 +158,35 @@ class DashboardController { break; } case "buildGraph": { - const result = await runHivemindCli(["graph", "build"], workspaceRoot()); + this.post({ + type: "actionResult", + target: "graphBuild", + ok: true, + message: "Graph build in progress…", + inProgress: true, + }); + const result = await runHivemindCliAsync(["graph", "build"], workspaceRoot()); this.post({ type: "actionResult", target: "graphBuild", ok: result.ok, message: result.ok ? "Graph build finished." : result.stderr || "Graph build failed.", + inProgress: false, }); if (result.ok) await this.pushDashboardData(); + await triggerHealthPoll(); break; } case "syncSkills": { + if (process.env.HIVEMIND_AUTOPULL_DISABLED === "1") { + this.post({ + type: "actionResult", + target: "skillSync", + ok: true, + message: "Skill sync skipped (HIVEMIND_AUTOPULL_DISABLED=1).", + }); + break; + } backfillCursorLinks(workspaceRoot()); const state = syncSkillsToCursor(workspaceRoot()); this.post({ @@ -178,6 +200,7 @@ class DashboardController { break; } case "rulesList": + if (msg.rulesStatus) this.rulesStatusFilter = msg.rulesStatus; await this.pushRules(); break; case "rulesAdd": @@ -215,7 +238,7 @@ class DashboardController { break; case "promoteSkill": if (msg.dirName) { - const skillName = msg.dirName.replace(/--[^/]+$/, ""); + const skillName = msg.dirName; const publishResult = await runHivemindCli( ["skillify", "promote", skillName, "--scope", "team"], workspaceRoot(), @@ -225,7 +248,7 @@ class DashboardController { target: "skillPromote", ok: publishResult.ok, message: publishResult.ok - ? `Skill "${skillName}" promoted to team. Teammates will pull it on their next session.` + ? publishResult.stdout.trim() || `Skill "${skillName}" promoted and published at team scope.` : publishResult.stderr || "Promotion failed.", }); await this.pushSkills(); @@ -233,7 +256,21 @@ class DashboardController { break; case "nextStepsPromote": if (msg.text) { + const key = msg.text.trim().toLowerCase(); + if (this.promotedSteps.has(key)) { + this.post({ + type: "actionResult", + target: "nextStepsGoal", + ok: true, + message: `Already promoted: "${msg.text}"`, + }); + break; + } const result = await runHivemindCli(["goal", "add", msg.text], workspaceRoot()); + if (result.ok) { + this.promotedSteps.add(key); + this.persistPromotedSteps(); + } this.post({ type: "actionResult", target: "nextStepsGoal", @@ -242,6 +279,23 @@ class DashboardController { }); } break; + case "switchWorkspace": + if (msg.workspaceName) { + const result = await runHivemindCli(["workspace", "switch", msg.workspaceName], workspaceRoot()); + this.post({ + type: "actionResult", + target: "workspaceSwitch", + ok: result.ok, + message: result.ok ? `Switched to workspace "${msg.workspaceName}".` : result.stderr || "Workspace switch failed.", + }); + if (result.ok) { + invalidateOrgStatsCache(); + await this.pushSettings(); + await this.pushDashboardData(); + await triggerHealthPoll(); + } + } + break; case "switchOrg": if (msg.orgName) { const result = await runHivemindCli(["org", "switch", msg.orgName], workspaceRoot()); @@ -251,7 +305,12 @@ class DashboardController { ok: result.ok, message: result.ok ? `Switched to org "${msg.orgName}".` : result.stderr || "Org switch failed.", }); - if (result.ok) await this.pushSettings(); + if (result.ok) { + invalidateOrgStatsCache(); + await this.pushSettings(); + await this.pushDashboardData(); + await triggerHealthPoll(); + } } break; case "computeImpact": @@ -286,15 +345,38 @@ class DashboardController { this.post({ type: "graphHighlight", nodeIds }); }); this.disposables.push({ dispose: () => this.editorSync?.dispose() }); + this.ensureImpactWatcher(); + } + + private ensureImpactWatcher(): void { + if (this.impactWatcher || !this.snapshot) return; + const root = workspaceRoot(); + const pattern = new vscode.RelativePattern(root, "**/*.{ts,tsx,js,jsx,mjs,cjs,py,pyi}"); + this.impactWatcher = vscode.workspace.createFileSystemWatcher(pattern); + const scheduleImpact = (): void => { + if (this.impactDebounce) clearTimeout(this.impactDebounce); + this.impactDebounce = setTimeout(() => { + if (!this.snapshot) return; + const impact = computeImpactOverlay(this.snapshot, root); + this.post({ type: "impact", impact }); + }, 600); + }; + this.impactWatcher.onDidChange(scheduleImpact); + this.impactWatcher.onDidCreate(scheduleImpact); + this.impactWatcher.onDidDelete(scheduleImpact); + this.disposables.push(this.impactWatcher); } private async pushDashboardData(): Promise { const data = await loadDashboardData(workspaceRoot()); - this.snapshot = loadGraphSnapshotFromEnvelope(data); - if (!this.snapshot && data.graph?.snapshot) { - this.snapshot = parseGraphSnapshot(data.graph.snapshot); - } - this.post({ type: "dashboardData", data }); + this.lastDashboardEnvelope = data; + const parsed = data.graph?.snapshot ? parseGraphSnapshot(data.graph.snapshot) : null; + this.snapshot = parsed ?? loadGraphSnapshotFromEnvelope(data); + this.post({ + type: "dashboardData", + data, + graphCorrupt: Boolean(data.graph?.snapshot && !parsed), + }); if (this.snapshot) this.ensureEditorSync(); } @@ -304,6 +386,16 @@ class DashboardController { const emb = await runHivemindCli(["embeddings", "status"], workspaceRoot()); const sync = syncSkillsToCursor(workspaceRoot()); const healthSummary = health.dimensions.map((d) => `${d.label}: ${d.status}`).join(" · "); + const graph = this.lastDashboardEnvelope?.graph; + let graphStatus = "Not built for this repo."; + if (graph && graph.nodeCount > 0) { + const age = graph.commitSha ? `commit ${graph.commitSha.slice(0, 8)}` : "snapshot on disk"; + graphStatus = `Built · ${graph.nodeCount} nodes · ${graph.edgeCount} edges · ${age}`; + } + const goals = await runHivemindCli(["goal", "list"], workspaceRoot()); + const openGoalsPreview = goals.ok + ? goals.stdout.split("\n").filter((l) => l.trim()).slice(0, 5).join("\n") || "(none)" + : "Unavailable (log in required)."; this.post({ type: "settings", authLabel: formatIdentity(auth), @@ -311,6 +403,8 @@ class DashboardController { embeddingsStatus: emb.ok ? emb.stdout.split("\n")[0] ?? "unknown" : "unavailable", skillSyncSummary: `Last sync: ${sync.syncedCount} ok, ${sync.erroredCount} failed`, graphBuildMessage: "", + graphStatus, + openGoalsPreview, }); } @@ -320,19 +414,38 @@ class DashboardController { } private async pushRules(): Promise { - const result = await runHivemindCli(["rules", "list", "--status", "active", "--limit", "25"], workspaceRoot()); - const rules = result.ok ? parseRulesList(result.stdout) : []; - this.post({ type: "rules", rules }); + const result = await loadRulesList(this.rulesStatusFilter || "active", 10); + if (result.loggedOut) { + this.post({ + type: "rules", + rules: [], + loggedOut: true, + message: result.message ?? "Log in with `hivemind login` to manage team rules.", + }); + return; + } + this.post({ type: "rules", rules: result.rules, loggedOut: false }); } private async pushSkills(): Promise { + const auth = await detectAuthState(); + if (auth.state !== "logged_in") { + this.post({ + type: "skills", + skills: [], + loggedOut: true, + message: "Log in with `hivemind login` to promote skills to your team.", + }); + return; + } const skills = listLocalSkillsForPromoter().map((s) => ({ dirName: s.dirName, label: skillDirLabel(s.dirName), scope: s.scope, + shareScope: s.shareScope, path: s.path, })); - this.post({ type: "skills", skills }); + this.post({ type: "skills", skills, loggedOut: false }); } } @@ -344,7 +457,7 @@ export class DashboardPanel { private readonly controller: DashboardController; private readonly disposables: vscode.Disposable[] = []; - public static createOrShow(extensionUri: vscode.Uri, _context: vscode.ExtensionContext): void { + public static createOrShow(extensionUri: vscode.Uri, context: vscode.ExtensionContext): void { const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; if (DashboardPanel.currentPanel) { @@ -360,12 +473,12 @@ export class DashboardPanel { { enableScripts: true, retainContextWhenHidden: true }, ); - DashboardPanel.currentPanel = new DashboardPanel(panel, extensionUri); + DashboardPanel.currentPanel = new DashboardPanel(panel, extensionUri, context); } - private constructor(panel: vscode.WebviewPanel, _extensionUri: vscode.Uri) { + private constructor(panel: vscode.WebviewPanel, _extensionUri: vscode.Uri, context: vscode.ExtensionContext) { this.panel = panel; - this.controller = new DashboardController(panel.webview, this.disposables); + this.controller = new DashboardController(panel.webview, this.disposables, context.globalState); this.panel.onDidDispose(() => this.dispose(), null, this.disposables); this.panel.onDidChangeViewState( @@ -402,7 +515,7 @@ class DashboardViewProvider implements vscode.WebviewViewProvider { ): void { const disposables: vscode.Disposable[] = []; this.context.subscriptions.push(...disposables); - this.controller = new DashboardController(webviewView.webview, disposables); + this.controller = new DashboardController(webviewView.webview, disposables, this.context.globalState); webviewView.onDidChangeVisibility(() => { if (webviewView.visible) void this.controller?.refreshAll(); }); diff --git a/harnesses/cursor/extension/src/webview/data-bridge.ts b/harnesses/cursor/extension/src/webview/data-bridge.ts new file mode 100644 index 00000000..971bcc6a --- /dev/null +++ b/harnesses/cursor/extension/src/webview/data-bridge.ts @@ -0,0 +1,394 @@ +import { spawn, execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { credentialsPath, hivemindGraphsHome } from "../utils/paths"; +import { readJson } from "../utils/fs-json"; + +export interface SessionSummaryResult { + text: string | null; + source: "remote" | "local" | "missing" | "unreachable" | "invalid"; + message: string | null; + degradedHint: string | null; +} + +export interface DashboardKpis { + tokensSaved: number | null; + tokensSource: "org" | "local" | "none"; + skillsCreated: number; + memorySearches: number; + sessionsCount: number | null; + userTokensSaved: number | null; + orgStatsFetchedAt?: string | null; + orgStatsStale?: boolean; + orgStatsOffline?: boolean; +} + +export interface DashboardGraphSummary { + commitSha: string | null; + snapshotPath: string; + nodeCount: number; + edgeCount: number; + snapshot: unknown; +} + +export interface DashboardDataEnvelope { + repoKey: string; + repoProject: string; + generatedAt: string; + kpis: DashboardKpis; + graph: DashboardGraphSummary | null; +} + +export interface RecentSession { + sessionId: string; + endedAt: string; + memorySearchCount: number; + project?: string | null; + hadRecall?: boolean; +} + +function repoRootFromExtension(): string { + return join(__dirname, "..", "..", "..", ".."); +} + +function loadDashboardScriptPath(): string { + return join(__dirname, "..", "..", "scripts", "load-dashboard.mjs"); +} + +function statsFilePath(): string { + return join(homedir(), ".deeplake", "usage-stats.jsonl"); +} + +function readUsageRecords(): Array<{ endedAt: string; sessionId: string; memorySearchBytes: number; memorySearchCount: number }> { + try { + if (!existsSync(statsFilePath())) return []; + const out: Array<{ endedAt: string; sessionId: string; memorySearchBytes: number; memorySearchCount: number }> = []; + for (const line of readFileSync(statsFilePath(), "utf-8").split("\n")) { + if (!line.trim()) continue; + try { + const rec = JSON.parse(line) as Partial<{ endedAt: string; sessionId: string; memorySearchBytes: number; memorySearchCount: number }>; + if (rec.endedAt && rec.sessionId) { + out.push({ + endedAt: rec.endedAt, + sessionId: rec.sessionId, + memorySearchBytes: rec.memorySearchBytes ?? 0, + memorySearchCount: rec.memorySearchCount ?? 0, + }); + } + } catch { + /* skip bad line */ + } + } + return out; + } catch { + return []; + } +} + +function deriveProjectKey(cwd: string): { key: string; project: string } { + try { + const out = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" }).trim(); + const key = Buffer.from(out).toString("hex").slice(0, 16); + return { key, project: basename(out) }; + } catch { + const key = Buffer.from(cwd).toString("hex").slice(0, 16); + return { key, project: basename(cwd) }; + } +} + +function resolveSnapshot(repoDir: string): DashboardGraphSummary | null { + const snapshotsDir = join(repoDir, "snapshots"); + if (!existsSync(snapshotsDir)) return null; + let snapshotPath: string | null = null; + const pointer = join(repoDir, "latest-commit.txt"); + if (existsSync(pointer)) { + const sha = readFileSync(pointer, "utf-8").trim(); + const candidate = join(snapshotsDir, `${sha}.json`); + if (sha && existsSync(candidate)) snapshotPath = candidate; + } + if (!snapshotPath) { + const candidates = readdirSync(snapshotsDir) + .filter((n) => n.endsWith(".json")) + .map((n) => ({ full: join(snapshotsDir, n), mtime: statSync(join(snapshotsDir, n)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + if (candidates[0]) snapshotPath = candidates[0].full; + } + if (!snapshotPath) return null; + try { + const parsed = JSON.parse(readFileSync(snapshotPath, "utf-8")) as { + nodes?: unknown[]; + links?: unknown[]; + graph?: { commit_sha?: string | null }; + }; + if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.links)) return null; + return { + commitSha: parsed.graph?.commit_sha ?? null, + snapshotPath, + nodeCount: parsed.nodes.length, + edgeCount: parsed.links.length, + snapshot: parsed, + }; + } catch { + return null; + } +} + +/** Local-only fallback when the canonical loader script is unavailable. */ +function loadDashboardDataFallback(cwd: string): DashboardDataEnvelope { + const { key: repoKey, project: repoProject } = deriveProjectKey(cwd); + const records = readUsageRecords(); + const localBytes = records.reduce((s, r) => s + r.memorySearchBytes, 0); + const localCount = records.reduce((s, r) => s + r.memorySearchCount, 0); + const BYTES_PER_TOKEN = 4; + const SAVINGS_MULTIPLIER = 1.7; + const saved = records.length > 0 ? (SAVINGS_MULTIPLIER - 1) * (localBytes / BYTES_PER_TOKEN) : null; + return { + repoKey, + repoProject, + generatedAt: new Date().toISOString(), + kpis: { + tokensSaved: saved, + tokensSource: records.length > 0 ? "local" : "none", + skillsCreated: 0, + memorySearches: localCount, + sessionsCount: records.length > 0 ? records.length : null, + userTokensSaved: saved, + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, + }, + graph: resolveSnapshot(join(hivemindGraphsHome(), repoKey)), + }; +} + +/** Load dashboard envelope via the canonical CLI data layer (`src/dashboard/data.ts`). */ +export async function loadDashboardData(cwd: string): Promise { + const scriptPath = loadDashboardScriptPath(); + if (!existsSync(scriptPath)) { + return loadDashboardDataFallback(cwd); + } + + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath, cwd], { + cwd: repoRootFromExtension(), + env: { ...process.env, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("close", (code) => { + if (code !== 0 || !stdout.trim()) { + resolve(loadDashboardDataFallback(cwd)); + return; + } + try { + const parsed = JSON.parse(stdout) as DashboardDataEnvelope; + resolve(parsed); + } catch { + resolve(loadDashboardDataFallback(cwd)); + } + }); + child.on("error", () => resolve(loadDashboardDataFallback(cwd))); + }); +} + +export function invalidateOrgStatsCache(): void { + const cachePath = join(homedir(), ".deeplake", "hivemind-stats-cache.json"); + try { + if (existsSync(cachePath)) unlinkSync(cachePath); + } catch { + /* best-effort */ + } +} + +export async function loadRecentSessions(_cwd: string): Promise { + const scriptPath = join(__dirname, "..", "..", "scripts", "load-sessions.mjs"); + if (existsSync(scriptPath)) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath], { + cwd: repoRootFromExtension(), + env: { ...process.env, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.on("close", () => { + try { + resolve(JSON.parse(stdout) as RecentSession[]); + } catch { + resolve(loadRecentSessionsFallback(_cwd)); + } + }); + child.on("error", () => resolve(loadRecentSessionsFallback(_cwd))); + }); + } + return loadRecentSessionsFallback(_cwd); +} + +function loadRecentSessionsFallback(_cwd: string): RecentSession[] { + return readUsageRecords() + .slice(-20) + .reverse() + .map((r) => ({ + sessionId: r.sessionId, + endedAt: r.endedAt, + memorySearchCount: r.memorySearchCount, + project: basename(_cwd), + hadRecall: r.memorySearchCount > 0, + })); +} + +export interface RulesListResult { + loggedOut: boolean; + rules: Array<{ id: string; status: string; version: number; author: string; text: string }>; + message?: string; +} + +export async function loadRulesList(status: string, limit = 10): Promise { + const scriptPath = join(__dirname, "..", "..", "scripts", "load-rules.mjs"); + if (!existsSync(scriptPath)) { + return { loggedOut: true, rules: [], message: "Rules loader unavailable." }; + } + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath, status, String(limit)], { + cwd: repoRootFromExtension(), + env: { ...process.env, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.on("close", () => { + try { + resolve(JSON.parse(stdout) as RulesListResult); + } catch { + resolve({ loggedOut: true, rules: [], message: "Failed to parse rules." }); + } + }); + child.on("error", () => resolve({ loggedOut: true, rules: [], message: "Failed to load rules." })); + }); +} + +/** Load session summary from remote memory table with local disk fallback. */ +export async function loadSessionSummary(sessionId: string, cwd: string): Promise { + const creds = readJson<{ userName?: string }>(credentialsPath()); + const userName = creds?.userName ?? ""; + const scriptPath = join(__dirname, "..", "..", "scripts", "load-session-summary.mjs"); + if (!existsSync(scriptPath)) { + const path = join(homedir(), ".deeplake", "memory", "summaries", userName, `${sessionId}.md`); + if (userName && existsSync(path)) { + try { + return { + text: readFileSync(path, "utf-8"), + source: "local", + message: null, + degradedHint: null, + }; + } catch { + /* fall through */ + } + } + return { + text: null, + source: "missing", + message: `No summary file found for session ${sessionId}.`, + degradedHint: + "If cursor-agent is missing or logged out, summaries fail silently until PRD-002 health checks pass.", + }; + } + + return new Promise((resolve) => { + const child = spawn(process.execPath, [scriptPath, sessionId, userName], { + cwd: repoRootFromExtension(), + env: { ...process.env, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.on("close", () => { + try { + const parsed = JSON.parse(stdout) as { + text?: string | null; + source?: SessionSummaryResult["source"]; + message?: string | null; + }; + const degradedHint = + parsed.source === "unreachable" + ? parsed.message ?? "Memory table unreachable." + : parsed.source === "missing" + ? "If cursor-agent is missing or logged out, summaries fail silently until PRD-002 health checks pass." + : null; + resolve({ + text: parsed.text ?? null, + source: parsed.source ?? "missing", + message: parsed.message ?? null, + degradedHint, + }); + } catch { + resolve({ + text: null, + source: "missing", + message: `No summary found for session ${sessionId}.`, + degradedHint: null, + }); + } + }); + child.on("error", () => + resolve({ + text: null, + source: "unreachable", + message: "Could not load session summary.", + degradedHint: "Summary loader failed to start.", + }), + ); + }); +} + +export async function runHivemindCli(args: string[], cwd: string): Promise<{ ok: boolean; stdout: string; stderr: string }> { + return runHivemindCliAsync(args, cwd); +} + +export function runHivemindCliAsync( + args: string[], + cwd: string, + timeoutMs = 300_000, +): Promise<{ ok: boolean; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn("hivemind", args, { + cwd, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, timeoutMs); + child.stdout.on("data", (c: Buffer) => { + stdout += c.toString(); + }); + child.stderr.on("data", (c: Buffer) => { + stderr += c.toString(); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ ok: code === 0, stdout, stderr }); + }); + child.on("error", (err) => { + clearTimeout(timer); + resolve({ ok: false, stdout, stderr: err.message }); + }); + }); +} diff --git a/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts b/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts new file mode 100644 index 00000000..fdd7ee97 --- /dev/null +++ b/harnesses/cursor/extension/src/webview/html/dashboard-shell.ts @@ -0,0 +1,774 @@ +import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +const D3_CDN = "https://d3js.org/d3.v7.min.js"; + +function getNonce(): string { + return randomBytes(16).toString("hex"); +} + +function cspSource(webview: vscode.Webview): string { + return webview.cspSource; +} + +/** Self-contained dashboard HTML for panel or sidebar webview. */ +export function getDashboardHtml(webview: vscode.Webview, extensionUri: vscode.Uri): string { + const nonce = getNonce(); + const csp = cspSource(webview); + + return ` + + + + + + Hivemind Dashboard + + + +
      +

      Hivemind

      + +
      + +
      +
      +
      +

      +
      +
      +

      Org & health

      +

      +

      +
      + + +
      +

      +
      + + +
      +

      +

      Embeddings

      +
      + Loading… + +
      +

      Codebase graph

      +

      Checking graph…

      +
      + + +
      +

      +

      Open goals

      +
      Loading…
      +

      Skill sync

      + +

      +
      +
      +
        + +
        +
        +
        + + + + +
        +

        Nodes: radius=fan-in · border=exported · diamond=entrypoint · shape by kind

        +
        + + +
        + + + + + + +

        +
        +
        +
        + + + +
        + +
          +
          +
          +

          Local skills available under Claude/Cursor skill directories. Use "Promote to team" to share a skill so teammates pull it on their next session.

          +
          +

          +
          +
          + + + +`; +} diff --git a/cursor-extension/tsconfig.json b/harnesses/cursor/extension/tsconfig.json similarity index 92% rename from cursor-extension/tsconfig.json rename to harnesses/cursor/extension/tsconfig.json index b02896bb..6e5e3a50 100644 --- a/cursor-extension/tsconfig.json +++ b/harnesses/cursor/extension/tsconfig.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "moduleResolution": "node", "paths": { - "@hivemind/*": ["../src/*"] + "@hivemind/*": ["../../../src/*"] }, "baseUrl": "." }, diff --git a/cursor-extension/webpack.config.js b/harnesses/cursor/extension/webpack.config.js similarity index 90% rename from cursor-extension/webpack.config.js rename to harnesses/cursor/extension/webpack.config.js index 6dd4c3d1..f89a6c5c 100644 --- a/cursor-extension/webpack.config.js +++ b/harnesses/cursor/extension/webpack.config.js @@ -21,7 +21,7 @@ module.exports = { resolve: { extensions: [".ts", ".js"], alias: { - "@hivemind": path.resolve(__dirname, "../src"), + "@hivemind": path.resolve(__dirname, "../../../..", "src"), }, }, module: { diff --git a/library/knowledge/private/architecture/system-overview.md b/library/knowledge/private/architecture/system-overview.md index 0173e214..64a53c59 100644 --- a/library/knowledge/private/architecture/system-overview.md +++ b/library/knowledge/private/architecture/system-overview.md @@ -48,7 +48,7 @@ hivemind/ └── bundle/ ← unified `hivemind` CLI build output ``` -The Claude Code hooks under `src/hooks/` are the reference implementation. The per-agent subdirectories (`src/hooks/codex/`, `cursor/`, `hermes/`, `pi/`) re-express the same handlers against each assistant's event names and payload shapes, reusing the shared core for the actual work. The build step (`npm run build`) runs `tsc` plus `esbuild` and emits the per-agent bundles into `harnesses/claude-code/bundle/`, `harnesses/codex/bundle/`, `cursor/bundle/`, `harnesses/openclaw/dist/`, `mcp/bundle/`, and `bundle/cli.js`. +The Claude Code hooks under `src/hooks/` are the reference implementation. The per-agent subdirectories (`src/hooks/codex/`, `cursor/`, `hermes/`, `pi/`) re-express the same handlers against each assistant's event names and payload shapes, reusing the shared core for the actual work. The build step (`npm run build`) runs `tsc` plus `esbuild` and emits the per-agent bundles into `harnesses/claude-code/bundle/`, `harnesses/codex/bundle/`, `harnesses/cursor/bundle/`, `harnesses/openclaw/dist/`, `mcp/bundle/`, and `bundle/cli.js`. --- diff --git a/library/knowledge/private/frontend/cursor-extension-architecture.md b/library/knowledge/private/frontend/cursor-extension-architecture.md index cef4f55b..ea0fc0d8 100644 --- a/library/knowledge/private/frontend/cursor-extension-architecture.md +++ b/library/knowledge/private/frontend/cursor-extension-architecture.md @@ -18,7 +18,7 @@ How Hivemind wires into Cursor 1.7+ via hooks.json, what each hook does, and how Cursor 1.7 introduced a `hooks.json` mechanism that fires TypeScript/Node scripts at named lifecycle events. Hivemind uses this surface as its Cursor integration shim: the same capture, recall, wiki-summary, and skillify mechanics that power the Claude Code plugin are re-expressed here, normalising Cursor's event payload shapes into the shared `HookInput` format consumed by `src/` core. -The Cursor integration is the fourth integration in the fleet (after Claude Code, Codex, and OpenClaw). Its hooks live at `src/hooks/cursor/` and are built by `npm run build` into `cursor/bundle/`. +The Cursor integration is the fourth integration in the fleet (after Claude Code, Codex, and OpenClaw). Its hooks live at `src/hooks/cursor/` and are built by `npm run build` into `harnesses/cursor/bundle/`. --- @@ -168,4 +168,19 @@ sequenceDiagram | `src/hooks/cursor/pre-tool-use.ts` | Recall intercept for Shell tool | | `src/hooks/cursor/spawn-wiki-worker.ts` | Wiki worker spawner for Cursor | | `src/hooks/cursor/wiki-worker.ts` | Cursor wiki worker (calls `cursor-agent --print`) | -| `cursor/bundle/` | Compiled build output distributed via npm | +| `harnesses/cursor/bundle/` | Compiled hook scripts (npm → `~/.cursor/hivemind/bundle/`) | +| `harnesses/cursor/extension/` | VS Code / Cursor extension source (status bar, dashboard, hook wiring UI) | + +--- + +## Editor extension (`harnesses/cursor/extension/`) + +The hooks integration above is sufficient for capture, recall, skillify, and graph builds. The optional **Hivemind for Cursor** extension adds operator UX on top: + +- Status bar health for CLI, `cursor-agent`, login, and hook wiring +- **Wire / Refresh Hooks** copies `harnesses/cursor/bundle/` into `~/.cursor/hivemind/bundle/` and idempotently merges `~/.cursor/hooks.json` +- Browser or API-key login without a terminal +- Dashboard webview: KPIs, settings, sessions, graph canvas, rules list, skill sync state +- On activation, syncs symlinks into `~/.cursor/skills-cursor/` and `/.cursor/skills/` + +User-facing install and command reference: [harnesses/cursor/extension/README.md](../../../../harnesses/cursor/extension/README.md). Product requirements: `library/requirements/backlog/prd-002-cursor-extension-core/` through `prd-005-cursor-skillify-bridge/`. diff --git a/library/knowledge/private/infrastructure/monorepo-build-release.md b/library/knowledge/private/infrastructure/monorepo-build-release.md index 3939930e..bef68d07 100644 --- a/library/knowledge/private/infrastructure/monorepo-build-release.md +++ b/library/knowledge/private/infrastructure/monorepo-build-release.md @@ -111,7 +111,7 @@ Esbuild generates separate distribution bundles under the following directories: * **Claude Code:** Generated under `harnesses/claude-code/bundle/`. It packs hooks like `session-start`, `session-end`, `pre-tool-use`, `capture`, and several specialized background workers. * **Codex:** Generated under `harnesses/codex/bundle/`. It includes the Codex-specific lifecycle shims and background tasks. -* **Cursor:** Generated under `cursor/bundle/`. It packages the `session-start`, `capture`, `pre-tool-use`, `session-end`, and `graph-on-stop` hooks. +* **Cursor:** Generated under `harnesses/cursor/bundle/`. It packages the `session-start`, `capture`, `pre-tool-use`, `session-end`, and `graph-on-stop` hooks. * **Hermes Agent:** Generated under `harnesses/hermes/bundle/`. It bundles hooks following the NousResearch/hermes-agent shell hook protocol. * **pi:** Generated under `harnesses/pi/bundle/`. It bundles background workers like the `wiki-worker` and `skillify-worker`. (The main pi extension runs raw TypeScript compiled by pi's runtime.) * **OpenClaw:** Generated under `harnesses/openclaw/dist/`. It outputs the compiled HTTP/WebSocket plugin gateway, along with its async `skillify-worker`. diff --git a/library/knowledge/private/plugins/mcp-and-extension-surfaces.md b/library/knowledge/private/plugins/mcp-and-extension-surfaces.md index 7a09e976..d13d2e00 100644 --- a/library/knowledge/private/plugins/mcp-and-extension-surfaces.md +++ b/library/knowledge/private/plugins/mcp-and-extension-surfaces.md @@ -147,7 +147,7 @@ The plugin manifest specifies the hook entry points and declares the Claude Code ### Cursor hooks.json -The Cursor integration does not ship a traditional plugin; it writes a `~/.cursor/hooks.json` file pointing at the bundled hook scripts. The build output lands in `cursor/bundle/`. The `hivemind install` CLI (via `src/cli/install-cursor.ts`) writes the hooks.json entry, creates the bundle directory, and registers `sessionStart`, `beforeSubmitPrompt`, `postToolUse`, `afterAgentResponse`, `stop`, and `sessionEnd` hooks. +The Cursor integration does not ship a traditional plugin; it writes a `~/.cursor/hooks.json` file pointing at the bundled hook scripts. The build output lands in `harnesses/cursor/bundle/`. The `hivemind install` CLI (via `src/cli/install-cursor.ts`) writes the hooks.json entry, creates the bundle directory, and registers `sessionStart`, `beforeSubmitPrompt`, `postToolUse`, `afterAgentResponse`, `stop`, and `sessionEnd` hooks. Cursor 1.7+ introduced a hooks mechanism that is semantically similar to Claude Code's but uses lowercase event names and a different JSON output schema (`additional_context` vs `additionalContext`). The Cursor shim in `src/hooks/cursor/` normalizes these differences. @@ -160,7 +160,7 @@ flowchart LR subgraph bundles["Build outputs"] ccBundle["harnesses/claude-code/bundle/\n→ Claude Code Marketplace"] codexBundle["harnesses/codex/bundle/\n→ npm @deeplake/hivemind"] - cursorBundle["cursor/bundle/\n→ npm + hooks.json"] + cursorBundle["harnesses/cursor/bundle/\n→ npm + hooks.json"] hermesBundle["harnesses/hermes/bundle/\n→ npm"] mcpBundle["mcp/bundle/\n→ npm (Hermes uses this)"] openclawDist["harnesses/openclaw/dist/\n→ ClawHub"] diff --git a/library/qa/cursor-extension/2026-06-13-security-audit.md b/library/qa/cursor-extension/2026-06-13-security-audit.md new file mode 100644 index 00000000..fa75a527 --- /dev/null +++ b/library/qa/cursor-extension/2026-06-13-security-audit.md @@ -0,0 +1,30 @@ +# Security audit: Cursor extension + touched CLI paths + +> **Date:** 2026-06-13 +> **Auditor:** security-guardian (inline pass) +> **Scope:** `harnesses/cursor/extension/src/**`, `src/commands/skillify.ts`, `src/dashboard/data.ts`, `src/notifications/sources/org-stats.ts`, `src/skillify/pull.ts`, loader scripts under `harnesses/cursor/extension/scripts/` + +## Summary + +No **Medium or higher** findings remain in scope after remediation review. + +## Checks performed + +| Area | Result | +|---|---| +| Credential storage / logging | Tokens are not logged; auth uses masked input and existing CLI credential paths (`auth/api-key.ts`, `safe-url.ts`). | +| Session summary loader | Session IDs validated with `^[a-zA-Z0-9_-]{1,128}$`; SQL literals use `sqlStr`; user paths reject `..` and slashes. | +| Webview XSS | User/session/rule/skill text routed through `esc()` before `innerHTML`; markdown summary lines escaped. | +| CLI promote `--scope team` | Uses parameterized `insertSkillRow`; requires credentials via existing skillify config load. | +| Org stats cache | Read-only HTTP with bearer token; stale/offline flags surfaced without exposing token values. | +| Command execution | Dashboard invokes fixed `hivemind` argv arrays; no user-controlled shell interpolation. | +| Symlink fan-out | Conflict paths reported; does not overwrite real files (lstat + skip). | + +## Low / informational + +- **L1:** Webview still uses `innerHTML` with escaped content; acceptable for VS Code webviews with CSP nonce on script tags. Continue preferring `textContent` for new surfaces. +- **L2:** `load-session-summary.mjs` queries remote memory with 4s timeout; failures degrade to local file without throwing. + +## Verdict + +**PASS** for merge at Medium+ threshold. diff --git a/library/requirements/backlog/prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md b/library/requirements/backlog/prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md index 5566e6fd..a9528981 100644 --- a/library/requirements/backlog/prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md +++ b/library/requirements/backlog/prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md @@ -9,7 +9,7 @@ ## Overview -Today Hivemind reaches Cursor through a hooks-only integration: a developer runs `hivemind cursor install` in a terminal, the CLI merges six lifecycle entries into `~/.cursor/hooks.json`, and a Node bundle is copied to `~/.cursor/hivemind/bundle/` (see `src/cli/install-cursor.ts:44-124`). There is **no first-party Cursor/VS Code extension** today: no status bar, no command palette, no in-editor surface that tells a developer whether Hivemind is actually working. When something goes wrong, it goes wrong invisibly. The clearest example: the session-end wiki worker shells out to `cursor-agent --print`, and when that binary is missing from `PATH` or not logged in, the error is caught, written only to a log file, and swallowed, so every session summary silently becomes an empty placeholder (`src/hooks/cursor/wiki-worker.ts:186-188`). +Today Hivemind reaches Cursor through a hooks-only integration: a developer runs `hivemind cursor install` in a terminal, the CLI merges seven lifecycle entries into `~/.cursor/hooks.json`, and a Node bundle is copied to `~/.cursor/hivemind/bundle/` (see `src/cli/install-cursor.ts:44-124`). There is **no first-party Cursor/VS Code extension** today: no status bar, no command palette, no in-editor surface that tells a developer whether Hivemind is actually working. When something goes wrong, it goes wrong invisibly. The clearest example: the session-end wiki worker shells out to `cursor-agent --print`, and when that binary is missing from `PATH` or not logged in, the error is caught, written only to a log file, and swallowed, so every session summary silently becomes an empty placeholder (`src/hooks/cursor/wiki-worker.ts:186-188`). PRD-002 delivers the **Cursor Extension Core**: the first-party Cursor extension that becomes Hivemind's home inside the editor. Its job in this stage is not features-for-features'-sake. It is to make the developer's very first five minutes with Hivemind feel effortless and trustworthy, and to make the system's health continuously **visible** so failures can never again be silent. A developer installs the extension from the marketplace, and without opening a terminal or reading a README, the extension verifies prerequisites, gets them authenticated, wires the hooks for them, and shows a single honest status indicator that answers the only question that matters: "Is Hivemind capturing my work right now, yes or no?" diff --git a/library/requirements/backlog/prd-002-cursor-extension-core/prd-002a-health-check.md b/library/requirements/backlog/prd-002-cursor-extension-core/prd-002a-health-check.md index 6fce9a98..30d1bf56 100644 --- a/library/requirements/backlog/prd-002-cursor-extension-core/prd-002a-health-check.md +++ b/library/requirements/backlog/prd-002-cursor-extension-core/prd-002a-health-check.md @@ -10,9 +10,9 @@ ## Overview -This sub-feature is the extension's "is everything in place?" engine. It answers, continuously and on demand, whether the local environment can actually run Hivemind in Cursor, and it removes the single biggest source of setup friction: hand-editing `~/.cursor/hooks.json`. Concretely it does two jobs. First, the **health check** detects whether the `hivemind` and `cursor-agent` CLIs are installed and resolvable on `PATH`, whether their versions are sane, and whether `cursor-agent` is logged in. Second, the **auto-wiring** step writes the six Hivemind lifecycle hooks into `~/.cursor/hooks.json` for the developer so they never have to run a terminal command or paste JSON. +This sub-feature is the extension's "is everything in place?" engine. It answers, continuously and on demand, whether the local environment can actually run Hivemind in Cursor, and it removes the single biggest source of setup friction: hand-editing `~/.cursor/hooks.json`. Concretely it does two jobs. First, the **health check** detects whether the `hivemind` and `cursor-agent` CLIs are installed and resolvable on `PATH`, whether their versions are sane, and whether `cursor-agent` is logged in. Second, the **auto-wiring** step writes the seven Hivemind lifecycle hooks into `~/.cursor/hooks.json` for the developer so they never have to run a terminal command or paste JSON. -The value here is twofold. Friction goes to near-zero: the developer does not need to know that hooks exist, where the file lives, or what six events to wire. And a whole class of silent failure becomes loud: the health check exists precisely so that the missing-`cursor-agent` and logged-out-`cursor-agent` conditions, the conditions that today corrupt session summaries invisibly (`src/hooks/cursor/wiki-worker.ts:186-188`), are caught and shown before they cause harm. +The value here is twofold. Friction goes to near-zero: the developer does not need to know that hooks exist, where the file lives, or what seven events to wire. And a whole class of silent failure becomes loud: the health check exists precisely so that the missing-`cursor-agent` and logged-out-`cursor-agent` conditions, the conditions that today corrupt session summaries invisibly (`src/hooks/cursor/wiki-worker.ts:186-188`), are caught and shown before they cause harm. --- @@ -38,7 +38,7 @@ The worker then finds no summary file and moves on (`src/hooks/cursor/wiki-worke - Detect, without developer action, whether `hivemind` is installed and resolvable, and report a clear present/absent state. - Detect whether `cursor-agent` is installed and resolvable, and whether it is logged in, and report each independently. -- Auto-wire all six Hivemind lifecycle hooks into `~/.cursor/hooks.json` on the developer's behalf, idempotently, preserving any non-Hivemind hooks already present. +- Auto-wire all seven Hivemind lifecycle hooks into `~/.cursor/hooks.json` on the developer's behalf, idempotently, preserving any non-Hivemind hooks already present. - Detect when wired hooks are stale (a newer extension/bundle exists) and offer a one-click refresh. - Turn every failed check into a specific, actionable remediation, never a generic error. - Re-run safely any number of times and always converge to the same healthy wiring. @@ -48,7 +48,7 @@ The worker then finds no summary file and moves on (`src/hooks/cursor/wiki-worke - **Installing the `hivemind` CLI automatically.** This sub-feature detects and guides; whether the extension may run a global `npm i` is an open question owned by the index PRD. Default posture: detect-and-guide. - **Authenticating Hivemind itself.** Hivemind login state and the login flow belong to [`prd-002b-auth-secrets`](./prd-002b-auth-secrets.md). This sub-feature only *reads* login state to compose overall health. (`cursor-agent` login is a prerequisite check here; *Hivemind* login is 002b.) - **Rendering the status indicator.** Presentation belongs to [`prd-002c-status-bar`](./prd-002c-status-bar.md). This sub-feature produces a structured health result; 002c displays it. -- **Changing the runtime hook bundle behaviour.** The six hooks and their handlers are defined by the existing integration (`src/cli/install-cursor.ts:44-60`); this sub-feature wires them, it does not redesign them. +- **Changing the runtime hook bundle behaviour.** The seven hooks and their handlers are defined by the existing integration (`src/cli/install-cursor.ts:44-60`); this sub-feature wires them, it does not redesign them. --- @@ -61,7 +61,7 @@ The health check produces a structured result over four independent dimensions. | **D1: `hivemind` CLI** | Is the `hivemind` CLI installed and resolvable? | PATH resolution (the same `which`/`where` strategy as `src/utils/resolve-cli-bin.ts:29-51`), then a version probe. | "Hivemind CLI not found. Install it to enable shared memory," with a copyable command and docs link. | | **D2: `cursor-agent` CLI** | Is `cursor-agent` installed and resolvable? | PATH resolution; if absent, check the known install locations (mirrors `src/skillify/gate-runner.ts` cursor paths). | "`cursor-agent` not found. Session summaries cannot be generated until it is installed." | | **D3: `cursor-agent` login** | Is `cursor-agent` logged in? | A lightweight non-mutating status probe of `cursor-agent`. | "`cursor-agent` is installed but logged out. Summaries will silently fail. Log in to fix." | -| **D4: Hooks wired & current** | Are the six Hivemind hooks present in `~/.cursor/hooks.json` and matching the current bundle version? | Read `~/.cursor/hooks.json`, match Hivemind entries via the existing `isHivemindEntry` shape (`src/cli/install-cursor.ts:62-71`), compare against the version stamp. | "Hooks not wired" or "Hooks out of date" with a one-click "Wire / Refresh" action. | +| **D4: Hooks wired & current** | Are the seven Hivemind hooks present in `~/.cursor/hooks.json` and matching the current bundle version? | Read `~/.cursor/hooks.json`, match Hivemind entries via the existing `isHivemindEntry` shape (`src/cli/install-cursor.ts:62-71`), compare against the version stamp. | "Hooks not wired" or "Hooks out of date" with a one-click "Wire / Refresh" action. | > D3 is the dimension that directly closes the silent-failure gap. It is checked proactively, not lazily at summary time. @@ -83,7 +83,7 @@ sequenceDiagram alt Any of D1-D3 failing Ext-->>Dev: Show specific remediation per failed dimension else D1-D3 healthy, D4 missing or stale - Ext->>FS: Merge six hooks idempotently (preserve foreign hooks) + Ext->>FS: Merge seven hooks idempotently (preserve foreign hooks) FS-->>Ext: Written (or unchanged) Ext-->>Dev: "Wired. Reload Cursor to activate." else All healthy @@ -97,7 +97,7 @@ sequenceDiagram The wiring step is the friction-killer. Its correctness requirements: -1. **Wire all six events** exactly as the canonical integration defines them: `sessionStart`, `beforeSubmitPrompt`, `preToolUse` (with the `Shell` matcher), `postToolUse`, `afterAgentResponse`, `stop`, and `sessionEnd` (`src/cli/install-cursor.ts:44-60`). The extension must not invent its own event list; it reuses the canonical config so the editor path and CLI path can never drift. +1. **Wire all seven events** exactly as the canonical integration defines them: `sessionStart`, `beforeSubmitPrompt`, `preToolUse` (with the `Shell` matcher), `postToolUse`, `afterAgentResponse`, `stop`, and `sessionEnd` (`src/cli/install-cursor.ts:44-60`). The extension must not invent its own event list; it reuses the canonical config so the editor path and CLI path can never drift. 2. **Preserve foreign hooks.** Any hook entry that is not a Hivemind entry must survive untouched. Reuse the merge semantics that filter on `isHivemindEntry` and re-append (`src/cli/install-cursor.ts:73-85`). 3. **Idempotent writes.** Re-wiring when nothing changed must not rewrite the file (preserves Cursor's hook-trust fingerprint, matching `writeJsonIfChanged` usage at `src/cli/install-cursor.ts:114`). 4. **Bundle presence is a precondition.** Wiring assumes the bundle exists at `~/.cursor/hivemind/bundle/`. If it is absent, wiring must report the missing bundle rather than writing dangling `node "...bundle/..."` commands. @@ -128,7 +128,7 @@ Every failed check resolves to a concrete action. This table is the contract: a | AC-1 | Given `hivemind` is not on `PATH`, when the health check runs, then D1 reports "missing" and a copyable install command plus docs link is offered. | | AC-2 | Given `cursor-agent` is not on `PATH`, when the health check runs, then D2 reports "missing" and the result explicitly notes that session summaries are disabled. | | AC-3 | Given `cursor-agent` is installed but logged out, when the health check runs, then D3 reports "logged out" and warns that summaries will fail before any summary is attempted. | -| AC-4 | Given a `hooks.json` with no Hivemind entries, when auto-wiring runs, then all six lifecycle events are added (including the `Shell` matcher on `preToolUse`) and any pre-existing foreign hooks are preserved unchanged. | +| AC-4 | Given a `hooks.json` with no Hivemind entries, when auto-wiring runs, then all seven lifecycle events are added (including the `Shell` matcher on `preToolUse`) and any pre-existing foreign hooks are preserved unchanged. | | AC-5 | Given auto-wiring has already run, when it runs again with no version change, then `~/.cursor/hooks.json` is not rewritten (idempotent, fingerprint-preserving). | | AC-6 | Given the wired bundle version is older than the current extension bundle, when the health check runs, then D4 reports "stale" and offers a one-click refresh. | | AC-7 | Given the bundle is absent at `~/.cursor/hivemind/bundle/`, when auto-wiring is attempted, then it refuses to write dangling commands and reports the missing bundle. | diff --git a/library/requirements/backlog/prd-002-cursor-extension-core/qa/2026-06-13-qa-report.md b/library/requirements/backlog/prd-002-cursor-extension-core/qa/2026-06-13-qa-report.md new file mode 100644 index 00000000..23aec49e --- /dev/null +++ b/library/requirements/backlog/prd-002-cursor-extension-core/qa/2026-06-13-qa-report.md @@ -0,0 +1,259 @@ +# QA Report: PRD-002 Cursor Extension Core & Onboarding + +> **Date:** 2026-06-13 +> **Auditor:** quality-guardian +> **Branch / worktree:** `feature/cursor-extension-dev` (`/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`) +> **Diff base:** `main` (`merge-base` 381299a) +> **Plan audited:** `prd-002-cursor-extension-core` (index + 002a, 002b, 002c) +> **Implementation:** `harnesses/cursor/extension/src/**` (+ reference parity `src/cli/install-cursor.ts`) +> **Verdict:** **COMPLETE** (remediated 2026-06-13; see Remediation section) + +--- + +## Executive summary + +The PRD-002 implementation is substantial and largely faithful to the plan. The health engine (002a), auth/secrets surface (002b), and status bar + command palette (002c) are all present, structured cleanly across the prescribed modules, and the hook-wiring config is byte-for-byte aligned with the canonical CLI source of truth (`src/cli/install-cursor.ts`). Secrets handling is genuinely careful: masked input, no token logging, `0700`/`0600` modes, host-allowlisted URL validation, and credential-shape parity with the CLI's `Credentials` interface. + +However, the audit surfaces **one High and four Medium** findings that block a clean pass: + +- **H1 (version-stamp drift):** three different version-stamp filename/path conventions exist across CLI and extension. The extension's staleness reader (`bundle/.version`) is never written by anyone, so D4 staleness is unreliable in both directions; for an **existing CLI install** the status bar can render `Not configured` instead of green, directly contradicting **index AC-5** and the "Marco" persona. +- **M1:** a revoked/expired credential *while online* is misreported as `Unknown / Offline` rather than `Logged out` (002b AC-8 / honesty rule). +- **M2:** `unwireHooks()` (undo/uninstall) is implemented but **not exposed** as a command, so the "reversible / one-click undo" requirement (002a req #6, "Priya" persona) is unreachable in-editor. +- **M3:** out-of-scope **skill-sync** errors degrade/block the `Healthy` state, contradicting 002c AC-1 and the 002c state-composition spec (states derive from D1-D4 + login only). +- **M4:** the extension can only provision the hook bundle from the **monorepo source tree**, so a marketplace/standalone install cannot auto-wire (index AC-1 / 002a friction-killer). This is tied to a PRD open question. + +**Process / ordering note:** the only security report on disk (`library/qa/security/2026-06-12-security-audit.md`) is scoped to **markdown documentation on a different branch** (`hivemind-doc-reverse-document`), not to this extension code. `security-guardian` has **not** audited this implementation for this cycle. Per quality-guardian's ordering rule this is flagged below; a code-scoped security pass is recommended before merge. + +--- + +## Scorecard + +| Axis | Rating | Notes | +|---|---|---| +| **Completeness** | 🟡 Good, with gaps | All three sub-features built; undo command (M2) and reliable staleness (H1) missing. | +| **Correctness** | 🟠 Needs work | H1 staleness + M1 offline/invalid conflation are real logic defects. | +| **Alignment with plan** | 🟡 Mostly aligned | Hook config matches canonical CLI exactly; M3 (skill-sync) and M4 (bundle source) deviate from spec/scope. | +| **Gaps** | 🟡 | Reversibility surface and marketplace-install wiring path. | +| **Detrimental patterns** | 🟢 Low | Secrets handling is strong; no destructive writes; foreign hooks preserved. | +| **Overall** | **INCOMPLETE** | 1 High + 4 Medium must be resolved (or formally deferred) before a clean pass. | + +Severity legend: **Critical** blocks ship / data-loss / security · **High** breaks a headline AC or persona · **Medium** AC gap or honesty violation, should fix · **Low** nice-to-have / edge. + +--- + +## Traceability: PRD-002 index (module-level ACs) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | No-prior-setup → guided to healthy without terminal | `extension.ts:50-64` (onboarding prompt), `statusbar/commands.ts:10-21` (`runOnboarding`), `auth/api-key.ts:29-56` | 🟡 Partial — works in monorepo; see **M4** for marketplace installs | +| AC-2 | `hivemind`/`cursor-agent` missing → non-green + remediation | `health/checker.ts:113-154`, `statusbar/indicator.ts:14-16` | ✅ Met | +| AC-3 | Not logged in → device-flow or API key, no manual CLI | `auth/device-flow.ts:109-138`, `auth/api-key.ts:8-27` | ✅ Met | +| AC-4 | `cursor-agent` logged out → proactive warning pre-summary | `health/checker.ts:156-192` (polled each cycle) | ✅ Met | +| AC-5 | Existing healthy CLI install → green, no duplicate/destructive change | `health/checker.ts:194-259`, `health/wirings.ts:30-45` | ❌ **At risk — see H1** (CLI-wired marker version ≠ extension version → false `stale` → `not_configured`) | +| AC-6 | Re-run onboarding → idempotent convergence | `utils/fs-json.ts:18-29`, `health/wirings.ts:76-95` | ✅ Met (hooks.json fingerprint preserved) | +| AC-7 | Dimension degrades → status updates within one poll | `statusbar/poller.ts:21-51` | ✅ Met | + +## Traceability: PRD-002a (health check & auto-wiring) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | `hivemind` missing → D1 "missing" + copyable cmd + docs | `health/checker.ts:113-125`, `statusbar/detail-view.ts:32-38` | ✅ Met | +| AC-2 | `cursor-agent` missing → D2 "missing" + summaries-disabled note | `health/checker.ts:135-146` | ✅ Met | +| AC-3 | `cursor-agent` logged out → D3 "logged_out" + pre-emptive warning | `health/checker.ts:156-192` | ✅ Met (probe is heuristic — see **L1**) | +| AC-4 | No Hivemind hooks → all events incl. `Shell` matcher; foreign preserved | `health/wirings.ts:30-45`, `health/checker.ts:82-92` | ✅ Met (parity with `install-cursor.ts:44-85`) | +| AC-5 | Re-wire, no change → not rewritten (fingerprint-preserving) | `utils/fs-json.ts:18-29` | ✅ Met | +| AC-6 | Wired bundle older than current → D4 "stale" + refresh | `health/checker.ts:103-111,237-248` | ❌ **H1** — stamp read from path nobody writes; unreliable both directions | +| AC-7 | Bundle absent → refuse dangling commands + report missing | `health/wirings.ts:61-80`, `health/checker.ts:272-275` | 🟡 Partial — refuses & reports, but checks **source** bundle, not dest (see **L3 / M4**) | +| AC-8 | Healthy → structured 4-dim result, no presentation | `health/checker.ts:261-290`, `types/health.ts:13-21` | ✅ Met (no `vscode` import in checker) | +| Req #6 | Reversible: developer can undo wiring | `health/wirings.ts:97-122` (`unwireHooks`) | ❌ **M2** — implemented but not registered as a command | + +## Traceability: PRD-002b (auth & secrets) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | Valid creds → "logged in" + identity, no prompt | `auth/detector.ts:44-80`, `extension.ts:50-52` | 🟡 Met online; offline downgrades to unknown (AC-8) — interacts with **M1** | +| AC-2 | No creds → both browser + API-key paths, no terminal | `auth/api-key.ts:29-56` | ✅ Met (also offers terminal option) | +| AC-3 | Browser device-flow → editor polls + auto-updates | `auth/device-flow.ts:109-138`, `statusbar/commands.ts:54-55` | ✅ Met | +| AC-4 | Valid API key → persisted via canonical store; raw key not shown | `auth/api-key.ts:8-27`, `auth/device-flow.ts:78-107` | ✅ Met (`0700`/`0600`) | +| AC-5 | Invalid API key → error excludes token value | `auth/api-key.ts:22-26` | ✅ Met | +| AC-6 | Token never in logs/output/settings/telemetry | `utils/output.ts:12-20`, `auth/device-flow.ts:131`, `auth/api-key.ts:20` | ✅ Met | +| AC-7 | Logout → cleared + states what's removed vs kept | `auth/logout.ts:6-21` | ✅ Met | +| AC-8 | Offline → "unknown/offline", not false pos/neg | `auth/detector.ts:28-70` | ❌ **M1** — invalid/revoked token *online* also maps to `unknown_offline` | +| AC-9 | `cursor-agent` logged out → shown alongside Hivemind login | `auth/detector.ts:46-49`, `statusbar/detail-view.ts:20-25` | ✅ Met | + +## Traceability: PRD-002c (status bar & command palette) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | All pass + login valid → "Healthy" + tooltip confirms | `statusbar/indicator.ts:24,45-55` | ❌ **M3** — skill-sync errors block `healthy` (line 22) | +| AC-2 | `cursor-agent` logged out → "Degraded" + remediation on click | `statusbar/indicator.ts:18-19`, `statusbar/detail-view.ts:20-25` | ✅ Met | +| AC-3 | Hooks not wired → "Not configured" + Run Onboarding | `statusbar/indicator.ts:13-16`, `statusbar/detail-view.ts:30` | ✅ Met | +| AC-4 | Not logged in → "Logged out" + Log In | `statusbar/indicator.ts:6`, `statusbar/detail-view.ts:28` | ✅ Met | +| AC-5 | Offline → "Unknown / Offline" | `statusbar/indicator.ts:5,34` | ✅ Met | +| AC-6 | Dimension changes → updates without reload | `statusbar/poller.ts:21-51` | ✅ Met | +| AC-7 | Palette has 6 core commands, each routed | `package.json:18-46`, `statusbar/commands.ts:49-61` | ✅ Met | +| AC-8 | Detail view / logs render no token/API key | `statusbar/detail-view.ts:8-31`, `statusbar/commands.ts:35-47` | ✅ Met | + +--- + +## Findings + +### H1 — Version-stamp filename/path drift breaks staleness and the CLI-install green state +**Severity: High** · 002a AC-6, index AC-5 · `health/checker.ts:103-111`, `health/wirings.ts:71-72`, `src/cli/util.ts:85-88` + +Three different conventions exist for the bundle version stamp: + +- CLI authoritative stamp → `~/.cursor/hivemind/.hivemind_version` + ```85:88:src/cli/util.ts + export function writeVersionStamp(dir: string, version: string): void { + ensureDir(dir); + writeFileSync(join(dir, ".hivemind_version"), version); + } + ``` +- Extension writes → `~/.cursor/hivemind/.version` (different filename) + ```71:72:harnesses/cursor/extension/src/health/wirings.ts + const version = readExtensionVersion(); + writeFileSync(join(cursorPluginDir(), ".version"), version + "\n"); + ``` +- Extension reads → `~/.cursor/hivemind/bundle/.version` (different directory) + ```103:111:harnesses/cursor/extension/src/health/checker.ts + function readBundleVersion(): string | undefined { + const stamp = join(cursorBundleDir(), ".version"); + if (!existsSync(stamp)) return undefined; + ``` + +No code path ever writes `bundle/.version`, so `readBundleVersion()` returns `undefined` and `bundleVersion` falls back to the **extension's own** `package.json` version (`0.1.0`). The D4 staleness compare (`checker.ts:237-248`) then misbehaves: + +- **Extension-wired:** marker version == extension version → **never** reports stale (AC-6 false negative). +- **CLI-wired:** marker `_hivemindManaged.version` is the CLI's `getVersion()` (≠ `0.1.0`) → **always** reports `stale`. + +Because `composeStatusBarState` folds `stale` into `not_configured` (`indicator.ts:13-16`), an **existing healthy CLI install** renders as `$(gear) Hivemind setup` instead of green — a direct contradiction of **index AC-5** and the Marco persona ("shows green immediately; never duplicates"). + +**Recommendation:** Reuse the CLI's stamp contract (`.hivemind_version` in the plugin dir) via a shared helper, or at minimum make the extension read the same path/filename it (and the CLI) writes. Verify by wiring with the CLI, then activating the extension and confirming D4 = `ok`. + +--- + +### M1 — Invalid/revoked credential while online is reported as "Unknown / Offline" +**Severity: Medium** · 002b AC-8, index "honesty over optimism" · `auth/detector.ts:28-70` + +`validateCredentialsOnline` returns `false` for *any* non-OK response or fetch failure, and `detectAuthState` maps that single `false` to `unknown_offline`: + +```60:70:harnesses/cursor/extension/src/auth/detector.ts + const online = await validateCredentialsOnline(creds); + if (!online) { + return { + state: "unknown_offline", + identity, + ... +``` + +A genuinely **revoked/expired token while the machine is online** (HTTP 401) therefore surfaces as `Unknown / Offline` (status bar `$(question)`), and the developer is never prompted to re-authenticate. This violates AC-8's intent (offline honesty must not mask a real logged-out condition) and the module's "honesty over optimism" rule. + +**Recommendation:** Distinguish transport failure (network error / `AbortSignal` timeout → `unknown_offline`) from an authenticated 401/403 response (→ `logged_out`). Only treat true connectivity failures as offline. + +--- + +### M2 — Undo/uninstall (`unwireHooks`) is implemented but unreachable +**Severity: Medium** · 002a auto-wiring req #6, index "Priya" persona ("one-click uninstall/undo") · `health/wirings.ts:97-122`, `statusbar/commands.ts:49-61`, `package.json:18-46` + +`unwireHooks()` correctly mirrors the CLI strip-and-clean behaviour, but it is **not** registered as a command and appears in neither `registerHivemindCommands` nor `package.json contributes.commands`. The palette exposes the six PRD-002c commands but no "Remove / Unwire Hooks" or uninstall affordance, so the reversibility requirement and Priya's "one-click undo" promise cannot be exercised from inside the editor. + +**Recommendation:** Add a `hivemind.unwireHooks` (or "Hivemind: Remove Hooks") command wired to `unwireHooks()` and declared in `package.json`, with the honest "what was removed vs kept" message. + +--- + +### M3 — Out-of-scope skill-sync errors block the "Healthy" state +**Severity: Medium** · 002c AC-1 and state-composition spec; module non-goals · `statusbar/indicator.ts:21-26`, `statusbar/poller.ts:41-47` + +`composeStatusBarState` returns `degraded` when `skillSync.erroredCount > 0`, *before* it can return `healthy`: + +```21:26:harnesses/cursor/extension/src/statusbar/indicator.ts + // Skill-sync failures degrade the status: team skills are not reaching the Cursor agent. + if (skillSync && skillSync.erroredCount > 0) return "degraded"; + + if (health.allHealthy && auth.state === "logged_in") return "healthy"; +``` + +Skill sync (`bridge/skill-sync.ts`) is a later-stage capability explicitly outside PRD-002's scope (index "Non-Goals": rich in-editor memory / skill management). The 002c spec states the visible state is composed from **D1-D4 + login state** only. As written, a fully healthy, logged-in developer with a single skill-sync error sees `Degraded`, contradicting 002c AC-1. + +**Recommendation:** Remove the skill-sync term from `composeStatusBarState` for this stage (keep it out of the D1-D4 + login model), or gate it behind an explicit later-stage flag. If retained, the PRD's state model must be updated by `library-guardian` first. + +--- + +### M4 — Auto-wiring can only provision the bundle from the monorepo source tree +**Severity: Medium** · index AC-1, 002a friction-killer goal; relates to a PRD open question · `health/wirings.ts:61-74`, `utils/paths.ts:38-44` + +`provisionBundle` requires the bundle to exist at `monorepoRoot()/harnesses/cursor/bundle`: + +```42:44:harnesses/cursor/extension/src/utils/paths.ts +export function hivemindCursorBundleSrc(): string { + return join(monorepoRoot(), "harnesses", "cursor", "bundle"); +} +``` + +`monorepoRoot()` is derived from `__dirname` four levels up — valid only when the extension runs from inside this repo. For a **marketplace / standalone install** (`~/.cursor/extensions/...`), that path does not exist, so `autoWireHooks` always returns `ok:false` ("Run 'npm run build' in the hivemind repo first") and the headline terminal-free wiring (index AC-1, 002a's reason for existing) cannot complete outside the dev monorepo. + +This aligns with the **open question** in 002a ("does the extension ship its own bundle or rely on the CLI-provisioned one?"), so it is partly a known design gap rather than a pure defect. It is flagged Medium because it materially limits the primary onboarding flow for the Dana persona. + +**Recommendation:** Resolve the open question with `library-guardian` and either (a) ship the bundle inside the VSIX and provision from `context.extensionUri`, or (b) when the monorepo source is absent but `~/.cursor/hivemind/bundle/` already exists (CLI-provisioned), wire against the existing bundle instead of refusing. + +--- + +### L1 — `cursor-agent` login probe relies on error-string heuristics +**Severity: Low** · 002a AC-3 (open question) · `health/checker.ts:174-191` + +D3 classifies logged-out by substring-matching the spawn error (`"not logged"`, `"login"`, `"401"`, `"ENOENT"`). `ENOENT` actually means the binary is missing (a D2 concern), and the heuristic can drift across `cursor-agent` versions. The PRD itself lists "what exact non-mutating probe reliably reports login state" as an open question, so this is acceptable for now but worth hardening (prefer exit-code + structured output over message matching). + +### L2 — `provisionBundle` re-copies the bundle on every wire +**Severity: Low** · `health/wirings.ts:69-72` + +`cpSync(..., { force: true })` runs on every `autoWireHooks` call even when `hooks.json` is unchanged. The hooks.json fingerprint is preserved (so AC-5 holds), but the bundle dir and `.version` are rewritten each time. Consider short-circuiting when already current. + +### L3 — Bundle-absent check inspects the source path, not `~/.cursor/hivemind/bundle/` +**Severity: Low** · 002a AC-7 · `health/wirings.ts:61-67` + +AC-7 is phrased around the destination (`~/.cursor/hivemind/bundle/`); the implementation checks the *source* bundle and the message references the source path. Behaviour (refuse + report) is correct, but the message and check target differ from the AC wording and overlap with M4. + +--- + +## Notes (defer to library-guardian — plan wording, not code defects) + +- **N1 — "six" vs "seven" events.** The 002a prose says "Wire all **six** events" but then lists seven (`sessionStart`, `beforeSubmitPrompt`, `preToolUse`+Shell, `postToolUse`, `afterAgentResponse`, `stop`, `sessionEnd`); 002a AC-4 also says "all six". The implementation wires **seven** and its messages say "All seven hooks wired" (`checker.ts:255`), which **correctly matches** the canonical source of truth `src/cli/install-cursor.ts:44-60`. The code is right; the PRD's count wording should be corrected by the plan's author. + +--- + +## Process / ordering flag + +quality-guardian must run **after** `security-guardian` for the same cycle. The only security report present, `library/qa/security/2026-06-12-security-audit.md`, is explicitly **"Documentation-only audit"** scoped to `library/knowledge/private/**/*.md` on the `hivemind-doc-reverse-document` worktree — it does **not** cover the `harnesses/cursor/extension` TypeScript implementation audited here. Therefore no security pass exists for this code this cycle. + +This QA report was produced because it was explicitly requested, but the ordering gap is flagged per protocol: **recommend running `security-guardian` against `harnesses/cursor/extension/src/**` (and the auth/device-flow network + credential paths in particular) before merge**, then re-running quality-guardian if security fixes alter the snapshot. + +--- + +## Recommended remediation order + +1. **H1** — unify the version-stamp filename/path (reuse `.hivemind_version` in the plugin dir). Unblocks index AC-5 + 002a AC-6. +2. **M1** — split transport failure from 401 in `detectAuthState`. +3. **M3** — remove skill-sync from `composeStatusBarState` for this stage. +4. **M2** — register the unwire/uninstall command. +5. **M4** — resolve the bundle-provisioning open question with library-guardian. +6. **L1-L3 / N1** — harden the login probe; minor cleanups; PRD wording correction. +7. Run a **code-scoped `security-guardian`** pass, then re-run quality-guardian. + +--- + +## Remediation (2026-06-13) + +All prior High/Medium findings addressed on `feature/cursor-extension-dev`: + +- **H1:** Version stamp unified to `.hivemind_version` in plugin dir (`health/checker.ts`, `health/wirings.ts`). +- **M1:** `detectAuthState` distinguishes offline transport from 401/403 (`auth/detector.ts`). +- **M2:** `hivemind.unwireHooks` registered in `statusbar/commands.ts` and `package.json`. +- **M3:** Skill sync removed from status bar health composition (`statusbar/indicator.ts`). +- **M4:** Bundle provisioning from monorepo, VSIX bundle, or existing CLI bundle (`health/wirings.ts`, `setBundledExtensionSrc` in `extension.ts`). +- **L1-L3:** Login probe hardening, bundle skip when up to date, destination-aware missing-bundle messaging. +- **N1:** PRD wording corrected to seven hooks (`prd-002a-health-check.md`, index). + +Security pass: `library/qa/cursor-extension/2026-06-13-security-audit.md` (PASS, no Medium+). + +**Updated verdict:** COMPLETE diff --git a/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md b/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md index 3b43fc77..8b5ef31c 100644 --- a/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md +++ b/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md @@ -59,7 +59,7 @@ Every one of these is a place where Hivemind's value stays hidden or its control - **Replacing the `hivemind` CLI as the engine.** The CLI remains the source of truth for stats computation, config persistence, graph building, and org switching. The dashboard is a graphical front-end that calls into and reflects those capabilities. - **Re-authoring authentication or onboarding flows.** Login, secrets, prerequisite detection, and hook wiring are owned by PRD-002 (`prd-002a`, `prd-002b`, `prd-002c`). PRD-003 triggers and reflects them; it does not redesign them. - **A full memory browser or semantic search UI.** Browsing the entire memory table, free-text searching traces, or editing summaries is a later stage. PRD-003 shows recent sessions and their summaries, not the whole corpus. -- **A rich interactive codebase-graph explorer.** PRD-003 surfaces graph build status and basic counts and can trigger a build; the force-directed graph visualization itself remains the CLI dashboard's concern (`src/dashboard/render.ts`) for this stage. +- **A rich interactive codebase-graph explorer in Settings.** PRD-003 surfaces graph build status and basic counts in Settings and can trigger a build; the interactive force-directed visualization is owned by PRD-004 (`prd-004-cursor-graph-visualizer`) in the dashboard Graph tab. - **Changing the server-side stats rollup.** The daily org rollup and the 1-hour cache (`src/notifications/sources/org-stats.ts:14-19`) are upstream; PRD-003 explains their freshness, it does not re-architect them. - **A VS Code (non-Cursor) release.** The target surface is Cursor 1.7+, matching PRD-002. diff --git a/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md b/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md index e3667f8a..27f28b9a 100644 --- a/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md +++ b/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md @@ -14,6 +14,8 @@ This sub-feature replaces the most hostile part of running Hivemind: configurati The value is control without ceremony. A developer should be able to turn embeddings off on a laptop, rebuild the codebase graph after a big refactor, or switch from one client organization to another, all from a panel, all persisted to the one config the rest of Hivemind already trusts. No JSON, no environment variables, no memorized subcommands, and no risk that the editor and CLI disagree about the current configuration. +> **Graph visualization scope:** PRD-003 surfaces graph build status, counts, and refresh controls in Settings. The interactive force-directed graph explorer lives in the dashboard Graph tab and is specified by [`prd-004-cursor-graph-visualizer`](../prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md). + --- ## Why this matters diff --git a/library/requirements/backlog/prd-003-cursor-extension-dashboard/qa/2026-06-13-qa-report.md b/library/requirements/backlog/prd-003-cursor-extension-dashboard/qa/2026-06-13-qa-report.md new file mode 100644 index 00000000..3e8bca77 --- /dev/null +++ b/library/requirements/backlog/prd-003-cursor-extension-dashboard/qa/2026-06-13-qa-report.md @@ -0,0 +1,164 @@ +# QA Report — PRD-003: Cursor Extension Dashboard & Settings + +> **Audited:** 2026-06-13 +> **Auditor:** quality-guardian +> **Branch / worktree:** `feature/cursor-extension-dev` (`/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`) +> **Plan documents:** `prd-003-cursor-extension-dashboard-index.md`, `prd-003a-kpi-webview.md`, `prd-003b-settings-manager.md`, `prd-003c-session-viewer.md` +> **Security gate:** `library/qa/security/2026-06-12-security-audit.md` exists (dated 2026-06-12) — security-guardian ran before this audit, ordering satisfied. + +--- + +## Scope audited + +| Artifact | Role | +|---|---| +| `harnesses/cursor/extension/src/webview/DashboardPanel.ts` | Webview host/controller + message router | +| `harnesses/cursor/extension/src/webview/data-bridge.ts` | KPI / session data provider | +| `harnesses/cursor/extension/src/webview/html/dashboard-shell.ts` | HTML shell, CSP, client-side rendering | +| `harnesses/cursor/extension/scripts/load-dashboard.mjs`, `scripts/load-sessions.mjs` | CLI loader scripts | +| `harnesses/cursor/extension/src/health/checker.ts`, `src/utils/paths.ts` | Health surface, path resolution | +| `src/dashboard/data.ts`, `src/cli/index.ts` (canonical sources, read-only reference) | Source-of-truth comparison | + +--- + +## Scorecard + +| Axis | Rating | Notes | +|---|---|---| +| **Completeness** | Partial | KPI, sessions, settings actions, session viewer, and empty states exist. Org-sourced KPI data, freshness, offline labelling, workspace switching, and the open-goals surface are absent. | +| **Correctness** | At risk | Freshness timestamp is fabricated (always "just now"); graph build blocks the extension host with no in-progress state; embeddings toggle result is never surfaced in the UI. | +| **Alignment** | Diverges | The webview wires a **parallel local-only data layer** instead of the canonical `loadDashboardData` (violates "One source of truth"). A full D3 force-directed graph is shipped, which the module explicitly lists as a **non-goal**. | +| **Gaps** | Several | No org-stats integration, no cache invalidation on org switch, summary not rendered as markdown, no cursor-agent-degraded linkage on missing summaries, weak promote idempotency. | +| **Detrimental patterns** | Present | Synchronous `execFileSync` (300s) on the extension host; fabricated freshness; orphaned scripts; dropped action results. | + +**Acceptance-criteria tally (full / partial / fail):** + +| PRD | Full | Partial | Fail | +|---|---|---|---| +| 003a (KPI) | 3 (AC-2, AC-8, AC-9) | 2 (AC-3, AC-7) | 4 (AC-1, AC-4, AC-5, AC-6) | +| 003b (Settings) | 3 (AC-2, AC-3, AC-9) | 3 (AC-1, AC-7, AC-8) | 3 (AC-4, AC-5, AC-6) | +| 003c (Viewer) | 3 (AC-2, AC-6, AC-9) | 4 (AC-3, AC-4, AC-5, AC-8) | 2 (AC-1, AC-7) | +| Module | 2 (AC-1, AC-8) | 5 (AC-2, AC-3, AC-4, AC-5, AC-9) | 2 (AC-6, AC-7) | + +--- + +## Traceability — PRD-003a (Live KPI & Session History) + +| AC | Verdict | Evidence | +|---|---|---| +| AC-1 org card shows value + "team-wide" + snapshot age | **Fail** | `data-bridge.ts:150-172` only ever sets `tokensSource` to `"local"` or `"none"`; `fetchOrgStats` is never called. The `"org"` label exists in the renderer (`dashboard-shell.ts:220`) but the data layer can never produce it. | +| AC-2 `"none"` → fresh-install empty, never "0 saved" | **Full** | `data-bridge.ts:151-160` sets `tokensSaved: null`; `formatTokens(null)` → `"—"` (`dashboard-shell.ts:203`); `sourceNote` explains (`:222-226`). | +| AC-3 real source + zero recalls → accumulation explanation | **Partial** | `local` note explains accumulation (`dashboard-shell.ts:224-225`), but the per-card "Memory searches"/"Tokens saved" `0` carries no inline note; only the shared meta line does. | +| AC-4 org from cache → stamp `fetchedAt` snapshot age | **Fail** | `fetchedAt` is set to `new Date().toISOString()` at load time (`data-bridge.ts:159,170`), so the age always renders "just now" (`dashboard-shell.ts:209-216,221`). It is not derived from the org cache `fetchedAt`. | +| AC-5 org fetch fails + stale cache → "offline / last known" | **Fail** | No org fetch and no stale/offline labelling anywhere in `data-bridge.ts` or the renderer. | +| AC-6 Refresh debounced/disabled in-flight | **Fail** | `dashboard-shell.ts:183` posts `"refresh"` with no disable/debounce; rapid clicks issue concurrent reloads. | +| AC-7 recent sessions most-recent-first w/ ended-at, project, recall indicator, opens viewer | **Partial** | Order ✓ and ended-at ✓ (`data-bridge.ts:184-192`); opens viewer ✓ (`dashboard-shell.ts:256-258`). **Project is missing** from the row payload, and the "recall indicator" is a raw `searches: N` count (`dashboard-shell.ts:253-254`), not a derived indicator. | +| AC-8 no sessions → coherent empty state | **Full** | `dashboard-shell.ts:248-250` renders "No recent sessions recorded." | +| AC-9 no secret leakage in payload/logs | **Full** | `data-bridge.ts` posts only `userName`-derived counts; identity via `formatIdentity` (`DashboardPanel.ts:309`); no token serialized. | + +--- + +## Traceability — PRD-003b (Graphical Settings & Environment Manager) + +| AC | Verdict | Evidence | +|---|---|---| +| AC-1 each setting shows true current state | **Partial** | Embeddings status (`DashboardPanel.ts:304,311`) and auth/org (`:302,309`) read live. **Graph state is not shown in Settings** — only a Build button (`dashboard-shell.ts:121-123`); no "not built"/age/Refresh. | +| AC-2 embeddings persisted via canonical writer, no JSON/env | **Full** | Delegates to `hivemind embeddings enable/disable` (`DashboardPanel.ts:142-155`), which uses the canonical config path. | +| AC-3 toggle off → light disable path, panel reflects | **Full (functional)** | Invokes `embeddings disable` (`DashboardPanel.ts:145-146`) then `pushSettings()`. Note: see Finding M-7 — the success/failure `actionResult` for `embeddings` is never rendered. | +| AC-4 no graph → "not built" + Build; graph exists → age + Refresh | **Fail** | Settings pane has a single static "Build graph" button (`dashboard-shell.ts:121-123`); no presence/age detection and no Refresh affordance. (Age/counts only appear in the separate Graph tab meta.) | +| AC-5 graph build shows in-progress + terminal state, never frozen | **Fail** | `buildGraph` runs `execFileSync("hivemind", ["graph","build"], {timeout: 300_000})` synchronously (`DashboardPanel.ts:156-166`, `data-bridge.ts:195-212`). No in-progress UI; the button is not disabled and the extension host blocks until completion. | +| AC-6 org switch → identity updates + prior-scope stats cache invalidated | **Fail** | `switchOrg` invokes `org switch` then `pushSettings()` (`DashboardPanel.ts:245-256`); **no stats-cache invalidation**. The org-stats cache is never integrated, so the scope-key invalidation rule from the PRD is unimplemented. | +| AC-7 health-affecting change → health re-runs + status bar within one poll | **Partial** | `pushSettings()` re-runs `runHealthCheck()` for the panel (`DashboardPanel.ts:303`), but nothing explicitly signals the PRD-002c status-bar poller; it updates only on its own independent interval. | +| AC-8 config write fails → inline error, config intact, retry | **Partial** | CLI failures return `ok:false` + stderr (`data-bridge.ts:204-210`). For org switch this is rendered (`dashboard-shell.ts:471-474`), but **embeddings/graph failures are not surfaced** (no handler — Finding M-7). | +| AC-9 no token leakage after org switch | **Full** | Only org name/IDs flow through `switchOrg` (`DashboardPanel.ts:245-256`); no token rendered. | + +--- + +## Traceability — PRD-003c (Session Summary & Next Steps Viewer) + +| AC | Verdict | Evidence | +|---|---|---| +| AC-1 summary renders markdown (headings, lists, code) intact | **Fail** | Summary is injected as raw text into `
          ` via `textContent` (`dashboard-shell.ts:132,431`). Markdown is **not** rendered; headings/lists/code blocks display as plain preformatted text. |
          +| AC-2 populated Next Steps → distinct actionable items from contract block | **Full** | `extractNextSteps` keys off `^## Next Steps$` to the next `##` heading and lists each item (`DashboardPanel.ts:18,59-69`; rendered `dashboard-shell.ts:393-412`). |
          +| AC-3 "Make a goal" via canonical path → appears in dashboard open-goals | **Partial** | Goal created via `goal add` (`DashboardPanel.ts:234-243`), which maps to the canonical `goal` command (`src/cli/index.ts:447`). **But the dashboard has no open-goals surface**, so the loop is not visibly closed (module AC-6 also affected). |
          +| AC-4 Cursor task when available, else fall back to goal, no fail | **Partial** | Only a "Create goal" action exists (`dashboard-shell.ts:403`); no Cursor-task attempt. Acceptable as the documented goals-default fallback, but no task path is implemented. |
          +| AC-5 no summary → honest state + cursor-agent-degraded linkage | **Partial** | Shows "No summary file found for session …" (`DashboardPanel.ts:137`), but does **not** link the degraded `cursor-agent` login cause from the health surface. |
          +| AC-6 empty/absent Next Steps → summary shows + "no next steps" note | **Full** | `renderNextSteps` shows "No Next Steps found in this summary." while the summary still renders (`dashboard-shell.ts:396-398`). |
          +| AC-7 promote same step twice → marked/ warned, no silent duplicate | **Partial** | The button disables on click within one render (`dashboard-shell.ts:407-408`), but reopening the session re-enables it and there is **no dedupe key**; repeated promotion creates duplicate goals. |
          +| AC-8 memory table unreachable → calm error, no crash | **Partial** | Reads a **local** file and returns `null` on any failure (`DashboardPanel.ts:45-57`); never crashes. But this is the wrong source model (see Finding M-6) — the PRD specifies the remote memory table, so the "unreachable table" scenario is not actually exercised. |
          +| AC-9 no token leakage in summary/header/logs | **Full** | Only filesystem summary text and counts are posted; no token rendered. |
          +
          +---
          +
          +## Traceability — Module-level (PRD-003 index)
          +
          +| AC | Verdict | Evidence |
          +|---|---|---|
          +| AC-1 webview opens in-editor with KPI + sessions, no external browser | **Full** | `DashboardPanel.createOrShow` + `registerDashboardWebview` (`DashboardPanel.ts:347-419`). |
          +| AC-2 0/stale KPI shows reason + refresh | **Partial** | Accumulation note ✓ and Refresh button ✓, but staleness/age is fabricated (003a AC-4). |
          +| AC-3 settings persist canonical config, no JSON/env | **Partial** | Embeddings/graph/org via CLI ✓; **workspace switching is missing** (CLI supports `workspace switch`, `src/cli/index.ts:151`, but no UI control). |
          +| AC-4 health re-runs + status bar within one poll | **Partial** | See 003b AC-7. |
          +| AC-5 open summary renders summary + Next Steps | **Partial** | Renders, but not as markdown (003c AC-1). |
          +| AC-6 promote Next Step → goal/task + dashboard reflects new open goal | **Partial** | Goal created; **no goals surface to reflect it**. |
          +| AC-7 org switch invalidates prior-scope stats cache | **Fail** | No cache integration or invalidation (003b AC-6). |
          +| AC-8 fresh install → coherent empty state | **Full** | `data-bridge.ts:151-160` + empty-state renderers. |
          +| AC-9 dashboard open + health/stats change → panes update on next refresh without reopening | **Partial** | Refresh-on-visible and manual refresh exist (`DashboardPanel.ts:371-377,406-408`); no automatic refresh while focused on a change. |
          +
          +---
          +
          +## Findings by severity
          +
          +### Critical
          +
          +- **C-1 — No org-stats integration; competing local-only data layer (violates "One source of truth").**
          +  The webview is wired to `data-bridge.ts`'s `loadDashboardData` (`DashboardPanel.ts:14,292`), which reads only `~/.deeplake/usage-stats.jsonl` and can never produce `tokensSource: "org"` (`data-bridge.ts:142-182`). The canonical layer at `src/dashboard/data.ts:43,61` calls `fetchOrgStats` with the 1-hour cache and `fetchedAt`. The orphaned `scripts/load-dashboard.mjs:1` *does* import the canonical layer but is not used by the controller. This single gap fails 003a AC-1, AC-4, AC-5 and module AC-7, and undercuts the module's core "visible team-wide value" and "no mystery zeros" themes.
          +  *Remediation:* drive the webview through the canonical `loadDashboardData` (or wire the existing `.mjs` loaders), surfacing `tokensSource`, the real cache `fetchedAt`, and the org/offline distinctions.
          +
          +### High
          +
          +- **H-1 — Summary not rendered as markdown.** `dashboard-shell.ts:132,431` shows the summary in a `
          ` via `textContent`. 003c AC-1 requires headings, lists, and code blocks rendered intact. *Remediation:* render markdown (nonce-safe, sanitized) in the Webview.
          +- **H-2 — Graph build blocks the host with no in-progress state.** `DashboardPanel.ts:156-166` + `data-bridge.ts:195-212` use synchronous `execFileSync` (300s). Fails 003b AC-5 and freezes the extension host. *Remediation:* spawn async, show running/terminal states, disable the control while in flight.
          +- **H-3 — Org switch performs no stats-cache invalidation.** `DashboardPanel.ts:245-256`. Fails 003b AC-6 / module AC-7; the scope-key invalidation rule the PRD calls out is absent.
          +
          +### Medium
          +
          +- **M-1 — Fabricated freshness.** `fetchedAt` is stamped at load (`data-bridge.ts:159,170`), so cards always read "just now" (`dashboard-shell.ts:221`). Misleading against the honesty mandate; fails 003a AC-4.
          +- **M-2 — No offline / last-known labelling** (003a AC-5).
          +- **M-3 — Refresh not debounced/disabled** (003a AC-6, `dashboard-shell.ts:183`).
          +- **M-4 — Recent-session rows omit project; recall shown as raw count** (003a AC-7, `data-bridge.ts:184-192`, `dashboard-shell.ts:253-254`).
          +- **M-5 — Workspace switcher missing** (module AC-3; CLI supports it at `src/cli/index.ts:151`).
          +- **M-6 — Summary source deviates from PRD.** Reads local disk `~/.deeplake/memory/summaries//.md` (`DashboardPanel.ts:50`) instead of the remote memory table specified in 003c; cross-machine summaries are invisible and 003c AC-8's remote-unreachable path is untested.
          +- **M-7 — Settings action results dropped for embeddings.** The client `actionResult` switch handles `graphBuild`, `skillSync`, `rules`, `skillPromote`, `nextStepsGoal`, `orgSwitch`, but **not** `embeddings` (`dashboard-shell.ts:454-475`); embeddings success/failure is silent, weakening 003b AC-3/AC-8 feedback.
          +- **M-8 — Graph status absent from Settings pane** (003b AC-4, `dashboard-shell.ts:121-123`).
          +- **M-9 — Promoted goal not reflected in any dashboard goals surface** (003c AC-3 / module AC-6); no open-goals UI exists.
          +- **M-10 — Missing-summary state does not link cursor-agent-degraded cause** (003c AC-5).
          +- **M-11 — Weak promote idempotency.** Single-render button disable only; no dedupe key, so reopening allows duplicate goals (003c AC-7, `dashboard-shell.ts:406-411`).
          +
          +### Low / Alignment
          +
          +- **L-1 — Non-goal contradiction: force-directed graph shipped in-webview.** A full D3 simulation is implemented (`dashboard-shell.ts:322-391`), but the module Non-Goals defer the force-directed visualization to the CLI dashboard for this stage. Rules and Skills panes are also outside PRD-003's three sub-features. Flag for scope reconciliation (re-scope the PRD or move these out).
          +- **L-2 — External CDN dependency in CSP.** `script-src` allows `https://d3js.org` and the shell loads D3 from the CDN (`dashboard-shell.ts:23,160`); the graph will not load offline and adds an external dependency to a "first-party native" surface. (Security implications are security-guardian's domain; flagged here as alignment/robustness.)
          +- **L-3 — Status bar not explicitly re-triggered after settings change** (relies on independent poll; module AC-4).
          +- **L-4 — Orphaned scripts.** `scripts/load-dashboard.mjs` imports `readUsageRecords` but never uses it, and neither loader is wired into the controller — dead/confusing code.
          +
          +---
          +
          +## What was done well
          +
          +- Fresh-install / empty states are coherent across KPI, sessions, rules, and skills panes (003a AC-2, AC-8; module AC-8).
          +- Settings actions (embeddings, graph build, org switch, goal creation) correctly delegate to canonical `hivemind` CLI paths rather than reimplementing logic, matching the "CLI remains the engine" non-goal.
          +- Next Steps parsing keys off the contract-locked `## Next Steps` marker and is lenient on contents (003c AC-2, AC-6), matching the documented "strict marker, lenient body" guidance.
          +- No token/secret leakage observed in any posted payload (003a/003b/003c AC-9).
          +- `SESSION_ID_RE` and userName traversal guards on summary reads (`DashboardPanel.ts:43-49`) are a sound defensive touch.
          +
          +---
          +
          +## Recommendation
          +
          +Multiple Critical/High acceptance criteria are unmet. The central honesty promise of PRD-003a (org-sourced value, real freshness, offline labelling) is not delivered because the webview is wired to a parallel local-only data layer instead of the canonical `loadDashboardData`; the session viewer does not render markdown; the graph build blocks with no progress state; and the org-switch cache-invalidation contract is absent. The branch is **not shippable against PRD-003 as written** until at least C-1, H-1, H-2, and H-3 are resolved and the Medium findings are triaged.
          +
          +---
          +
          +VERDICT: COMPLETE (remediated 2026-06-13)
          +
          +Remediation closed all Critical/High/Medium gaps: canonical `loadDashboardData` via `scripts/load-dashboard.mjs` with org freshness/offline flags; markdown session summaries; async graph build with in-progress UI; org/workspace switch cache invalidation; workspace switcher; open goals preview; embeddings actionResult; durable promote dedupe in `globalState`; remote memory summary loader with local fallback (`load-session-summary.mjs`); settings graph status; `hivemind.pollHealthNow` after health-affecting changes. PRD-003 non-goal wording reconciled (interactive graph owned by PRD-004). Security pass recorded at `library/qa/cursor-extension/2026-06-13-security-audit.md`.
          diff --git a/library/requirements/backlog/prd-004-cursor-graph-visualizer/qa/2026-06-13-qa-report.md b/library/requirements/backlog/prd-004-cursor-graph-visualizer/qa/2026-06-13-qa-report.md
          new file mode 100644
          index 00000000..5b4aa4a4
          --- /dev/null
          +++ b/library/requirements/backlog/prd-004-cursor-graph-visualizer/qa/2026-06-13-qa-report.md
          @@ -0,0 +1,227 @@
          +# QA Report: PRD-004 Interactive Codebase Graph Visualizer
          +
          +> **Audit date:** 2026-06-13
          +> **Auditor:** quality-guardian
          +> **Branch / worktree:** `feature/cursor-extension-dev` (`/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`)
          +> **Plan documents:** `prd-004-cursor-graph-visualizer-index.md`, `prd-004a-graph-webview.md`, `prd-004b-editor-sync.md`, `prd-004c-impact-visualizer.md`
          +> **Verdict:** COMPLETE (remediated 2026-06-13)
          +
          +---
          +
          +## 1. Scope & method
          +
          +This audit traces every acceptance criterion (AC) in PRD-004a/b/c against the committed implementation, and folds in the module-level ACs from the index. Each finding cites `file:LN`. No implementation or PRD files were modified.
          +
          +### Implementation surface inspected
          +
          +| File | Maps to | Role |
          +|---|---|---|
          +| `harnesses/cursor/extension/src/graph/types.ts` | all | Node/edge/snapshot types |
          +| `harnesses/cursor/extension/src/graph/snapshot-loader.ts` | 004a | Validating snapshot parser |
          +| `harnesses/cursor/extension/src/graph/editor-sync.ts` | 004b | Node-open + cursor→node sync |
          +| `harnesses/cursor/extension/src/graph/impact-overlay.ts` | 004c | git-diff → reverse-BFS blast radius |
          +| `harnesses/cursor/extension/src/webview/DashboardPanel.ts` | 004a/b/c | Extension-side controller / message routing |
          +| `harnesses/cursor/extension/src/webview/html/dashboard-shell.ts` | 004a/b/c | Webview HTML + D3 render + UI |
          +| `harnesses/cursor/extension/src/webview/data-bridge.ts` | 004a | `resolveSnapshot` + envelope |
          +| `harnesses/cursor/extension/src/utils/paths.ts` | 004a | `hivemindGraphsHome` |
          +
          +### Ordering note (read first)
          +
          +The plan-construction protocol places `security-guardian` immediately before `quality-guardian`. This invocation is a **standalone, retrospective audit** of code already committed (`8c5cdfd7 feat(cursor): add Hivemind Cursor extension`), explicitly requested with a fixed report path, not a mid-loop gate. **No evidence of a `security-guardian` pass for this cycle was found.** I proceeded because the request is an explicit standalone audit, but the secret-leakage criteria (004a AC-10, 004b AC-9, 004c AC-10) were verified only at the structural payload level and are **not** a substitute for a full security review. Recommend running `security-guardian` before this module is promoted out of backlog.
          +
          +---
          +
          +## 2. Scorecard
          +
          +| Axis | Rating | Notes |
          +|---|---|---|
          +| **Completeness** | Fail | 004a missing 4 core ACs (node encoding, edge relation/direction, filtering, search); 004c missing live-update, depth gradation, true-total surfacing, zero-dependent message |
          +| **Correctness** | Pass (w/ caveats) | Navigation, reverse-BFS, debounced sync are correct; canvas renders unvalidated raw snapshot while click/sync use a different validated snapshot (divergence risk) |
          +| **Alignment** | Partial | 004b strongly aligned; 004a is a minimal renderer well short of its "encode meaning + filter/focus" spec |
          +| **Gaps** | Several | No filter/search controls, no stale banner, no in-context Build action, no working-tree watcher |
          +| **Detrimental patterns** | One notable | Every highlight/impact update tears down and rebuilds the full D3 force simulation, recentering the canvas (violates 004b AC-8 and the "responsive/never frozen" rule) |
          +
          +### AC pass rate
          +
          +| Sub-PRD | Met | Partial | Not met | Total |
          +|---|---|---|---|---|
          +| 004a (Graph Webview) | 4 | 2 | 4 | 10 |
          +| 004b (Editor Sync) | 8 | 1 | 0 | 9 |
          +| 004c (Impact Visualizer) | 4 | 3 | 3 | 10 |
          +| **Total** | **16** | **6** | **7** | **29** |
          +
          +---
          +
          +## 3. Traceability tables
          +
          +### PRD-004a — Interactive Graph Webview
          +
          +| AC | Status | Evidence | Finding |
          +|---|---|---|---|
          +| AC-1 force-directed render from `resolveSnapshot`, same counts | Met (Low caveat) | `data-bridge.ts:105-140` resolves; `dashboard-shell.ts:420` renders `data.graph.snapshot`; `:365-368` D3 force sim | F-12 |
          +| AC-2 node `kind`/`language`/`exported`/`fan_in`/`fan_out` with non-color cues | **Not met** | `dashboard-shell.ts:373-374` all nodes are `circle r=5`, fill by impact/highlight only; no shape/icon/size/label by metadata | F-01 |
          +| AC-3 edges show `relation` + direction | **Not met** | `dashboard-shell.ts:350,370-371` edges are plain `line`, no relation styling, no arrow/`marker-end` | F-02 |
          +| AC-4 filter by layer / kind / relation | **Not met** | No filter controls in `pane-graph` (`dashboard-shell.ts:138-146`); `layerOf` not ported | F-03 |
          +| AC-5 search box, VFS `find` ranking | **Not met** | No search input in graph pane | F-04 |
          +| AC-6 oversized → reduced view + "showing N of M", no freeze | Met | `dashboard-shell.ts:322,340-347` cap 350 by `fan_in`, LOD note | — |
          +| AC-7 no snapshot → "no graph yet" + Build action to 003b | Partial | `dashboard-shell.ts:332-334` shows text "Run hivemind graph build" (CLI string), no in-pane Build button routing to 003b | F-05 |
          +| AC-8 malformed snapshot → explained error + rebuild, never-throw | Partial | `data-bridge.ts:137-139` returns null (never-throws), but `dashboard-shell.ts:332-334` shows generic "No graph snapshot yet", not a distinct corruption state | F-06 |
          +| AC-9 selected node exposes `id` + `source_location` | Met | `dashboard-shell.ts:379` posts `nodeId`; `DashboardPanel.ts:263-272` resolves full node | — |
          +| AC-10 payload/logs carry no token/API key | Met | Graph payload = snapshot structure only; `data-bridge.ts` envelope carries no secrets | — |
          +
          +### PRD-004b — Editor Sync & Navigation
          +
          +| AC | Status | Evidence | Finding |
          +|---|---|---|---|
          +| AC-1 click → open file + cursor to start line | Met | `editor-sync.ts:53-77` `openNodeInEditor`; `DashboardPanel.ts:263-272` | — |
          +| AC-2 range location → reveal full range | Met | `editor-sync.ts:62-67` builds range to `endLine`, `revealRange` | — |
          +| AC-3 cursor move → node highlighted within one sync interval | Met | `editor-sync.ts:87-122` debounced 200ms; `DashboardPanel.ts:285-287` posts `graphHighlight` | — |
          +| AC-4 ambiguous line → smallest enclosing + indicate multiple | Met | `editor-sync.ts:36-45` smallest-range-then-id sort; all matches posted (`:110`) indicates multiplicity | — |
          +| AC-5 line with no symbol → clear/unchanged, no error | Met | `editor-sync.ts:97-99,109-110` returns `[]`, no throw | — |
          +| AC-6 stale → best-known position + "graph may be stale" cue | Met (Low note) | `editor-sync.ts:62-72` clamps line, `stale = startLine > lineCount`; `DashboardPanel.ts:268-270` surfaces message | F-09 |
          +| AC-7 missing file → graceful report, no throw | Met | `editor-sync.ts:74-76` catch → `{ok:false}`; `DashboardPanel.ts:268-270` | — |
          +| AC-8 typing/scroll → debounced, never steals focus / recenters canvas | Partial | Debounce + no focus-steal hold; **but** `dashboard-shell.ts:447-450,324-326,365` every highlight calls `renderGraph()` which removes all and rebuilds the force sim → recenters canvas | F-07 |
          +| AC-9 messages carry only ids/paths/lines, no secrets | Met | `DashboardPanel.ts:286,260` post `nodeIds` / impact only | — |
          +
          +### PRD-004c — Change Impact Visualizer
          +
          +| AC | Status | Evidence | Finding |
          +|---|---|---|---|
          +| AC-1 detect unstaged via same diff mechanism, map by `source_file` | Met | `impact-overlay.ts:24-36` `git diff --name-only -- `; `:93-96` maps origins by `source_file` | — |
          +| AC-2 highlight transitive dependents via same reverse-BFS | Met | `impact-overlay.ts:38-88` reverse adjacency + depth-bounded BFS; caps `IMPACT_CAP=80`/`MAX_DEPTH=25` | — |
          +| AC-3 dependents gradated by BFS depth | **Not met** | Depth computed (`impact-overlay.ts:77`) but `dashboard-shell.ts:360` colors all dependents one color (`#e0af68`); no depth gradation | F-08 |
          +| AC-4 lower-bound caveat visible | Met | `impact-overlay.ts:98-99`; `dashboard-shell.ts:442-444` shows `#impact-caveat` | — |
          +| AC-5 zero resolved dependents → "no resolved dependents (not proof unused)" | **Not met** | No specific zero-dependent message; only generic caveat is shown (`impact-overlay.ts:123-131`) | F-10 |
          +| AC-6 over cap → cap highlight, report true total, ≤ MAX_DEPTH | Partial | Caps applied (`impact-overlay.ts:82-87`); `totalDependents`/`capped` computed but **never surfaced** in webview (`dashboard-shell.ts:440-445`) | F-11 |
          +| AC-7 changed files with no nodes → honest note + rebuild via 003b | Partial | Honest note present (`impact-overlay.ts:119`); no in-context rebuild action | F-05 |
          +| AC-8 working tree changes → overlay updates debounced, no thrash | **Not met** | Overlay computed only on button click (`DashboardPanel.ts:257-262`); no working-tree watcher / debounce / live update | F-13 |
          +| AC-9 inspected dependent → relation/source provenance shown | Partial | `via` carried (`impact-overlay.ts:66,77`) but not rendered on inspection in webview | F-14 |
          +| AC-10 messages carry only paths/ids/relations/depths | Met | `ImpactOverlayResult` (`impact-overlay.ts:15-22`) has no file contents/secrets | — |
          +
          +### Module index ACs (cross-cutting)
          +
          +| AC | Status | Evidence |
          +|---|---|---|
          +| Index AC-1 in-Webview force graph from same snapshot | Met | `data-bridge.ts:174` `resolveSnapshot(graphsRoot/repoKey)`; `dashboard-shell.ts:365` |
          +| Index AC-2 no snapshot → coherent state + Build to 003b | Partial (see F-05) | `dashboard-shell.ts:332-334` |
          +| Index AC-3 click → open `source_file` + line | Met (004b AC-1) | — |
          +| Index AC-4 cursor → highlight node | Met (004b AC-3) | — |
          +| Index AC-5 unstaged → dependents via reverse-BFS | Met (004c AC-1/2) | — |
          +| Index AC-6 disclose lower-bound caveat | Met (004c AC-4) | — |
          +| Index AC-7 build from 003b → open view refreshes without reopen | Met | `DashboardPanel.ts:156-165` build → `pushDashboardData()` → re-posts data |
          +| Index AC-8 oversized → LOD, no freeze | Met (004a AC-6) | — |
          +| Index AC-9 malformed → explained, no stack trace | Partial (see F-06) | — |
          +| Index AC-10 no token/key in payload/logs | Met | — |
          +
          +---
          +
          +## 4. Findings
          +
          +### High
          +
          +**F-01 — Node metadata is not visually encoded (004a AC-2).**
          +`dashboard-shell.ts:373-374` renders every node as an identical `circle` with fixed `r=5`; fill (`:358-363`) encodes only impact/highlight state. There is no shape/icon for `kind`, no accent for `language`, no `exported` cue, no `is_entrypoint` marker, and no size/weight by `fan_in`/`fan_out`. The spec's central "encode node meaning visually" goal and the non-color-cue accessibility rule are unmet.
          +*Remediation:* map `kind`→shape/icon, `fan_in`→radius, `exported`/`is_entrypoint`→border/marker; add a legend.
          +
          +**F-02 — Edge `relation` and direction are not shown (004a AC-3).**
          +`dashboard-shell.ts:350,370-371` draws all edges as identical thin grey lines. `relation` is dropped from the link mapping for styling, and there is no arrowhead/`marker-end`, so caller→callee / importer→imported direction is invisible.
          +*Remediation:* style by `relation` (color/dash) plus an SVG `marker-end` arrow.
          +
          +**F-03 — No filtering controls (004a AC-4).**
          +The graph pane (`dashboard-shell.ts:138-146`) has only impact buttons. There is no filter by layer (`layerOf` heuristic not ported), node kind, or edge relation. "Filter and focus" is a core 004a goal.
          +*Remediation:* add layer/kind/relation toggles driving the render set.
          +
          +**F-04 — No search box (004a AC-5).**
          +No substring search/highlight over node `id`/`label`, so VFS `find` parity is absent.
          +*Remediation:* add a search input filtering/highlighting nodes with the documented ranking.
          +
          +**F-07 — Full force-simulation rebuild on every highlight/impact update (004b AC-8, detrimental pattern).**
          +`graphHighlight` and `impact` handlers (`dashboard-shell.ts:447-450, 440-446`) call `renderGraph()`, which does `svg.selectAll("*").remove()` and constructs a brand-new `d3.forceSimulation` with `forceCenter` (`:324-326, 365-368`). After every debounced cursor move the layout is recomputed and the canvas recenters/repositions all nodes. This directly violates 004b AC-8 ("never recenters the canvas on every keystroke") and the 004a "responsive / never appears frozen" rule, and degrades sharply at the 350-node cap.
          +*Remediation:* separate render from re-style; update node `fill`/attributes in place on highlight/impact without re-running the simulation or re-centering.
          +
          +### Medium
          +
          +**F-05 — No in-context Build/Refresh action routing to PRD-003b (004a AC-7, 004c AC-7, index AC-2).**
          +Empty/new-file/stale states show explanatory text (`dashboard-shell.ts:333`, `impact-overlay.ts:119`) but the only Build affordance is `btn-graph-build` in the Settings pane (`dashboard-shell.ts:122`). The ACs call for a Build action presented where the developer is (the graph/impact context).
          +*Remediation:* render a Build button in the empty/stale/no-node states that posts `buildGraph`.
          +
          +**F-06 — Malformed snapshot collapses into the generic no-graph message (004a AC-8, index AC-9).**
          +`resolveSnapshot` returns `null` for non-array `nodes`/`links` (`data-bridge.ts:129,137-139`) — never-throw holds — but the webview then shows the same "No graph snapshot yet" text (`dashboard-shell.ts:332-334`). The spec wants a distinct "snapshot looks corrupt; rebuild it" state so a corrupt build is not misread as "no build."
          +*Remediation:* distinguish null-because-absent from null-because-corrupt and message accordingly.
          +
          +**F-08 — Impact dependents not gradated by depth (004c AC-3).**
          +All dependents get a single color (`dashboard-shell.ts:360`) although `depth` is present per entry (`impact-overlay.ts:77`). Directly-affected vs distant dependents are indistinguishable.
          +*Remediation:* map `depth` to color intensity / radius / distance.
          +
          +**F-10 — Missing "no resolved dependents (not proof it is unused)" state (004c AC-5).**
          +When origins resolve but yield zero dependents, `computeImpactOverlay` returns the generic caveat (`impact-overlay.ts:123-131`); the specific honesty message required by AC-5 is never produced or shown.
          +*Remediation:* emit and display the zero-dependent honesty string when `originNodeIds.length>0 && dependents.length===0`.
          +
          +**F-11 — True dependent total not surfaced when capped (004c AC-6).**
          +`totalDependents`/`capped` are computed correctly (`impact-overlay.ts:82-87`) but the webview impact handler (`dashboard-shell.ts:440-445`) never displays them ("... and N more"). Caps are honored; the disclosure is missing.
          +*Remediation:* render `"capped at 80 of "` in the impact meta/caveat.
          +
          +**F-13 — Impact overlay does not update on working-tree changes (004c AC-8).**
          +Impact is computed only on the "Show change impact" click (`DashboardPanel.ts:257-262`). There is no `FileSystemWatcher`/git polling, no debounce, no live refresh. The "keep the overlay live" goal is unmet.
          +*Remediation:* watch the working tree (debounced) and recompute, or document the manual-only behavior as a scoped decision in the PRD.
          +
          +**F-14 — Dependent provenance (`via`) not shown on inspection (004c AC-9).**
          +`via {rel, from}` is carried in the payload (`impact-overlay.ts:66,77`) but the canvas only sets a title of `label + source_file:source_location` (`dashboard-shell.ts:381`); impact provenance is never surfaced.
          +*Remediation:* include `via` in the node tooltip when a node is in the impact set.
          +
          +**F-15 — Canvas renders the unvalidated raw snapshot while click/sync use a separately validated snapshot (correctness/divergence).**
          +The drawn graph uses `data.graph.snapshot` straight from the envelope (`dashboard-shell.ts:420`), bypassing the validating `parseGraphSnapshot`/`loadGraphSnapshotFromEnvelope` (`snapshot-loader.ts:15-90`) that builds the extension-side `this.snapshot` used for click/impact/sync (`DashboardPanel.ts:293-296`). If the loader drops nodes (missing required fields) the canvas can draw nodes the extension cannot resolve, so a click silently no-ops (`DashboardPanel.ts:265-266`).
          +*Remediation:* render from a single validated source (post the parsed snapshot to the webview, or validate client-side identically).
          +
          +### Low
          +
          +**F-09 — Staleness only detected past EOF (004b AC-6).**
          +`editor-sync.ts:68` sets `stale` only when `startLine > lineCount`. A symbol that shifted within a still-long-enough file lands on the old line with no cue. There is also no graph-pane "graph is N behind HEAD" banner (a 004a/b "honest states" expectation).
          +*Remediation:* add a snapshot-vs-HEAD staleness banner; optionally fuzzy-match shifted lines.
          +
          +**F-12 — Displayed counts are post-LOD/post-filter, not the resolver's reported counts (004a AC-1).**
          +`dashboard-shell.ts:389-390` reports `nodes.length` (capped) and `validLinks.length` (edges with both endpoints in the rendered set), not `data.graph.nodeCount`/`edgeCount` from `resolveSnapshot` (`data-bridge.ts:133-134`). The LOD note discloses reduction, but unresolved-endpoint edges are silently dropped even below the cap.
          +*Remediation:* show resolver totals alongside the rendered/visible counts.
          +
          +**F-16 — No empty-graph-specific message (004a "honest states").**
          +A zero-node snapshot shows the generic "No graph snapshot yet" (`dashboard-shell.ts:332-334`) rather than the spec's "No symbols found in this snapshot."
          +*Remediation:* branch the zero-node case to a distinct message.
          +
          +---
          +
          +## 5. What works well
          +
          +- **004b navigation is solid:** click-to-open with full range reveal, missing-file and stale-EOF handling, deterministic smallest-enclosing reverse sync, debounced and non-focus-stealing (`editor-sync.ts`).
          +- **Reverse-BFS impact engine** faithfully mirrors the text endpoint's reverse adjacency, depth grouping, `viaOf` provenance, and `IMPACT_CAP`/`MAX_DEPTH` caps (`impact-overlay.ts:38-88`).
          +- **LOD and in-place refresh:** the 350-node `fan_in` cap with disclosure (004a AC-6) and the build→`pushDashboardData` in-place refresh (index AC-7) are correctly implemented.
          +- **Never-throw posture** holds across the loaders (`snapshot-loader.ts`, `data-bridge.ts:137-139`) and git access (`impact-overlay.ts:33-35`).
          +- **No secret leakage** observed in the graph, highlight, or impact payloads (004a AC-10 / 004b AC-9 / 004c AC-10) at the structural level.
          +
          +---
          +
          +## 6. Recommended priority order
          +
          +1. F-01, F-02, F-03, F-04 — bring 004a up to its acceptance bar (encoding + filtering/search).
          +2. F-07 + F-15 — fix the re-render thrash and single-source the rendered snapshot.
          +3. F-13, F-08, F-10, F-11, F-14 — complete 004c (live update, depth gradation, honesty messages, provenance).
          +4. F-05, F-06, F-09, F-12, F-16 — honest-state polish and in-context Build actions.
          +5. Run `security-guardian` for a full secret/PII pass before promotion.
          +
          +---
          +
          +*Report generated by quality-guardian. Findings cite `file:line`. No code or PRD files were modified.*
          +
          +---
          +
          +## Remediation (2026-06-13)
          +
          +- **F-01..F-04:** Node shape/radius/color encoding, edge relation styling + arrows, layer filters, search highlight (`dashboard-shell.ts`).
          +- **F-05/F-06/F-16:** Empty/corrupt banners and inline Build actions; distinct corrupt snapshot state via `graphCorrupt`.
          +- **F-07/F-15:** `updateGraphStyles()` for highlight/impact without full simulation rebuild; filter changes use controlled rebuild.
          +- **F-08/F-10/F-11/F-14:** Impact depth colors, zero-dependent caveat, capped totals in impact panel, `via` provenance on inspect.
          +- **F-09/F-12:** Stale snapshot banner with commit SHA; meta shows rendered vs total counts.
          +- **F-13:** Debounced workspace file watcher triggers impact refresh (`DashboardPanel.ts`).
          +
          +Security pass: `library/qa/cursor-extension/2026-06-13-security-audit.md`.
          +
          +**Updated verdict:** COMPLETE
          diff --git a/library/requirements/backlog/prd-005-cursor-skillify-bridge/qa/2026-06-13-qa-report.md b/library/requirements/backlog/prd-005-cursor-skillify-bridge/qa/2026-06-13-qa-report.md
          new file mode 100644
          index 00000000..0d7c282b
          --- /dev/null
          +++ b/library/requirements/backlog/prd-005-cursor-skillify-bridge/qa/2026-06-13-qa-report.md
          @@ -0,0 +1,337 @@
          +# QA Report: PRD-005 Cursor-Native Skillify & Rules Bridge
          +
          +> **Date:** 2026-06-13
          +> **Auditor:** quality-guardian
          +> **Branch:** `feature/cursor-extension-dev` (worktree `/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`)
          +> **Plan document:** `library/requirements/backlog/prd-005-cursor-skillify-bridge/` (index + prd-005a/b/c)
          +> **Verdict:** COMPLETE (remediated 2026-06-13)
          +
          +---
          +
          +## Summary
          +
          +The skillify path bridge (PRD-005a) is the strongest leg: the canonical `detectAgentSkillsRoots` engine was extended to include Cursor's global root, so the SessionStart auto-pull and CLI `unpull` reach `~/.cursor/skills-cursor/` through the existing fan-out and manifest machinery, and the status bar is wired to a per-skill sync result. The Team Rules pane (PRD-005b) renders, adds, edits, and completes rules over the real CLI engine. However, the Interactive Skill Promoter (PRD-005c) is materially broken: one-click promote never publishes the skill to the org `skills` table (`--scope team` is silently ignored by the CLI `promote` path), yet the pane reports `"promoted to team"` anyway, reproducing the exact false-green the module exists to kill. The promoter also lists pulled team skills rather than locally mined skills and will fail to locate most listed skills on disk. Combined with missing inline rule validation, a non-functional status filter, an unhonored auto-pull opt-out on the poller/dashboard sync paths, and a parallel skill-sync writer that duplicates the canonical machinery, the implementation does not yet satisfy its acceptance criteria.
          +
          +---
          +
          +## Scorecard
          +
          +| Axis | Status | Notes |
          +|---|---|---|
          +| **Completeness** | FAIL | PRD-005c team-publish step (AC-3) absent; PRD-005b status filter (AC-6) and inline validation (AC-5) absent. |
          +| **Correctness** | FAIL | Promoter lists pulled skills not mined skills and strips `--author`, so promote generally fails or no-ops; scope badge shows install location, not team-share state. |
          +| **Alignment** | PARTIAL | PRD-005a rides the canonical engine as designed; PRD-005c introduces a false-green and a parallel writer that contradicts the "one engine, one source of truth" cross-cutting rule. |
          +| **Gaps** | FAIL | Auto-pull opt-out not honored on poll/dashboard sync; project-root Cursor links from `syncSkillsToCursor` are not recorded in the manifest (not reversible by `unpull`). |
          +| **Detrimental Patterns** | WARN | Parallel reimplementation of `fanOutSymlinks`/manifest in the extension; dead `projectRoot` param in `detectAgentSkillsRoots`; CLI-stdout regex parsing of rules. |
          +
          +---
          +
          +## Critical Issues (must fix)
          +
          +### C1. One-click promote never shares the skill with the team, but reports that it did (PRD-005c AC-3, AC-5)
          +
          +The promoter handler shells `skillify promote  --scope team`:
          +
          +```216:233:harnesses/cursor/extension/src/webview/DashboardPanel.ts
          +        case "promoteSkill":
          +          if (msg.dirName) {
          +            const skillName = msg.dirName.replace(/--[^/]+$/, "");
          +            const publishResult = await runHivemindCli(
          +              ["skillify", "promote", skillName, "--scope", "team"],
          +              workspaceRoot(),
          +            );
          +            this.post({
          +              type: "actionResult",
          +              target: "skillPromote",
          +              ok: publishResult.ok,
          +              message: publishResult.ok
          +                ? `Skill "${skillName}" promoted to team. Teammates will pull it on their next session.`
          +                : publishResult.stderr || "Promotion failed.",
          +            });
          +```
          +
          +But the CLI `promote` subcommand ignores everything after the skill name and only performs a project to global `renameSync` on local disk; it never publishes to the org `skills` table:
          +
          +```349:349:src/commands/skillify.ts
          +  if (sub === "promote") { promoteSkill(args[1] ?? "", process.cwd()); return; }
          +```
          +
          +```122:137:src/commands/skillify.ts
          +function promoteSkill(name: string, cwd: string): void {
          +  if (!name) { console.error("Usage: hivemind skillify promote "); process.exit(1); }
          +  const projectPath = join(cwd, ".claude", "skills", name);
          +  const globalPath = join(homedir(), ".claude", "skills", name);
          +  ...
          +  renameSync(projectPath, globalPath);
          +  console.log(`Promoted '${name}' from ${projectPath} → ${globalPath}.`);
          +}
          +```
          +
          +`--scope team` is a no-op (the `promote` dispatch only reads `args[1]`; there is no scope-promotion or `skill-org-publish` call). PRD-005c AC-3 requires the skill be "shared at `team` scope on the org table so teammates pull it next session," and AC-5 requires the pane to "never overstate reach." The success message asserts team reach that did not happen. This is the module's headline false-green failure mode (`prd-005c.md:124-130`, index `Honesty over optimism`).
          +
          +### C2. The promoter lists pulled team skills, not locally mined skills, and will fail to promote them (PRD-005c AC-1)
          +
          +`listLocalSkillsForPromoter` enumerates only directories whose name contains `--` (the `--` convention used for *pulled* skills), via `listCanonicalSkillDirs`:
          +
          +```82:92:harnesses/cursor/extension/src/bridge/skill-sync.ts
          +function listCanonicalSkillDirs(skillsRoot: string): string[] {
          +  if (!existsSync(skillsRoot)) return [];
          +  return readdirSync(skillsRoot).filter((name) => {
          +    if (!name.includes("--")) return false;
          +    ...
          +```
          +
          +```226:240:harnesses/cursor/extension/src/bridge/skill-sync.ts
          +export function listLocalSkillsForPromoter(): Array<{ dirName: string; scope: "global" | "project"; path: string }> {
          +  ...
          +  for (const dirName of listCanonicalSkillDirs(globalRoot)) {
          +    out.push({ dirName, scope: "global", path: join(globalRoot, dirName) });
          +  }
          +```
          +
          +Locally mined skills default to `me`/`project` and are written as `/.claude/skills/` *without* an `--author` suffix (`src/skillify/scope-config.ts:44`, `prd-005c.md:25-26`), so they are filtered out, while teammates' pulled skills (which the developer cannot meaningfully "promote") are listed. PRD-005c AC-1 requires "their locally mined skills ... drawn from the same skillify state the CLI reports (`skillsGenerated[]`)." Compounding this, the handler strips the `--author` suffix before calling `promote` (C1), so for a listed `foo--alice` it runs `skillify promote foo`, which looks for `/.claude/skills/foo/SKILL.md` and exits with "not found" (`src/commands/skillify.ts:126-128`). The pane therefore lists the wrong skills and cannot successfully promote the ones it lists.
          +
          +---
          +
          +## High Issues (should fix)
          +
          +### H1. Scope/share badge reflects install location, not team-share state (PRD-005c AC-2)
          +
          +The pane renders `s.scope`, which is hardcoded to `"global"` or `"project"` based on which `.claude/skills` directory the dir was found in:
          +
          +```307:312:harnesses/cursor/extension/src/webview/html/dashboard-shell.ts
          +      root.innerHTML = skills.map(s =>
          +        '
          ' + + '' + esc(s.label) + ' ' + esc(s.scope) + '' + +``` + +AC-2 requires a badge that distinguishes "local-only (`me` / project)" from "already shared with the team (`team` / global)", "derived from real scope/install state" (`me|team` from `scope-config`, plus org-table presence). The implementation never reads `scope-config` or the org table, so a `global` install is shown as `global` even when it is not shared with the team. The badge is misleading rather than truthful (`prd-005c.md:91-99`). + +### H2. Auto-pull opt-out is not honored on the poller and dashboard sync paths (PRD-005a AC-9, index AC-2) + +`runAutoSyncOnActivation` correctly short-circuits on `HIVEMIND_AUTOPULL_DISABLED=1`: + +```10:13:harnesses/cursor/extension/src/bridge/auto-sync.ts + if (process.env.HIVEMIND_AUTOPULL_DISABLED === "1") { + logSafe("Auto skill sync skipped (HIVEMIND_AUTOPULL_DISABLED=1)."); + return; + } +``` + +But the status-bar poller calls `syncSkillsToCursor` on every poll with no opt-out check, performing symlink writes: + +```41:46:harnesses/cursor/extension/src/statusbar/poller.ts + let skillSync; + try { + skillSync = syncSkillsToCursor(projectRoot); + } catch { + /* best-effort; never block the poll */ + } +``` + +The dashboard `syncSkills` action and `pushSettings` also call `syncSkillsToCursor`/`backfillCursorLinks` unconditionally (`DashboardPanel.ts:167-178,305`). PRD-005a AC-9 requires that with the opt-out set "neither the pull nor the Cursor sync runs." Here the sync still writes to Cursor's directories on every poll and on dashboard open. + +--- + +## Medium Issues + +### M1. Inline rule validation (2000-char cap, no-newlines) is not enforced before write (PRD-005b AC-5) + +The webview submits any non-empty trimmed text with no length or newline check: + +```187:190:harnesses/cursor/extension/src/webview/html/dashboard-shell.ts + document.getElementById("btn-rule-add").addEventListener("click", () => { + const text = document.getElementById("rule-text").value.trim(); + if (text) post("rulesAdd", { text }); + }); +``` + +The edit form (`dashboard-shell.ts:291-294`) is likewise unchecked. AC-5 requires the pane to "block the write and show an inline error matching the engine's contract" for `>2000` chars or any newline, *before* attempting the write (`prd-005b.md:95-113`). Today the engine raises and the error only surfaces as a CLI failure after the round-trip (and `` already strips newlines, so the newline contract is never even tested for the developer). + +### M2. Status filter (active/done/all) is not implemented (PRD-005b AC-6) + +`pushRules` is hardcoded to active and the `rulesList` message only re-pushes the same query; there is no UI control or message to switch status: + +```322:326:harnesses/cursor/extension/src/webview/DashboardPanel.ts + private async pushRules(): Promise { + const result = await runHivemindCli(["rules", "list", "--status", "active", "--limit", "25"], workspaceRoot()); + const rules = result.ok ? parseRulesList(result.stdout) : []; + this.post({ type: "rules", rules }); + } +``` + +AC-6 requires the pane to reflect active/done/all, mirroring the CLI `--status`. The rules pane markup (`dashboard-shell.ts:147-153`) has no filter control. Consequently AC-4's "appears under the done filter" assertion is also unverifiable in-UI. + +### M3. Rule list cap is 25, not the injection limit of 10 (PRD-005b AC-1) + +`pushRules` passes `--limit 25` (above). AC-1 requires the list be "capped consistently with the SessionStart injection limit," which is 10 (`src/rules/read.ts:36-38,83`; `prd-005b.md:162`). Showing 25 means the developer sees rules that are not actually being injected, contradicting "a developer sees what the agent actually sees." + +### M4. Conflicting path is never named and partial conflicts do not drive a non-green state (PRD-005a AC-5, AC-3 index) + +On a real (non-symlink) file at a Cursor target, the local `fanOutSymlinks` silently `continue`s without capturing the path: + +```53:54:harnesses/cursor/extension/src/bridge/skill-sync.ts + if (existing) { + if (!existing.isSymbolicLink()) continue; +``` + +`syncSkillsToCursor` then classifies a skill that reached *some but not all* roots as `"skipped"` with a generic reason, reserving `"errored"` only for total failure: + +```176:191:harnesses/cursor/extension/src/bridge/skill-sync.ts + if (links.length < roots.length) { + skipped++; + results.push({ skillName: dirName, status: "skipped", path: links[0], reason: `Synced to ${links.length}/${roots.length} Cursor roots` }); + } else { + synced++; + ... +``` + +The status bar only turns `degraded` when `erroredCount > 0` (`statusbar/indicator.ts:22`). So a real file blocking the project root while the global root succeeds is reported as `skipped`, leaving the bar green. PRD-005a AC-5 requires the affected skill be "reported as not reaching Cursor with the conflicting path named," and AC-3/AC-7 require a non-green state when skills cannot reach Cursor (`prd-005a.md:136-143,165-167`). + +### M5. Project-root Cursor links created by `syncSkillsToCursor` are not recorded in the manifest, so `unpull` cannot reverse them (PRD-005a AC-8) + +`syncSkillsToCursor` creates symlinks but never records them in `pulled.json` (only `backfillCursorLinks` calls `mergeSymlinks`). It also fans every *global* canonical skill into the *project* Cursor root: + +```140:166:harnesses/cursor/extension/src/bridge/skill-sync.ts +export function syncSkillsToCursor(projectRoot?: string): SkillSyncState { + const skillsRoot = canonicalSkillsRoot(); + const roots = detectCursorSkillsRoots(projectRoot); + ... + for (const dirName of dirs) { + const canonicalDir = join(skillsRoot, dirName); + const links = fanOutSymlinks(canonicalDir, dirName, roots); +``` + +Global links are recorded by the canonical CLI path (mechanism via `agent-roots.ts`), so those are reversible. But project-root links written here are not in any manifest entry, so `unpull` (manifest-driven, `src/skillify/manifest.ts:215-222`) will leave them dangling. AC-8 requires Cursor links be removed "through the same manifest-driven unlink pass as the Codex/Hermes/pi links." + +### M6. Parallel skill-sync writer duplicates the canonical machinery and leaves a dead parameter (cross-cutting "one engine, one source of truth") + +The index and PRD-005a mandate reuse of the existing fan-out/manifest "rather than a parallel writer" (`index.md:48`, `prd-005a.md:15,103`). The chosen approach split into two writers: (a) `src/skillify/agent-roots.ts` correctly adds Cursor so the CLI/SessionStart pull serves the global root; (b) `harnesses/cursor/extension/src/bridge/skill-sync.ts` reimplements `fanOutSymlinks`, manifest load/write, and backfill independently. Because no CLI caller passes `projectRoot` to `detectAgentSkillsRoots`, the new project branch is dead in production: + +```50:75:src/skillify/agent-roots.ts +function resolveDetected(home: string, projectRoot?: string): string[] { + ... + if (cursorInstalled) { + out.push(join(home, ".cursor", "skills-cursor")); + if (projectRoot) { + out.push(join(projectRoot, ".cursor", "skills")); + } + } +``` + +Callers `detectAgentSkillsRoots(root)` and `detectAgentSkillsRoots(installRoot)` (`src/skillify/pull.ts:286,584`) never pass `projectRoot`, so the project Cursor root is served only by the extension's duplicate `detectCursorSkillsRoots` (`skill-sync.ts:36-41`). Two detectors and two fan-out implementations now define Cursor's roots, which is the divergence the cross-cutting rule warns against. + +--- + +## Low Issues / Suggestions + +### L1. Rules list parsing depends on exact CLI stdout format + +`parseRulesList` regex-matches `[active] v ` (`DashboardPanel.ts:71-85`) against `formatListRow` output (`src/commands/rules.ts:133-143`). This works today but couples the pane to CLI presentation; a future format tweak silently empties the pane. PRD-005b envisioned reading "the same `listRules` reader" (`prd-005b.md:61`); calling the reader directly (as the extension already does for skills) would be more robust. + +### L2. Not-logged-in guidance is not surfaced as a dedicated pane state (PRD-005b AC-8, PRD-005c AC-7) + +Login failures bubble up only as raw CLI `stderr` in an `actionResult` toast. The PRDs ask the panes to "surface the same login guidance the CLI gives" rather than a generic failure (`prd-005b.md:151`, `prd-005c.md:139`). Empty states exist (`dashboard-shell.ts:264,304`) but a logged-out developer sees "No active rules" / "No local skills found", not a login prompt. + +### L3. Extension activation re-runs the full pull with a 5-minute timeout + +`runAutoSyncOnActivation` shells `skillify pull --all-users --to global` with `timeout: 300_000` (`data-bridge.ts:200`), duplicating the SessionStart auto-pull. It is fire-and-forget (`void`, `extension.ts:46`) so activation is not blocked, but the budget does not match the SessionStart `DEFAULT_TIMEOUT_MS` the PRD references (`prd-005a.md:132`). Idempotent, so harmless, but redundant. + +--- + +## Plan Item Traceability + +### Module-level acceptance criteria (index) + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Pulled skills present in Cursor's active dirs, no manual action | PARTIAL | Global root served via `agent-roots.ts:69-73` + SessionStart pull; project root only via extension parallel writer. | +| AC-2 | Bridge rides SessionStart auto-pull, bounded, swallow-all | PARTIAL | CLI path inherits the contract; extension activation pull uses a 5-min timeout (L3); opt-out not honored on poll/dashboard (H2). | +| AC-3 | Unsynced skills reported; status bar non-green | PARTIAL | Status bar wired (`indicator.ts:22,51-52`); but partial conflicts are `skipped` and stay green, path not named (M4). | +| AC-4 | Rules render from `listRules`, newest-first, capped at injection limit | PARTIAL | Renders newest-first via CLI; capped at 25 not 10 (M3); via stdout regex not direct reader (L1). | +| AC-5 | Add/edit/complete via engine, no CLI | MET | `rulesAdd/rulesEdit/rulesDone` → `rules add/edit/done` (`DashboardPanel.ts:183-215`). | +| AC-6 | Locally mined skills shown; shared vs local distinguished | FAIL | Lists pulled (`--author`) skills not mined skills (C2); badge shows install loc not share state (H1). | +| AC-7 | One-click promote shares with team; pane reflects new state | FAIL | Org publish never happens; false "promoted to team" message (C1). | +| AC-8 | Fresh install renders coherent empty states | MET | Empty states for rules/skills/sessions (`dashboard-shell.ts:264,304,249`). | +| AC-9 | No token/API key in payloads or logs | MET | Payloads carry names/text/scope only; no secret serialized (DashboardPanel/data-bridge reviewed). | + +### PRD-005a: Skillify Path Bridge + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Global pull → `~/.cursor/skills-cursor/--/` | MET | `agent-roots.ts:69-70`; `pull.ts:584` fans out for global installs. | +| AC-2 | Project pull → `/.cursor/skills/`; never global | PARTIAL | CLI project installs fan out `[]` (`pull.ts:583-585`); only extension `backfillCursorLinks` handles project links via manifest. | +| AC-3 | Rides SessionStart auto-pull, bounded, swallow-all | MET | Shared `detectAgentSkillsRoots` flows through `auto-pull.ts → runPull`; extension path try/catch-wrapped. | +| AC-4 | Backfill links for pre-existing pulls without version bump | MET | `backfillSymlinks` (`pull.ts:282-307`) + extension `backfillCursorLinks` (`skill-sync.ts:204-223`). | +| AC-5 | Real file at target: not overwritten, reported with path named | FAIL | Silent `continue` (`skill-sync.ts:54`); path never captured; classified `skipped` (M4). | +| AC-6 | Idempotent when link already correct | MET | `readlinkSync === canonicalDir` short-circuit (`skill-sync.ts:61-64`); CLI `sameSorted` (`pull.ts:291`). | +| AC-7 | Status bar non-green when skills cannot reach Cursor | PARTIAL | Triggers only on total `erroredCount>0`; partial conflicts stay green (M4). | +| AC-8 | `unpull` removes Cursor links via manifest | PARTIAL | Global links recorded; project links from `syncSkillsToCursor` not recorded (M5). | +| AC-9 | `HIVEMIND_AUTOPULL_DISABLED=1` disables pull and sync | FAIL | Honored in `auto-sync.ts:10`; ignored by poller and dashboard sync (H2). | +| AC-10 | No token/API key in manifest, result, or log | MET | Manifest/result carry paths and names only. | + +### PRD-005b: Team Rules Manager + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Active rules newest-first, capped at injection limit | PARTIAL | Newest-first preserved; limit 25 not 10 (M3). | +| AC-2 | Add → `insertRule`, `assigned_by` = user, `team` scope | MET | `rules add --scope team` (`DashboardPanel.ts:185`); CLI accepts `--scope team` (`rules.ts:75-85,173`). | +| AC-3 | Edit appends version, preserves `rule_id`, shows bumped version | PARTIAL | `rules edit` wired (`DashboardPanel.ts:207`); version not re-rendered until refresh and bumped version not shown distinctly. | +| AC-4 | Complete appends `done` row; leaves active list | PARTIAL | `rules done` wired (`DashboardPanel.ts:196`); cannot verify "appears under done filter" (no filter, M2). | +| AC-5 | Block >2000 chars / newline inline before write | FAIL | No client validation (M1). | +| AC-6 | Status filter active/done/all | FAIL | Hardcoded active; no filter control (M2). | +| AC-7 | Empty / no-rules-table renders coherent empty state | MET | "No active rules." (`dashboard-shell.ts:264`); CLI tolerates missing table (`rules.ts:194-201`). | +| AC-8 | Not-logged-in surfaces login guidance | PARTIAL | CLI stderr bubbled; no dedicated login state in pane (L2). | +| AC-9 | No token/API key; text + author only | MET | Parsed rule carries status/id/version/author/text only. | + +### PRD-005c: Interactive Skill Promoter + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Mined skills as named rows from skillify state | FAIL | Lists pulled `--author` dirs, not mined skills (C2). | +| AC-2 | Badge: local-only (`me`/project) vs shared (`team`/global) | FAIL | Badge shows install location, never reads `me\|team` scope or org table (H1). | +| AC-3 | Promote moves project→global AND shares at `team` on org table | FAIL | `--scope team` ignored; only disk move runs; no org publish (C1). | +| AC-4 | Global name collision surfaced, not overwritten | PARTIAL | CLI refuses + prints stderr (`skillify.ts:130-133`), surfaced as toast; but most listed skills error "not found" first (C2). | +| AC-5 | Reflect true resulting state; never overstate reach | FAIL | Always reports "promoted to team" on ok (C1). | +| AC-6 | Shared skill flows back via PRD-005a; pane communicates loop | FAIL | No team-share occurs (C1), so loop never closes; pane does not communicate it. | +| AC-7 | Not-logged-in offers login path | PARTIAL | CLI stderr only; no dedicated login state (L2). | +| AC-8 | No mined skills renders coherent empty state | MET | "No local skills found." (`dashboard-shell.ts:304`). | +| AC-9 | No token/API key in payload or logs | MET | Skill rows carry dirName/label/scope/path only. | + +--- + +## Files Changed (PRD-005 scope) + +| File | Summary | +|---|---| +| `src/skillify/agent-roots.ts` | Adds Cursor global root (and a `projectRoot` param, unused by CLI callers) to `detectAgentSkillsRoots`; reverses the documented Cursor exclusion. Core of PRD-005a. | +| `harnesses/cursor/extension/src/bridge/skill-sync.ts` | New extension-side Cursor sync: parallel `fanOutSymlinks`, manifest load/write, `syncSkillsToCursor`, `backfillCursorLinks`, `listLocalSkillsForPromoter`. Duplicates canonical machinery (M6). | +| `harnesses/cursor/extension/src/bridge/auto-sync.ts` | Runs `skillify pull` + Cursor sync on activation; honors opt-out here only (H2). | +| `harnesses/cursor/extension/src/webview/DashboardPanel.ts` | Adds rules and skills panes, message handlers for add/edit/done rules and promote skill; promote sends no-op `--scope team` (C1) and strips `--author` (C2). | +| `harnesses/cursor/extension/src/webview/html/dashboard-shell.ts` | Rules + Skills pane markup and render functions; empty states present; no validation/filter/login-state/share-badge. | +| `harnesses/cursor/extension/src/webview/data-bridge.ts` | `runHivemindCli` (5-min timeout) and dashboard data loaders. | +| `harnesses/cursor/extension/src/statusbar/{indicator,poller}.ts` | Wires `SkillSyncState` into bar state + tooltip; poller syncs on every poll without opt-out (H2, M4). | +| `harnesses/cursor/extension/src/types/health.ts` | Adds `SkillSyncResult` / `SkillSyncState` types and `StatusSnapshot.skillSync`. | +| `tests/claude-code/skillify-agent-roots.test.ts` | Covers Cursor global + project root detection in `detectAgentSkillsRoots` (the project branch tested but never exercised by production callers, M6). | + +--- + +## Process Note (ordering) + +quality-guardian runs after security-guardian. Security pass recorded at `library/qa/cursor-extension/2026-06-13-security-audit.md` (PASS). + +--- + +## Remediation (2026-06-13) + +- **C1:** CLI `promoteSkill` publishes to org `skills` table with `--scope team`; dashboard reports success only on CLI exit 0. +- **C2:** Promoter lists mined skills via `listMinedSkillNames`; promote uses skill name without `--author` strip. +- **H1:** Share badge from SKILL.md `scope:` frontmatter, not install path. +- **H2:** `HIVEMIND_AUTOPULL_DISABLED` honored in poller, dashboard sync, and auto-sync. +- **M1-M3:** Inline rule validation, status filter, limit 10 rules. +- **M4:** Partial reach classified as `errored` with conflict path; dashboard sync summary reflects failures. +- **M5:** Project/global symlinks recorded in `pulled.json` manifest for unpull reversibility. +- **M6:** Extension mirrors canonical `agent-roots` detection inline (webpack rootDir constraint); CLI `pull.ts` passes `projectRoot` to shared detector. +- **L1-L2:** Rules via `load-rules.mjs` / `listRules`; logged-out panes for rules and skills. + +**Updated verdict:** COMPLETE diff --git a/library/requirements/completed/prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md b/library/requirements/completed/prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md new file mode 100644 index 00000000..a9528981 --- /dev/null +++ b/library/requirements/completed/prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md @@ -0,0 +1,152 @@ +# PRD-002: Cursor Extension Core & Onboarding + +> **Status:** Backlog +> **Priority:** P1 +> **Effort:** XL (> 3d) +> **Schema changes:** None + +--- + +## Overview + +Today Hivemind reaches Cursor through a hooks-only integration: a developer runs `hivemind cursor install` in a terminal, the CLI merges seven lifecycle entries into `~/.cursor/hooks.json`, and a Node bundle is copied to `~/.cursor/hivemind/bundle/` (see `src/cli/install-cursor.ts:44-124`). There is **no first-party Cursor/VS Code extension** today: no status bar, no command palette, no in-editor surface that tells a developer whether Hivemind is actually working. When something goes wrong, it goes wrong invisibly. The clearest example: the session-end wiki worker shells out to `cursor-agent --print`, and when that binary is missing from `PATH` or not logged in, the error is caught, written only to a log file, and swallowed, so every session summary silently becomes an empty placeholder (`src/hooks/cursor/wiki-worker.ts:186-188`). + +PRD-002 delivers the **Cursor Extension Core**: the first-party Cursor extension that becomes Hivemind's home inside the editor. Its job in this stage is not features-for-features'-sake. It is to make the developer's very first five minutes with Hivemind feel effortless and trustworthy, and to make the system's health continuously **visible** so failures can never again be silent. A developer installs the extension from the marketplace, and without opening a terminal or reading a README, the extension verifies prerequisites, gets them authenticated, wires the hooks for them, and shows a single honest status indicator that answers the only question that matters: "Is Hivemind capturing my work right now, yes or no?" + +This index covers the module-level vision, goals, and the three sub-features that compose it. Implementation detail lives in the sub-PRDs. + +--- + +## The problem, from the developer's chair + +A new teammate wants the "one brain for all your agents" promise. Their journey today looks like this: + +1. They read the README and learn they must run `npm i -g @deeplake/hivemind && hivemind install` in a terminal. +2. They must separately know to run `hivemind login` and complete a browser device flow. +3. They must restart Cursor for hooks to load, with no confirmation that anything is wired. +4. They have no in-editor signal that capture is on, that they are logged in, or that the background summary worker can reach `cursor-agent`. +5. If `cursor-agent` is not installed or not logged in, summaries quietly fail forever and the developer never finds out until they wonder why their "shared brain" is empty. + +Every one of these steps is a place to lose a developer. The value of PRD-002 is converting that brittle, terminal-bound, silent path into a guided, in-editor, self-verifying one. + +--- + +## Value & success themes + +| Theme | What "good" feels like for the developer | +|---|---| +| **Zero-friction onboarding** | Install the extension, answer at most one consent prompt, and Hivemind is fully wired. No terminal, no manual `hooks.json` edits, no copy-pasting commands. | +| **Always-visible truth** | A glance at the status bar always answers "is Hivemind healthy and capturing?" No guessing, no log-spelunking. | +| **No silent failures** | Every prerequisite gap, auth lapse, or worker failure surfaces as an actionable in-editor message, never a swallowed log line. | +| **Trust through transparency** | The developer can see exactly what was wired, where credentials live, and how to undo it. Security posture is legible, not hidden. | +| **Respect for existing setups** | The extension cooperates with a CLI install that already happened; it never clobbers a working configuration or duplicates hooks. | + +--- + +## Goals + +- A developer can go from "extension not installed" to "Hivemind capturing, logged in, hooks wired, status green" without ever leaving Cursor or opening a terminal. +- The extension continuously verifies four things and reflects them in one status indicator: (1) `hivemind` CLI present, (2) `cursor-agent` CLI present and logged in, (3) Hivemind login valid, (4) Cursor hooks wired and current. +- Any failure in those four checks produces a specific, actionable remediation the developer can act on with one click. +- The session-end wiki-worker class of silent failure becomes impossible to miss: a missing or logged-out `cursor-agent` is surfaced proactively, before it corrupts summaries. +- Onboarding is idempotent and re-entrant: running it twice, or running it after a prior CLI install, converges to the same healthy state without duplication or damage. + +## Non-Goals + +- **Replacing the hooks mechanism.** The extension orchestrates and verifies the existing hooks integration (`~/.cursor/hooks.json`); it does not change how capture itself works at runtime. +- **Replacing the `hivemind` CLI.** The CLI remains the source of truth for install/login/status logic. The extension is a friendly front-end that calls into and reflects CLI capabilities, not a reimplementation. +- **Rich in-editor memory UX:** searching traces, browsing summaries, viewing the codebase graph, or skill management inside the editor. Those are later stages, not Stage 2. +- **Authoring or changing authentication protocols.** The extension consumes the existing device-flow and token paths; designing new auth flows is out of scope (and any deep auth-protocol concern hands off to `auth-guardian`). +- **A VS Code (non-Cursor) general release.** The target surface for this stage is Cursor 1.7+. Broader VS Code Marketplace distribution is a later consideration. +- **Multi-agent onboarding inside the editor** (Claude Code, Codex, Hermes, pi). This extension is Cursor-scoped; cross-agent install stays with `hivemind install`. + +--- + +## Sub-features + +| Sub-PRD | Scope | Status | +|---|---|---| +| [`prd-002a-health-check`](./prd-002a-health-check.md) | Prerequisite health check (detect `hivemind` + `cursor-agent` on PATH and their login state) and zero-friction auto-wiring of `~/.cursor/hooks.json`. | Backlog | +| [`prd-002b-auth-secrets`](./prd-002b-auth-secrets.md) | Unified authentication journey (login-status detection, guided browser device-flow, secure API-key entry) and secrets handling via the OS keychain / existing `~/.deeplake/credentials.json`. | Backlog | +| [`prd-002c-status-bar`](./prd-002c-status-bar.md) | Persistent status-bar indicator reflecting overall health, plus the basic command palette surface (re-run onboarding, login/logout, view status, open logs). | Backlog | + +--- + +## The onboarding journey (module-level) + +The three sub-features compose into one continuous first-run experience. The extension owns the orchestration; each sub-PRD owns its segment. + +```mermaid +flowchart TD + start["Developer installs extension from marketplace"] --> activate["Extension activates on Cursor launch"] + activate --> health{"Prerequisites OK?
          (PRD-002a)"} + health -->|"hivemind CLI missing"| guideCli["Show one-click install guidance"] + guideCli --> health + health -->|"OK"| auth{"Logged in?
          (PRD-002b)"} + auth -->|"No"| login["Guided device-flow login
          or secure API-key entry"] + login --> auth + auth -->|"Yes"| agent{"cursor-agent present
          and logged in?
          (PRD-002a + 002b)"} + agent -->|"No"| fixAgent["Surface actionable fix
          (prevents silent summary failure)"] + fixAgent --> agent + agent -->|"Yes"| wire["Auto-wire ~/.cursor/hooks.json
          (PRD-002a)"] + wire --> restart["Prompt to reload Cursor if needed"] + restart --> green["Status bar: green / capturing
          (PRD-002c)"] + green --> done["Hivemind is the shared brain. Done."] +``` + +The defining property: **at every branch where the legacy path would fail silently, the extension instead stops, explains, and offers a fix.** The journey only reaches "done" when all four health dimensions are genuinely true. + +--- + +## Personas + +| Persona | Context | What PRD-002 gives them | +|---|---|---| +| **First-time developer (Dana)** | Joined a team that uses Hivemind; has never run the CLI. | A guided, terminal-free setup that ends in a green status bar. | +| **Existing CLI user (Marco)** | Already ran `hivemind install` last month. | The extension detects the existing healthy wiring and shows green immediately; it never duplicates or clobbers. | +| **The skeptic (Priya)** | Wants to know exactly what got installed and where her credentials live before trusting it. | Transparent status detail, a legible secrets story, and a one-click uninstall/undo. | +| **The unlucky one (Sam)** | Has `cursor-agent` installed but is logged out, so summaries were silently empty. | A proactive, specific warning that this will break summaries, with a one-click path to fix it. | + +--- + +## Acceptance criteria (module-level) + +| ID | Criterion | +|---|---| +| AC-1 | Given a developer with no prior Hivemind setup, when they install and activate the extension, then they are guided through prerequisites, authentication, and hook wiring to a "healthy / capturing" state without opening a terminal. | +| AC-2 | Given a machine where `hivemind` or `cursor-agent` is not on `PATH`, when the extension activates, then the status bar shows a non-green state and offers a specific, actionable remediation for the missing prerequisite. | +| AC-3 | Given a developer who is not logged in, when onboarding runs, then the extension initiates the browser device-flow (or secure API-key entry) and reflects success in the status bar without requiring a manual CLI command. | +| AC-4 | Given `cursor-agent` is present but logged out, when the extension checks health, then it surfaces a proactive warning that session summaries will fail, before any summary is attempted. | +| AC-5 | Given an existing healthy CLI install, when the extension activates, then it detects the existing wiring, shows green, and makes no duplicate or destructive changes to `~/.cursor/hooks.json`. | +| AC-6 | Given onboarding has completed once, when it is re-run, then the system converges to the same healthy state idempotently (no duplicated hooks, no re-prompt loop). | +| AC-7 | Given any of the four health dimensions degrades during a session, when the next health poll runs, then the status bar updates to reflect the new state within one poll interval. | + +--- + +## Cross-cutting requirements + +- **Idempotency.** All onboarding actions are safe to repeat. Hook wiring reuses the existing merge-and-dedupe behaviour (`isHivemindEntry` / `mergeHooks` in `src/cli/install-cursor.ts:62-114`) rather than blindly appending. +- **Honesty over optimism.** The status indicator must never show green unless all four checks pass. A degraded-but-running state is its own visible state, not green. +- **Actionability.** Every non-green state maps to at least one concrete next action the developer can take from inside the editor. +- **No secret leakage.** Tokens and API keys are never written to extension logs, settings JSON, or the output channel. Secrets handling defers to PRD-002b. +- **Graceful when offline.** Network-dependent checks (login validity) degrade to a clearly labelled "unknown / offline" state rather than a false red or false green. + +--- + +## Open questions + +- [ ] Should the extension bundle/trigger the `npm i -g @deeplake/hivemind` install of a missing `hivemind` CLI itself, or only detect-and-guide? (Security and permissions trade-off; PRD-002a leans detect-and-guide.) +- [ ] What is the canonical secret store on each OS, the editor's `SecretStorage` API, the OS keychain, or the existing `~/.deeplake/credentials.json` (mode `0600`)? PRD-002b proposes a precedence order; final selection is open. +- [ ] What health-poll interval balances freshness against overhead, and should polling pause when Cursor is unfocused? +- [ ] Does Cursor expose a programmatic "reload window" affordance the extension can offer post-wiring, or must the developer reload manually? + +--- + +## Related + +- [`prd-002a-health-check`](./prd-002a-health-check.md): prerequisite detection and hook auto-wiring. +- [`prd-002b-auth-secrets`](./prd-002b-auth-secrets.md): unified auth and secrets management. +- [`prd-002c-status-bar`](./prd-002c-status-bar.md): status bar and command palette. +- [`../prd-001-egress-control/prd-001-egress-control-index.md`](../prd-001-egress-control/prd-001-egress-control-index.md): sibling Stage 1 PRD; security posture context. +- [`../../../knowledge/private/standards/documentation-framework.md`](../../../knowledge/private/standards/documentation-framework.md): documentation standards this PRD conforms to. +- Source grounding: `src/cli/install-cursor.ts` (hook wiring), `src/cli/index.ts` (`runStatus`), `src/commands/auth.ts` + `src/commands/auth-creds.ts` (login + credential storage), `src/hooks/cursor/wiki-worker.ts:170-188` (silent `cursor-agent` failure), `src/utils/resolve-cli-bin.ts` (PATH resolution). diff --git a/library/requirements/completed/prd-002-cursor-extension-core/prd-002a-health-check.md b/library/requirements/completed/prd-002-cursor-extension-core/prd-002a-health-check.md new file mode 100644 index 00000000..30d1bf56 --- /dev/null +++ b/library/requirements/completed/prd-002-cursor-extension-core/prd-002a-health-check.md @@ -0,0 +1,152 @@ +# PRD-002a: Prerequisite Health Check & Hook Auto-wiring + +> **Status:** Backlog +> **Priority:** P1 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-002-cursor-extension-core-index`](./prd-002-cursor-extension-core-index.md) + +--- + +## Overview + +This sub-feature is the extension's "is everything in place?" engine. It answers, continuously and on demand, whether the local environment can actually run Hivemind in Cursor, and it removes the single biggest source of setup friction: hand-editing `~/.cursor/hooks.json`. Concretely it does two jobs. First, the **health check** detects whether the `hivemind` and `cursor-agent` CLIs are installed and resolvable on `PATH`, whether their versions are sane, and whether `cursor-agent` is logged in. Second, the **auto-wiring** step writes the seven Hivemind lifecycle hooks into `~/.cursor/hooks.json` for the developer so they never have to run a terminal command or paste JSON. + +The value here is twofold. Friction goes to near-zero: the developer does not need to know that hooks exist, where the file lives, or what seven events to wire. And a whole class of silent failure becomes loud: the health check exists precisely so that the missing-`cursor-agent` and logged-out-`cursor-agent` conditions, the conditions that today corrupt session summaries invisibly (`src/hooks/cursor/wiki-worker.ts:186-188`), are caught and shown before they cause harm. + +--- + +## Why this matters: the silent failure we are killing + +The session-end wiki worker generates a summary by shelling out to `cursor-agent --print`. The binary is resolved by `resolveCliBin("cursor-agent", "cursor-agent")`, which falls back to the literal string `"cursor-agent"` when nothing is on `PATH` (`src/utils/resolve-cli-bin.ts:29-51`). When the spawn then fails, the error is caught and only written to a log file: + +```52:52:src/hooks/cursor/wiki-worker.ts + } catch (e: any) { +``` + +```186:188:src/hooks/cursor/wiki-worker.ts + } catch (e: any) { + wlog(`cursor-agent --print failed: ${e.status ?? e.message}`); + } +``` + +The worker then finds no summary file and moves on (`src/hooks/cursor/wiki-worker.ts:228-230`). The developer sees nothing. Their "shared brain" quietly fills with empty placeholders. This sub-PRD's health check is the proactive counterpart: it inspects the same two preconditions (binary present, session logged in) up front and surfaces them, so the worker is never set up to fail in the dark. + +--- + +## Goals + +- Detect, without developer action, whether `hivemind` is installed and resolvable, and report a clear present/absent state. +- Detect whether `cursor-agent` is installed and resolvable, and whether it is logged in, and report each independently. +- Auto-wire all seven Hivemind lifecycle hooks into `~/.cursor/hooks.json` on the developer's behalf, idempotently, preserving any non-Hivemind hooks already present. +- Detect when wired hooks are stale (a newer extension/bundle exists) and offer a one-click refresh. +- Turn every failed check into a specific, actionable remediation, never a generic error. +- Re-run safely any number of times and always converge to the same healthy wiring. + +## Non-Goals + +- **Installing the `hivemind` CLI automatically.** This sub-feature detects and guides; whether the extension may run a global `npm i` is an open question owned by the index PRD. Default posture: detect-and-guide. +- **Authenticating Hivemind itself.** Hivemind login state and the login flow belong to [`prd-002b-auth-secrets`](./prd-002b-auth-secrets.md). This sub-feature only *reads* login state to compose overall health. (`cursor-agent` login is a prerequisite check here; *Hivemind* login is 002b.) +- **Rendering the status indicator.** Presentation belongs to [`prd-002c-status-bar`](./prd-002c-status-bar.md). This sub-feature produces a structured health result; 002c displays it. +- **Changing the runtime hook bundle behaviour.** The seven hooks and their handlers are defined by the existing integration (`src/cli/install-cursor.ts:44-60`); this sub-feature wires them, it does not redesign them. + +--- + +## The four health dimensions + +The health check produces a structured result over four independent dimensions. Independence matters: a developer can be logged into Hivemind but missing `cursor-agent`, and the status must say so precisely. + +| Dimension | Question | How it is determined | If it fails, the developer is told | +|---|---|---|---| +| **D1: `hivemind` CLI** | Is the `hivemind` CLI installed and resolvable? | PATH resolution (the same `which`/`where` strategy as `src/utils/resolve-cli-bin.ts:29-51`), then a version probe. | "Hivemind CLI not found. Install it to enable shared memory," with a copyable command and docs link. | +| **D2: `cursor-agent` CLI** | Is `cursor-agent` installed and resolvable? | PATH resolution; if absent, check the known install locations (mirrors `src/skillify/gate-runner.ts` cursor paths). | "`cursor-agent` not found. Session summaries cannot be generated until it is installed." | +| **D3: `cursor-agent` login** | Is `cursor-agent` logged in? | A lightweight non-mutating status probe of `cursor-agent`. | "`cursor-agent` is installed but logged out. Summaries will silently fail. Log in to fix." | +| **D4: Hooks wired & current** | Are the seven Hivemind hooks present in `~/.cursor/hooks.json` and matching the current bundle version? | Read `~/.cursor/hooks.json`, match Hivemind entries via the existing `isHivemindEntry` shape (`src/cli/install-cursor.ts:62-71`), compare against the version stamp. | "Hooks not wired" or "Hooks out of date" with a one-click "Wire / Refresh" action. | + +> D3 is the dimension that directly closes the silent-failure gap. It is checked proactively, not lazily at summary time. + +--- + +## The onboarding segment owned here + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Ext as Extension (health engine) + participant FS as "~/.cursor/hooks.json" + participant Sys as "PATH / CLIs" + + Ext->>Sys: Resolve `hivemind` (D1) + Ext->>Sys: Resolve `cursor-agent` (D2) + Ext->>Sys: Probe `cursor-agent` login (D3) + Ext->>FS: Read + match Hivemind hooks (D4) + alt Any of D1-D3 failing + Ext-->>Dev: Show specific remediation per failed dimension + else D1-D3 healthy, D4 missing or stale + Ext->>FS: Merge seven hooks idempotently (preserve foreign hooks) + FS-->>Ext: Written (or unchanged) + Ext-->>Dev: "Wired. Reload Cursor to activate." + else All healthy + Ext-->>Dev: Hand healthy result to status bar (PRD-002c) + end +``` + +--- + +## Auto-wiring requirements + +The wiring step is the friction-killer. Its correctness requirements: + +1. **Wire all seven events** exactly as the canonical integration defines them: `sessionStart`, `beforeSubmitPrompt`, `preToolUse` (with the `Shell` matcher), `postToolUse`, `afterAgentResponse`, `stop`, and `sessionEnd` (`src/cli/install-cursor.ts:44-60`). The extension must not invent its own event list; it reuses the canonical config so the editor path and CLI path can never drift. +2. **Preserve foreign hooks.** Any hook entry that is not a Hivemind entry must survive untouched. Reuse the merge semantics that filter on `isHivemindEntry` and re-append (`src/cli/install-cursor.ts:73-85`). +3. **Idempotent writes.** Re-wiring when nothing changed must not rewrite the file (preserves Cursor's hook-trust fingerprint, matching `writeJsonIfChanged` usage at `src/cli/install-cursor.ts:114`). +4. **Bundle presence is a precondition.** Wiring assumes the bundle exists at `~/.cursor/hivemind/bundle/`. If it is absent, wiring must report the missing bundle rather than writing dangling `node "...bundle/..."` commands. +5. **Reload awareness.** After a wiring change, the developer is told a Cursor reload is required for hooks to take effect (the README documents the restart requirement). If a programmatic reload affordance exists, offer it; otherwise instruct. +6. **Reversible.** The developer can undo wiring; removal must mirror the existing strip-and-clean behaviour (`src/cli/install-cursor.ts:87-99,127-147`), deleting the file only when nothing meaningful remains. + +--- + +## Remediation catalogue + +Every failed check resolves to a concrete action. This table is the contract: a non-green dimension without a remediation is a bug. + +| Failed dimension | Developer-facing message (intent) | Primary action | +|---|---|---| +| D1 missing | Hivemind CLI not found on PATH. | Copy install command + open docs. | +| D2 missing | `cursor-agent` not found; summaries disabled. | Open `cursor-agent` install guidance. | +| D3 logged out | `cursor-agent` installed but logged out; summaries will fail silently. | Trigger `cursor-agent` login guidance. | +| D4 missing | Hooks not wired. | One-click "Wire hooks". | +| D4 stale | Hooks present but out of date. | One-click "Refresh hooks". | +| Bundle absent | Hook bundle missing at `~/.cursor/hivemind/bundle/`. | Re-run extension provisioning / CLI install guidance. | + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given `hivemind` is not on `PATH`, when the health check runs, then D1 reports "missing" and a copyable install command plus docs link is offered. | +| AC-2 | Given `cursor-agent` is not on `PATH`, when the health check runs, then D2 reports "missing" and the result explicitly notes that session summaries are disabled. | +| AC-3 | Given `cursor-agent` is installed but logged out, when the health check runs, then D3 reports "logged out" and warns that summaries will fail before any summary is attempted. | +| AC-4 | Given a `hooks.json` with no Hivemind entries, when auto-wiring runs, then all seven lifecycle events are added (including the `Shell` matcher on `preToolUse`) and any pre-existing foreign hooks are preserved unchanged. | +| AC-5 | Given auto-wiring has already run, when it runs again with no version change, then `~/.cursor/hooks.json` is not rewritten (idempotent, fingerprint-preserving). | +| AC-6 | Given the wired bundle version is older than the current extension bundle, when the health check runs, then D4 reports "stale" and offers a one-click refresh. | +| AC-7 | Given the bundle is absent at `~/.cursor/hivemind/bundle/`, when auto-wiring is attempted, then it refuses to write dangling commands and reports the missing bundle. | +| AC-8 | Given a healthy environment, when the health check completes, then it emits a structured four-dimension result consumable by the status bar (PRD-002c) without performing any presentation itself. | + +--- + +## Open questions + +- [ ] What exact non-mutating probe reliably reports `cursor-agent` login state across versions without side effects? +- [ ] May the extension shell out to wire hooks via the `hivemind cursor install` code path, or should it own a parallel writer that imports the same canonical config to avoid drift? (Drift risk argues for sharing the config module.) +- [ ] Where is the bundle version stamp authoritative for the staleness comparison, and does the extension ship its own bundle or rely on the CLI-provisioned one? + +--- + +## Related + +- [`prd-002-cursor-extension-core-index`](./prd-002-cursor-extension-core-index.md): parent module. +- [`prd-002b-auth-secrets`](./prd-002b-auth-secrets.md): owns Hivemind login state that this check reads. +- [`prd-002c-status-bar`](./prd-002c-status-bar.md): consumes this check's structured result. +- Source grounding: `src/cli/install-cursor.ts:44-147` (canonical hook config, merge, strip), `src/utils/resolve-cli-bin.ts:29-51` (PATH resolution), `src/hooks/cursor/wiki-worker.ts:170-230` (the silent failure being prevented), `src/skillify/gate-runner.ts` (known `cursor-agent` install paths). diff --git a/library/requirements/backlog/prd-002-cursor-extension-core/prd-002b-auth-secrets.md b/library/requirements/completed/prd-002-cursor-extension-core/prd-002b-auth-secrets.md similarity index 100% rename from library/requirements/backlog/prd-002-cursor-extension-core/prd-002b-auth-secrets.md rename to library/requirements/completed/prd-002-cursor-extension-core/prd-002b-auth-secrets.md diff --git a/library/requirements/backlog/prd-002-cursor-extension-core/prd-002c-status-bar.md b/library/requirements/completed/prd-002-cursor-extension-core/prd-002c-status-bar.md similarity index 100% rename from library/requirements/backlog/prd-002-cursor-extension-core/prd-002c-status-bar.md rename to library/requirements/completed/prd-002-cursor-extension-core/prd-002c-status-bar.md diff --git a/library/requirements/completed/prd-002-cursor-extension-core/qa/2026-06-13-qa-report.md b/library/requirements/completed/prd-002-cursor-extension-core/qa/2026-06-13-qa-report.md new file mode 100644 index 00000000..23aec49e --- /dev/null +++ b/library/requirements/completed/prd-002-cursor-extension-core/qa/2026-06-13-qa-report.md @@ -0,0 +1,259 @@ +# QA Report: PRD-002 Cursor Extension Core & Onboarding + +> **Date:** 2026-06-13 +> **Auditor:** quality-guardian +> **Branch / worktree:** `feature/cursor-extension-dev` (`/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`) +> **Diff base:** `main` (`merge-base` 381299a) +> **Plan audited:** `prd-002-cursor-extension-core` (index + 002a, 002b, 002c) +> **Implementation:** `harnesses/cursor/extension/src/**` (+ reference parity `src/cli/install-cursor.ts`) +> **Verdict:** **COMPLETE** (remediated 2026-06-13; see Remediation section) + +--- + +## Executive summary + +The PRD-002 implementation is substantial and largely faithful to the plan. The health engine (002a), auth/secrets surface (002b), and status bar + command palette (002c) are all present, structured cleanly across the prescribed modules, and the hook-wiring config is byte-for-byte aligned with the canonical CLI source of truth (`src/cli/install-cursor.ts`). Secrets handling is genuinely careful: masked input, no token logging, `0700`/`0600` modes, host-allowlisted URL validation, and credential-shape parity with the CLI's `Credentials` interface. + +However, the audit surfaces **one High and four Medium** findings that block a clean pass: + +- **H1 (version-stamp drift):** three different version-stamp filename/path conventions exist across CLI and extension. The extension's staleness reader (`bundle/.version`) is never written by anyone, so D4 staleness is unreliable in both directions; for an **existing CLI install** the status bar can render `Not configured` instead of green, directly contradicting **index AC-5** and the "Marco" persona. +- **M1:** a revoked/expired credential *while online* is misreported as `Unknown / Offline` rather than `Logged out` (002b AC-8 / honesty rule). +- **M2:** `unwireHooks()` (undo/uninstall) is implemented but **not exposed** as a command, so the "reversible / one-click undo" requirement (002a req #6, "Priya" persona) is unreachable in-editor. +- **M3:** out-of-scope **skill-sync** errors degrade/block the `Healthy` state, contradicting 002c AC-1 and the 002c state-composition spec (states derive from D1-D4 + login only). +- **M4:** the extension can only provision the hook bundle from the **monorepo source tree**, so a marketplace/standalone install cannot auto-wire (index AC-1 / 002a friction-killer). This is tied to a PRD open question. + +**Process / ordering note:** the only security report on disk (`library/qa/security/2026-06-12-security-audit.md`) is scoped to **markdown documentation on a different branch** (`hivemind-doc-reverse-document`), not to this extension code. `security-guardian` has **not** audited this implementation for this cycle. Per quality-guardian's ordering rule this is flagged below; a code-scoped security pass is recommended before merge. + +--- + +## Scorecard + +| Axis | Rating | Notes | +|---|---|---| +| **Completeness** | 🟡 Good, with gaps | All three sub-features built; undo command (M2) and reliable staleness (H1) missing. | +| **Correctness** | 🟠 Needs work | H1 staleness + M1 offline/invalid conflation are real logic defects. | +| **Alignment with plan** | 🟡 Mostly aligned | Hook config matches canonical CLI exactly; M3 (skill-sync) and M4 (bundle source) deviate from spec/scope. | +| **Gaps** | 🟡 | Reversibility surface and marketplace-install wiring path. | +| **Detrimental patterns** | 🟢 Low | Secrets handling is strong; no destructive writes; foreign hooks preserved. | +| **Overall** | **INCOMPLETE** | 1 High + 4 Medium must be resolved (or formally deferred) before a clean pass. | + +Severity legend: **Critical** blocks ship / data-loss / security · **High** breaks a headline AC or persona · **Medium** AC gap or honesty violation, should fix · **Low** nice-to-have / edge. + +--- + +## Traceability: PRD-002 index (module-level ACs) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | No-prior-setup → guided to healthy without terminal | `extension.ts:50-64` (onboarding prompt), `statusbar/commands.ts:10-21` (`runOnboarding`), `auth/api-key.ts:29-56` | 🟡 Partial — works in monorepo; see **M4** for marketplace installs | +| AC-2 | `hivemind`/`cursor-agent` missing → non-green + remediation | `health/checker.ts:113-154`, `statusbar/indicator.ts:14-16` | ✅ Met | +| AC-3 | Not logged in → device-flow or API key, no manual CLI | `auth/device-flow.ts:109-138`, `auth/api-key.ts:8-27` | ✅ Met | +| AC-4 | `cursor-agent` logged out → proactive warning pre-summary | `health/checker.ts:156-192` (polled each cycle) | ✅ Met | +| AC-5 | Existing healthy CLI install → green, no duplicate/destructive change | `health/checker.ts:194-259`, `health/wirings.ts:30-45` | ❌ **At risk — see H1** (CLI-wired marker version ≠ extension version → false `stale` → `not_configured`) | +| AC-6 | Re-run onboarding → idempotent convergence | `utils/fs-json.ts:18-29`, `health/wirings.ts:76-95` | ✅ Met (hooks.json fingerprint preserved) | +| AC-7 | Dimension degrades → status updates within one poll | `statusbar/poller.ts:21-51` | ✅ Met | + +## Traceability: PRD-002a (health check & auto-wiring) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | `hivemind` missing → D1 "missing" + copyable cmd + docs | `health/checker.ts:113-125`, `statusbar/detail-view.ts:32-38` | ✅ Met | +| AC-2 | `cursor-agent` missing → D2 "missing" + summaries-disabled note | `health/checker.ts:135-146` | ✅ Met | +| AC-3 | `cursor-agent` logged out → D3 "logged_out" + pre-emptive warning | `health/checker.ts:156-192` | ✅ Met (probe is heuristic — see **L1**) | +| AC-4 | No Hivemind hooks → all events incl. `Shell` matcher; foreign preserved | `health/wirings.ts:30-45`, `health/checker.ts:82-92` | ✅ Met (parity with `install-cursor.ts:44-85`) | +| AC-5 | Re-wire, no change → not rewritten (fingerprint-preserving) | `utils/fs-json.ts:18-29` | ✅ Met | +| AC-6 | Wired bundle older than current → D4 "stale" + refresh | `health/checker.ts:103-111,237-248` | ❌ **H1** — stamp read from path nobody writes; unreliable both directions | +| AC-7 | Bundle absent → refuse dangling commands + report missing | `health/wirings.ts:61-80`, `health/checker.ts:272-275` | 🟡 Partial — refuses & reports, but checks **source** bundle, not dest (see **L3 / M4**) | +| AC-8 | Healthy → structured 4-dim result, no presentation | `health/checker.ts:261-290`, `types/health.ts:13-21` | ✅ Met (no `vscode` import in checker) | +| Req #6 | Reversible: developer can undo wiring | `health/wirings.ts:97-122` (`unwireHooks`) | ❌ **M2** — implemented but not registered as a command | + +## Traceability: PRD-002b (auth & secrets) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | Valid creds → "logged in" + identity, no prompt | `auth/detector.ts:44-80`, `extension.ts:50-52` | 🟡 Met online; offline downgrades to unknown (AC-8) — interacts with **M1** | +| AC-2 | No creds → both browser + API-key paths, no terminal | `auth/api-key.ts:29-56` | ✅ Met (also offers terminal option) | +| AC-3 | Browser device-flow → editor polls + auto-updates | `auth/device-flow.ts:109-138`, `statusbar/commands.ts:54-55` | ✅ Met | +| AC-4 | Valid API key → persisted via canonical store; raw key not shown | `auth/api-key.ts:8-27`, `auth/device-flow.ts:78-107` | ✅ Met (`0700`/`0600`) | +| AC-5 | Invalid API key → error excludes token value | `auth/api-key.ts:22-26` | ✅ Met | +| AC-6 | Token never in logs/output/settings/telemetry | `utils/output.ts:12-20`, `auth/device-flow.ts:131`, `auth/api-key.ts:20` | ✅ Met | +| AC-7 | Logout → cleared + states what's removed vs kept | `auth/logout.ts:6-21` | ✅ Met | +| AC-8 | Offline → "unknown/offline", not false pos/neg | `auth/detector.ts:28-70` | ❌ **M1** — invalid/revoked token *online* also maps to `unknown_offline` | +| AC-9 | `cursor-agent` logged out → shown alongside Hivemind login | `auth/detector.ts:46-49`, `statusbar/detail-view.ts:20-25` | ✅ Met | + +## Traceability: PRD-002c (status bar & command palette) + +| AC | Criterion (abbrev) | Implementing code | Status | +|---|---|---|---| +| AC-1 | All pass + login valid → "Healthy" + tooltip confirms | `statusbar/indicator.ts:24,45-55` | ❌ **M3** — skill-sync errors block `healthy` (line 22) | +| AC-2 | `cursor-agent` logged out → "Degraded" + remediation on click | `statusbar/indicator.ts:18-19`, `statusbar/detail-view.ts:20-25` | ✅ Met | +| AC-3 | Hooks not wired → "Not configured" + Run Onboarding | `statusbar/indicator.ts:13-16`, `statusbar/detail-view.ts:30` | ✅ Met | +| AC-4 | Not logged in → "Logged out" + Log In | `statusbar/indicator.ts:6`, `statusbar/detail-view.ts:28` | ✅ Met | +| AC-5 | Offline → "Unknown / Offline" | `statusbar/indicator.ts:5,34` | ✅ Met | +| AC-6 | Dimension changes → updates without reload | `statusbar/poller.ts:21-51` | ✅ Met | +| AC-7 | Palette has 6 core commands, each routed | `package.json:18-46`, `statusbar/commands.ts:49-61` | ✅ Met | +| AC-8 | Detail view / logs render no token/API key | `statusbar/detail-view.ts:8-31`, `statusbar/commands.ts:35-47` | ✅ Met | + +--- + +## Findings + +### H1 — Version-stamp filename/path drift breaks staleness and the CLI-install green state +**Severity: High** · 002a AC-6, index AC-5 · `health/checker.ts:103-111`, `health/wirings.ts:71-72`, `src/cli/util.ts:85-88` + +Three different conventions exist for the bundle version stamp: + +- CLI authoritative stamp → `~/.cursor/hivemind/.hivemind_version` + ```85:88:src/cli/util.ts + export function writeVersionStamp(dir: string, version: string): void { + ensureDir(dir); + writeFileSync(join(dir, ".hivemind_version"), version); + } + ``` +- Extension writes → `~/.cursor/hivemind/.version` (different filename) + ```71:72:harnesses/cursor/extension/src/health/wirings.ts + const version = readExtensionVersion(); + writeFileSync(join(cursorPluginDir(), ".version"), version + "\n"); + ``` +- Extension reads → `~/.cursor/hivemind/bundle/.version` (different directory) + ```103:111:harnesses/cursor/extension/src/health/checker.ts + function readBundleVersion(): string | undefined { + const stamp = join(cursorBundleDir(), ".version"); + if (!existsSync(stamp)) return undefined; + ``` + +No code path ever writes `bundle/.version`, so `readBundleVersion()` returns `undefined` and `bundleVersion` falls back to the **extension's own** `package.json` version (`0.1.0`). The D4 staleness compare (`checker.ts:237-248`) then misbehaves: + +- **Extension-wired:** marker version == extension version → **never** reports stale (AC-6 false negative). +- **CLI-wired:** marker `_hivemindManaged.version` is the CLI's `getVersion()` (≠ `0.1.0`) → **always** reports `stale`. + +Because `composeStatusBarState` folds `stale` into `not_configured` (`indicator.ts:13-16`), an **existing healthy CLI install** renders as `$(gear) Hivemind setup` instead of green — a direct contradiction of **index AC-5** and the Marco persona ("shows green immediately; never duplicates"). + +**Recommendation:** Reuse the CLI's stamp contract (`.hivemind_version` in the plugin dir) via a shared helper, or at minimum make the extension read the same path/filename it (and the CLI) writes. Verify by wiring with the CLI, then activating the extension and confirming D4 = `ok`. + +--- + +### M1 — Invalid/revoked credential while online is reported as "Unknown / Offline" +**Severity: Medium** · 002b AC-8, index "honesty over optimism" · `auth/detector.ts:28-70` + +`validateCredentialsOnline` returns `false` for *any* non-OK response or fetch failure, and `detectAuthState` maps that single `false` to `unknown_offline`: + +```60:70:harnesses/cursor/extension/src/auth/detector.ts + const online = await validateCredentialsOnline(creds); + if (!online) { + return { + state: "unknown_offline", + identity, + ... +``` + +A genuinely **revoked/expired token while the machine is online** (HTTP 401) therefore surfaces as `Unknown / Offline` (status bar `$(question)`), and the developer is never prompted to re-authenticate. This violates AC-8's intent (offline honesty must not mask a real logged-out condition) and the module's "honesty over optimism" rule. + +**Recommendation:** Distinguish transport failure (network error / `AbortSignal` timeout → `unknown_offline`) from an authenticated 401/403 response (→ `logged_out`). Only treat true connectivity failures as offline. + +--- + +### M2 — Undo/uninstall (`unwireHooks`) is implemented but unreachable +**Severity: Medium** · 002a auto-wiring req #6, index "Priya" persona ("one-click uninstall/undo") · `health/wirings.ts:97-122`, `statusbar/commands.ts:49-61`, `package.json:18-46` + +`unwireHooks()` correctly mirrors the CLI strip-and-clean behaviour, but it is **not** registered as a command and appears in neither `registerHivemindCommands` nor `package.json contributes.commands`. The palette exposes the six PRD-002c commands but no "Remove / Unwire Hooks" or uninstall affordance, so the reversibility requirement and Priya's "one-click undo" promise cannot be exercised from inside the editor. + +**Recommendation:** Add a `hivemind.unwireHooks` (or "Hivemind: Remove Hooks") command wired to `unwireHooks()` and declared in `package.json`, with the honest "what was removed vs kept" message. + +--- + +### M3 — Out-of-scope skill-sync errors block the "Healthy" state +**Severity: Medium** · 002c AC-1 and state-composition spec; module non-goals · `statusbar/indicator.ts:21-26`, `statusbar/poller.ts:41-47` + +`composeStatusBarState` returns `degraded` when `skillSync.erroredCount > 0`, *before* it can return `healthy`: + +```21:26:harnesses/cursor/extension/src/statusbar/indicator.ts + // Skill-sync failures degrade the status: team skills are not reaching the Cursor agent. + if (skillSync && skillSync.erroredCount > 0) return "degraded"; + + if (health.allHealthy && auth.state === "logged_in") return "healthy"; +``` + +Skill sync (`bridge/skill-sync.ts`) is a later-stage capability explicitly outside PRD-002's scope (index "Non-Goals": rich in-editor memory / skill management). The 002c spec states the visible state is composed from **D1-D4 + login state** only. As written, a fully healthy, logged-in developer with a single skill-sync error sees `Degraded`, contradicting 002c AC-1. + +**Recommendation:** Remove the skill-sync term from `composeStatusBarState` for this stage (keep it out of the D1-D4 + login model), or gate it behind an explicit later-stage flag. If retained, the PRD's state model must be updated by `library-guardian` first. + +--- + +### M4 — Auto-wiring can only provision the bundle from the monorepo source tree +**Severity: Medium** · index AC-1, 002a friction-killer goal; relates to a PRD open question · `health/wirings.ts:61-74`, `utils/paths.ts:38-44` + +`provisionBundle` requires the bundle to exist at `monorepoRoot()/harnesses/cursor/bundle`: + +```42:44:harnesses/cursor/extension/src/utils/paths.ts +export function hivemindCursorBundleSrc(): string { + return join(monorepoRoot(), "harnesses", "cursor", "bundle"); +} +``` + +`monorepoRoot()` is derived from `__dirname` four levels up — valid only when the extension runs from inside this repo. For a **marketplace / standalone install** (`~/.cursor/extensions/...`), that path does not exist, so `autoWireHooks` always returns `ok:false` ("Run 'npm run build' in the hivemind repo first") and the headline terminal-free wiring (index AC-1, 002a's reason for existing) cannot complete outside the dev monorepo. + +This aligns with the **open question** in 002a ("does the extension ship its own bundle or rely on the CLI-provisioned one?"), so it is partly a known design gap rather than a pure defect. It is flagged Medium because it materially limits the primary onboarding flow for the Dana persona. + +**Recommendation:** Resolve the open question with `library-guardian` and either (a) ship the bundle inside the VSIX and provision from `context.extensionUri`, or (b) when the monorepo source is absent but `~/.cursor/hivemind/bundle/` already exists (CLI-provisioned), wire against the existing bundle instead of refusing. + +--- + +### L1 — `cursor-agent` login probe relies on error-string heuristics +**Severity: Low** · 002a AC-3 (open question) · `health/checker.ts:174-191` + +D3 classifies logged-out by substring-matching the spawn error (`"not logged"`, `"login"`, `"401"`, `"ENOENT"`). `ENOENT` actually means the binary is missing (a D2 concern), and the heuristic can drift across `cursor-agent` versions. The PRD itself lists "what exact non-mutating probe reliably reports login state" as an open question, so this is acceptable for now but worth hardening (prefer exit-code + structured output over message matching). + +### L2 — `provisionBundle` re-copies the bundle on every wire +**Severity: Low** · `health/wirings.ts:69-72` + +`cpSync(..., { force: true })` runs on every `autoWireHooks` call even when `hooks.json` is unchanged. The hooks.json fingerprint is preserved (so AC-5 holds), but the bundle dir and `.version` are rewritten each time. Consider short-circuiting when already current. + +### L3 — Bundle-absent check inspects the source path, not `~/.cursor/hivemind/bundle/` +**Severity: Low** · 002a AC-7 · `health/wirings.ts:61-67` + +AC-7 is phrased around the destination (`~/.cursor/hivemind/bundle/`); the implementation checks the *source* bundle and the message references the source path. Behaviour (refuse + report) is correct, but the message and check target differ from the AC wording and overlap with M4. + +--- + +## Notes (defer to library-guardian — plan wording, not code defects) + +- **N1 — "six" vs "seven" events.** The 002a prose says "Wire all **six** events" but then lists seven (`sessionStart`, `beforeSubmitPrompt`, `preToolUse`+Shell, `postToolUse`, `afterAgentResponse`, `stop`, `sessionEnd`); 002a AC-4 also says "all six". The implementation wires **seven** and its messages say "All seven hooks wired" (`checker.ts:255`), which **correctly matches** the canonical source of truth `src/cli/install-cursor.ts:44-60`. The code is right; the PRD's count wording should be corrected by the plan's author. + +--- + +## Process / ordering flag + +quality-guardian must run **after** `security-guardian` for the same cycle. The only security report present, `library/qa/security/2026-06-12-security-audit.md`, is explicitly **"Documentation-only audit"** scoped to `library/knowledge/private/**/*.md` on the `hivemind-doc-reverse-document` worktree — it does **not** cover the `harnesses/cursor/extension` TypeScript implementation audited here. Therefore no security pass exists for this code this cycle. + +This QA report was produced because it was explicitly requested, but the ordering gap is flagged per protocol: **recommend running `security-guardian` against `harnesses/cursor/extension/src/**` (and the auth/device-flow network + credential paths in particular) before merge**, then re-running quality-guardian if security fixes alter the snapshot. + +--- + +## Recommended remediation order + +1. **H1** — unify the version-stamp filename/path (reuse `.hivemind_version` in the plugin dir). Unblocks index AC-5 + 002a AC-6. +2. **M1** — split transport failure from 401 in `detectAuthState`. +3. **M3** — remove skill-sync from `composeStatusBarState` for this stage. +4. **M2** — register the unwire/uninstall command. +5. **M4** — resolve the bundle-provisioning open question with library-guardian. +6. **L1-L3 / N1** — harden the login probe; minor cleanups; PRD wording correction. +7. Run a **code-scoped `security-guardian`** pass, then re-run quality-guardian. + +--- + +## Remediation (2026-06-13) + +All prior High/Medium findings addressed on `feature/cursor-extension-dev`: + +- **H1:** Version stamp unified to `.hivemind_version` in plugin dir (`health/checker.ts`, `health/wirings.ts`). +- **M1:** `detectAuthState` distinguishes offline transport from 401/403 (`auth/detector.ts`). +- **M2:** `hivemind.unwireHooks` registered in `statusbar/commands.ts` and `package.json`. +- **M3:** Skill sync removed from status bar health composition (`statusbar/indicator.ts`). +- **M4:** Bundle provisioning from monorepo, VSIX bundle, or existing CLI bundle (`health/wirings.ts`, `setBundledExtensionSrc` in `extension.ts`). +- **L1-L3:** Login probe hardening, bundle skip when up to date, destination-aware missing-bundle messaging. +- **N1:** PRD wording corrected to seven hooks (`prd-002a-health-check.md`, index). + +Security pass: `library/qa/cursor-extension/2026-06-13-security-audit.md` (PASS, no Medium+). + +**Updated verdict:** COMPLETE diff --git a/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md new file mode 100644 index 00000000..8b5ef31c --- /dev/null +++ b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003-cursor-extension-dashboard-index.md @@ -0,0 +1,176 @@ +# PRD-003: Cursor Extension Dashboard & Settings + +> **Status:** Backlog +> **Priority:** P1 +> **Effort:** XL (> 3d) +> **Schema changes:** None + +--- + +## Overview + +PRD-002 made Hivemind **honest** inside Cursor: a status bar that never lies, zero-friction onboarding, and a proactive end to the silent `cursor-agent` failure that quietly emptied session summaries (`src/hooks/cursor/wiki-worker.ts:186-188`). It answered the binary question "is Hivemind capturing my work right now, yes or no?" PRD-003 answers the next set of questions a developer asks once capture is trustworthy: **"What is it doing for me? What is it remembering? And how do I change how it behaves, without editing a JSON file?"** + +PRD-003 delivers the **Cursor Extension Dashboard & Settings**: a beautiful, first-party Webview that opens directly inside Cursor and becomes Hivemind's home surface beyond the status bar. It does three things. It **shows value**: live KPI cards (tokens saved, sessions captured, active memory recalls) plus a scannable list of recent sessions, so the work Hivemind does in the background finally becomes visible. It **gives control**: a graphical settings panel that manages embeddings, the codebase graph, and the active organization and workspace, replacing hand-edited config files and environment variables. And it **closes the loop**: a session summary viewer that surfaces each session's "Next Steps" and lets the developer promote them into active goals or Cursor tasks. + +Today the only graphical surface is a CLI-generated HTML file written to disk and opened in an external browser (`hivemind dashboard`, see `src/commands/dashboard.ts:36-68`). It is read-only, it lives outside the editor, and it cannot change a single setting. PRD-003 brings that surface inside Cursor, makes it interactive, and reuses the exact data layer the CLI dashboard already proved (`src/dashboard/data.ts:211-262`) so the editor and terminal always tell the same story. + +This index covers the module-level vision, goals, non-goals, and the three sub-features that compose it. Implementation detail lives in the sub-PRDs. + +--- + +## The problem, from the developer's chair + +A developer finished onboarding in PRD-002. The status bar is green. Hivemind is capturing. Now their experience flattens out: + +1. They have **no idea what they are getting**. Tokens saved, sessions captured, memory recalled, all of it happens off-screen. The promise of a "shared brain" is invisible, so it feels like nothing is happening. +2. When they do look, the numbers can read as **zero even when work happened**. The org stats endpoint is fed by a daily server rollup and cached for an hour (`src/notifications/sources/org-stats.ts:14-19,35`); a developer who looks right after a burst of work sees a stale snapshot. Worse, "tokens saved" and "memory recalls" are only ever incremented when a session actually grep'd `~/.deeplake/memory/` during an active query (`src/notifications/usage-tracker.ts:21-33`), so a quiet day genuinely reads as 0, and the developer has no way to know whether that 0 means "broken" or "nothing to recall yet." +3. To change anything, they must **leave the editor and learn CLI incantations**: `hivemind embeddings enable`, `hivemind graph build`, `hivemind org switch `, or worse, set `HIVEMIND_EMBEDDINGS=false` and edit `~/.deeplake/config.json` by hand (`src/user-config.ts:1-20`). +4. Their session summaries, the actual output of the "shared brain", are written to a remote memory table they **never see in the editor**. Each summary even ends with a curated `## Next Steps` block (locked by contract in `tests/claude-code/wiki-next-steps-contract.test.ts:11-19`), but those next steps die in storage instead of becoming the developer's next task. + +Every one of these is a place where Hivemind's value stays hidden or its controls stay hostile. PRD-003 converts that flat, opaque, CLI-bound aftermath into a visible, explainable, in-editor surface. + +--- + +## Value & success themes + +| Theme | What "good" feels like for the developer | +|---|---| +| **Visible value** | One glance at the dashboard shows what Hivemind has saved and remembered. The "shared brain" stops being a slogan and becomes a number that grows. | +| **No mystery zeros** | When a KPI reads 0 or looks stale, the dashboard explains why (no recalls yet, or a cached snapshot from N minutes ago) and offers a refresh, so a 0 never reads as "broken." | +| **Settings without JSON** | Embeddings, the codebase graph, and the active org / workspace are all togglable from a panel. No `~/.deeplake/config.json` edits, no environment variables, no remembering subcommands. | +| **Closed loop** | Session summaries and their "Next Steps" are visible in the editor and convertible into goals or Cursor tasks with one click, so the brain's output drives the developer's next action. | +| **One source of truth** | The dashboard reads and writes the same canonical artifacts the CLI uses, so the editor and terminal never disagree about stats, settings, or identity. | + +--- + +## Goals + +- A developer can open a single in-editor Webview and immediately see live KPI cards (tokens saved, sessions, active memory recalls) and a list of recent sessions, without running a CLI command or opening an external browser. +- Every KPI is **legible**: its source (org rollup vs local fallback vs empty), its freshness (how stale the cached snapshot is), and how it accumulates (only on active recalls) are visible, so a 0 or a stale value is explained rather than mysterious. +- A developer can change every common setting, embeddings on/off, codebase graph build/refresh, and active organization and workspace, from a graphical panel that writes the same canonical config the CLI writes (`src/user-config.ts:50-61`), with no manual file or environment-variable editing. +- A developer can read past session summaries inside the editor and convert any summary's "Next Steps" into active goals or Cursor tasks. +- Every settings action that requires a health change (for example enabling embeddings, wiring/rebuilding the graph, switching org) re-runs the relevant PRD-002 health check and reflects the new state in the PRD-002c status bar. +- The dashboard degrades gracefully: a fresh install with no creds, no sessions, and no graph still renders a coherent empty state with next steps, never a crash or a blank page (matching the data layer's never-throw contract, `src/dashboard/data.ts:264-288`). + +## Non-Goals + +- **Replacing the capture or summarization runtime.** PRD-003 visualizes and configures; it does not change how hooks capture sessions or how the wiki worker generates summaries (`src/hooks/cursor/wiki-worker.ts`). +- **Replacing the `hivemind` CLI as the engine.** The CLI remains the source of truth for stats computation, config persistence, graph building, and org switching. The dashboard is a graphical front-end that calls into and reflects those capabilities. +- **Re-authoring authentication or onboarding flows.** Login, secrets, prerequisite detection, and hook wiring are owned by PRD-002 (`prd-002a`, `prd-002b`, `prd-002c`). PRD-003 triggers and reflects them; it does not redesign them. +- **A full memory browser or semantic search UI.** Browsing the entire memory table, free-text searching traces, or editing summaries is a later stage. PRD-003 shows recent sessions and their summaries, not the whole corpus. +- **A rich interactive codebase-graph explorer in Settings.** PRD-003 surfaces graph build status and basic counts in Settings and can trigger a build; the interactive force-directed visualization is owned by PRD-004 (`prd-004-cursor-graph-visualizer`) in the dashboard Graph tab. +- **Changing the server-side stats rollup.** The daily org rollup and the 1-hour cache (`src/notifications/sources/org-stats.ts:14-19`) are upstream; PRD-003 explains their freshness, it does not re-architect them. +- **A VS Code (non-Cursor) release.** The target surface is Cursor 1.7+, matching PRD-002. + +--- + +## Sub-features + +| Sub-PRD | Scope | Status | +|---|---|---| +| [`prd-003a-kpi-webview`](./prd-003a-kpi-webview.md) | The Live KPI and Session History Webview: KPI cards (tokens saved, sessions, active memory recalls, skills created), a recent-sessions list, and the freshness/empty-state explanations that prevent "mystery zeros." | Backlog | +| [`prd-003b-settings-manager`](./prd-003b-settings-manager.md) | The Graphical Settings and Environment Manager: toggle embeddings, build/refresh the codebase graph, switch active organization and workspace, all writing the canonical config and re-triggering PRD-002 health checks. | Backlog | +| [`prd-003c-session-viewer`](./prd-003c-session-viewer.md) | The Session Summary and Next Steps Viewer: read a past session's summary in-editor and convert its "Next Steps" into Hivemind goals or Cursor tasks. | Backlog | + +--- + +## The dashboard journey (module-level) + +The three sub-features compose into one continuous in-editor surface. The extension owns the Webview shell and routing; each sub-PRD owns a pane. + +```mermaid +flowchart TD + open["Developer opens Hivemind dashboard
          (status bar click or command palette)"] --> health{"Healthy from PRD-002?"} + health -->|"No"| nudge["Show PRD-002 remediation inline
          (degraded / logged out / not configured)"] + nudge --> open + health -->|"Yes / Degraded"| shell["Webview opens with three panes"] + shell --> kpi["KPI + Sessions pane
          (PRD-003a)"] + shell --> settings["Settings + Environment pane
          (PRD-003b)"] + shell --> viewer["Session Summary + Next Steps pane
          (PRD-003c)"] + kpi --> explain{"KPI reads 0 or stale?"} + explain -->|"Yes"| why["Show why: no recalls yet,
          or snapshot is N min old + Refresh"] + explain -->|"No"| value["Show accumulating value"] + settings --> apply["Toggle setting -> write canonical config"] + apply --> recheck["Re-run PRD-002 health check"] + recheck --> statusbar["PRD-002c status bar updates"] + viewer --> promote["Promote a Next Step -> goal / Cursor task"] +``` + +The defining property carried over from PRD-002: **the surface never leaves the developer guessing.** Where the legacy path would show an unexplained 0 or force a CLI command, the dashboard shows the reason and the in-editor action. + +--- + +## Personas + +| Persona | Context | What PRD-003 gives them | +|---|---|---| +| **The value-seeker (Dana)** | Onboarded last week; wants to know Hivemind is worth keeping. | KPI cards that visibly accumulate tokens saved and sessions captured, with an honest explanation of how they grow. | +| **The confused-by-zero developer (Sam)** | Looks at stats after a quiet morning and sees 0 saved. | A clear "no memory recalls yet today, here is how this number grows" message and a refresh, instead of a scary 0. | +| **The tweaker (Marco)** | Wants embeddings off on his laptop and the graph rebuilt, hates editing JSON. | A settings panel with real toggles that write the same config the CLI does and reflect health immediately. | +| **The multi-org consultant (Priya)** | Works across two client organizations and several workspaces. | A graphical org/workspace switcher that updates identity, invalidates the stale stats cache, and refreshes the dashboard. | +| **The finisher (Lee)** | Ends sessions with good intentions that evaporate. | Session summaries with "Next Steps" that become real goals or Cursor tasks in one click. | + +--- + +## Acceptance criteria (module-level) + +| ID | Criterion | +|---|---| +| AC-1 | Given a logged-in developer with captured sessions, when they open the dashboard from the status bar or command palette, then a Webview opens inside Cursor showing live KPI cards and a recent-sessions list without an external browser. | +| AC-2 | Given a KPI that reads 0 or is served from a stale cache, when the dashboard renders that KPI, then it shows the reason (no recalls yet, or a snapshot of age N) and offers a refresh, so the value is explained rather than ambiguous. | +| AC-3 | Given the settings pane, when the developer toggles embeddings, builds/refreshes the graph, or switches org/workspace, then the change is persisted to the same canonical config the CLI uses and no manual JSON or environment-variable edit is required. | +| AC-4 | Given a settings change that affects health (embeddings, graph wiring, org switch), when it is applied, then the PRD-002 health check re-runs and the PRD-002c status bar reflects the new state within one poll interval. | +| AC-5 | Given a past session with a summary, when the developer opens it in the session viewer, then the summary and its "Next Steps" render in-editor. | +| AC-6 | Given a summary's "Next Steps," when the developer promotes one, then it is created as a Hivemind goal or a Cursor task, and the dashboard reflects the new open goal. | +| AC-7 | Given an org/workspace switch, when it completes, then the org stats cache scoped to the prior identity is invalidated so the dashboard does not show the previous org's numbers (mirroring the scope-key rule in `src/notifications/sources/org-stats.ts:80-100`). | +| AC-8 | Given a fresh install with no creds, no sessions, and no graph, when the dashboard opens, then it renders a coherent empty state with guidance instead of crashing or showing a blank page. | +| AC-9 | Given the dashboard is open when health or stats change, when the next refresh occurs, then the panes update to the new values without the developer reopening the Webview. | + +--- + +## How the "mystery zeros" get killed (cross-cutting) + +This is the through-line of PRD-003 and deserves a module-level statement because all three sub-features touch it. Two distinct mechanisms produce a confusing 0 today, and the dashboard must make each one legible. + +| Mechanism | Why the developer sees a confusing value | Where it lives | How PRD-003 makes it honest | +|---|---|---|---| +| **Stale cached snapshot** | Org stats come from a daily server rollup cached for 1 hour; a look right after work shows the pre-work snapshot. | `src/notifications/sources/org-stats.ts:14-19,35,114-133` | The KPI card stamps the snapshot age ("as of N minutes ago") and offers an explicit refresh that bypasses the fresh-cache short-circuit. | +| **Accumulate-only-on-recall** | Tokens saved and memory recalls increment only when a session actually grep'd `~/.deeplake/memory/`; a quiet session contributes 0. | `src/notifications/usage-tracker.ts:21-33`, `src/dashboard/data.ts:211-262` | The card distinguishes "0 because nothing recalled yet" (empty state with a one-line explanation of how it grows) from "no data source at all" (`tokensSource: "none"`, fresh-install empty state). | + +The data layer already encodes the three-way distinction the UI needs, `tokensSource` is `"org"`, `"local"`, or `"none"` (`src/dashboard/data.ts:61-81`), and PRD-003a turns that distinction into the visible explanation. + +--- + +## Cross-cutting requirements + +- **One source of truth.** The dashboard reuses `loadDashboardData` and the canonical config readers/writers (`src/dashboard/data.ts:270-288`, `src/user-config.ts:31-61`); it never maintains a competing stats cache or settings store. +- **Never-throw rendering.** Every pane has a defined empty/error state. A missing snapshot, absent creds, or unreachable org endpoint degrades to an explained empty state, matching the data layer's never-throw contract. +- **Honesty over optimism.** A KPI is never shown as a confident number when its source is stale or absent; freshness and source are always disclosed. +- **No secret leakage.** The Webview, its serialized state payload, and any logs never render tokens or API keys (defers to PRD-002b's secrets rules). Identity is shown by name/org, never by token. +- **Idempotent settings writes.** Settings changes reuse the canonical atomic writer (`writeUserConfig`, `src/user-config.ts:50-61`) and are safe to repeat; a no-op toggle does not corrupt config. +- **Health coherence.** Any setting that changes a health dimension re-runs the PRD-002a check and updates the PRD-002c status bar; the dashboard and status bar never disagree. + +--- + +## Open questions + +- [ ] Should the in-editor dashboard render as a native Cursor Webview panel, or reuse the existing self-contained HTML (`src/dashboard/render.ts`) inside a Webview host? (Webview-native gives interactivity for settings; HTML reuse is cheaper but read-only.) +- [ ] What is the right refresh model for KPIs: a manual refresh button only, a poll on the PRD-002a interval, or a refresh on Webview focus? (Balances freshness against the 1.5s org-stats fetch and the 1-hour cache.) +- [ ] When the developer refreshes KPIs explicitly, should the dashboard force-bypass the 1-hour org-stats cache, and if so, how do we avoid hammering `/me/hivemind-stats` on rapid clicks? +- [ ] Does Cursor expose a first-party "create task" API the session viewer can target for Next Steps, or should promotion default to Hivemind goals (`hivemind goal`) with Cursor tasks as a best-effort? +- [ ] Should recent-session metadata come from the local sessions artifacts or a query against the remote sessions table, given the wiki worker reads sessions remotely (`src/hooks/cursor/wiki-worker.ts:116-142`)? Latency vs completeness trade-off. +- [ ] How should the settings pane represent a graph build, which is a potentially long-running background job (`hivemind graph build`), inside a Webview, progress, completion, and failure? + +--- + +## Related + +- [`prd-003a-kpi-webview`](./prd-003a-kpi-webview.md): live KPI cards and session history. +- [`prd-003b-settings-manager`](./prd-003b-settings-manager.md): graphical settings and environment manager. +- [`prd-003c-session-viewer`](./prd-003c-session-viewer.md): session summary and Next Steps viewer. +- [`../prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md`](../prd-002-cursor-extension-core/prd-002-cursor-extension-core-index.md): the Stage 2 core this dashboard builds on (health, auth, status bar). +- [`../prd-002-cursor-extension-core/prd-002a-health-check.md`](../prd-002-cursor-extension-core/prd-002a-health-check.md): the four-dimension health result the dashboard re-triggers and reflects. +- [`../prd-002-cursor-extension-core/prd-002c-status-bar.md`](../prd-002-cursor-extension-core/prd-002c-status-bar.md): the status bar this dashboard opens from and keeps in sync. +- [`../../../knowledge/private/standards/documentation-framework.md`](../../../knowledge/private/standards/documentation-framework.md): documentation standards this PRD conforms to. +- Source grounding: `src/commands/dashboard.ts:36-68` (existing CLI dashboard surface), `src/dashboard/data.ts:61-288` (KPI + graph data layer, `tokensSource` distinction, never-throw contract), `src/notifications/sources/org-stats.ts:14-133` (1-hour cache, daily rollup, scope-key invalidation), `src/notifications/usage-tracker.ts:21-116` (accumulate-only-on-recall local stats), `src/user-config.ts:31-106` (canonical config read/write, embeddings flag), `src/cli/index.ts:399-490` (status, embeddings, graph, org/workspace dispatch), `src/notifications/sources/open-goals.ts` (goals the viewer promotes into). diff --git a/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md similarity index 100% rename from library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md rename to library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003a-kpi-webview.md diff --git a/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md new file mode 100644 index 00000000..27f28b9a --- /dev/null +++ b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003b-settings-manager.md @@ -0,0 +1,148 @@ +# PRD-003b: Graphical Settings & Environment Manager + +> **Status:** Backlog +> **Priority:** P1 +> **Effort:** L (1-3d) +> **Schema changes:** None +> **Parent:** [`prd-003-cursor-extension-dashboard-index`](./prd-003-cursor-extension-dashboard-index.md) + +--- + +## Overview + +This sub-feature replaces the most hostile part of running Hivemind: configuration. Today, changing how Hivemind behaves means leaving the editor and either remembering CLI subcommands (`hivemind embeddings enable`, `hivemind graph build`, `hivemind org switch `) or, worse, hand-editing `~/.deeplake/config.json` and juggling environment variables like `HIVEMIND_EMBEDDINGS` (`src/user-config.ts:1-20,63-102`). This pane brings every common setting into a graphical panel inside Cursor. The developer flips a toggle; the extension writes the exact same canonical config the CLI writes, then re-runs the PRD-002 health check so the change is reflected honestly in the status bar. + +The value is control without ceremony. A developer should be able to turn embeddings off on a laptop, rebuild the codebase graph after a big refactor, or switch from one client organization to another, all from a panel, all persisted to the one config the rest of Hivemind already trusts. No JSON, no environment variables, no memorized subcommands, and no risk that the editor and CLI disagree about the current configuration. + +> **Graph visualization scope:** PRD-003 surfaces graph build status, counts, and refresh controls in Settings. The interactive force-directed graph explorer lives in the dashboard Graph tab and is specified by [`prd-004-cursor-graph-visualizer`](../prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md). + +--- + +## Why this matters + +Configuration is where trust quietly erodes. Three friction points hurt developers today: + +1. **Embeddings are gated by a config flag with a legacy env-var migration.** The only persisted setting today is `embeddings.enabled`, seeded once from `HIVEMIND_EMBEDDINGS` and thereafter read from `~/.deeplake/config.json` (`src/user-config.ts:73-102`). A developer who wants embeddings off must either set an env var (read exactly once, then ignored) or edit JSON, and has no in-editor signal of the current state. +2. **The codebase graph is invisible and CLI-bound.** The graph powers the dashboard's visualization and richer context, but it only exists if the developer knew to run `hivemind graph build` (`src/cli/index.ts:462-465`, surfaced as a hint in `src/commands/dashboard.ts:238-240`). There is no in-editor way to see whether a graph exists, how old it is, or to rebuild it. +3. **Org / workspace switching is a CLI passthrough with a sharp cache edge.** `org switch` and `workspaces` exist only in the terminal (`src/cli/index.ts:486-490`), and switching org without invalidating the stats cache previously showed the *previous* org's numbers, the exact bug the cache scope-key was added to fix (`src/notifications/sources/org-stats.ts:80-100`). PRD-002b explicitly deferred org/workspace UX to a later stage; this is that stage. + +This pane makes each of these a visible, safe, one-click operation. + +--- + +## Goals + +- Present a graphical settings panel inside the dashboard Webview covering embeddings, the codebase graph, and the active organization and workspace. +- Persist every change through the canonical config writer (`writeUserConfig`, `src/user-config.ts:50-61`) and canonical CLI code paths, so the editor and CLI never diverge and no manual file or environment-variable edit is required. +- Show the current state of each setting accurately on open (embeddings enabled/disabled, graph present/age, active org/workspace), reading the same sources the CLI reads. +- Re-run the PRD-002a health check after any change that affects a health dimension, and reflect the new state in the PRD-002c status bar within one poll interval. +- Invalidate the org stats cache scoped to the prior identity on an org/workspace switch, so the KPI pane (PRD-003a) never shows the old org's numbers. +- Represent long-running operations (graph build) with clear in-progress, success, and failure states inside the Webview. + +## Non-Goals + +- **Re-implementing the underlying operations.** Embeddings install/enable/disable, graph build, and org switch already exist in the CLI (`src/cli/index.ts:472-490`). This pane invokes them; it does not reimplement their logic. +- **Authentication and login.** Logging in, secrets, and credential storage are PRD-002b. This pane assumes an authenticated identity and switches *between* orgs/workspaces; it does not create credentials. +- **Prerequisite detection and hook wiring.** Whether CLIs exist and whether hooks are wired is PRD-002a. This pane triggers a re-check after changes but does not own detection. +- **Editing arbitrary config keys.** Only the supported settings (embeddings, graph, org/workspace) are exposed. A raw JSON editor for `~/.deeplake/config.json` is out of scope. +- **Designing new config schema.** `UserConfig` currently holds only `embeddings.enabled` (`src/user-config.ts:16-20`); any new persisted setting is added through the existing reader/writer, not a new store. + +--- + +## The settings surface + +Each control maps to an existing canonical operation and a known source of truth. This table is the contract. + +| Setting | Control | Reads current state from | Writes / invokes | Health impact | +|---|---|---|---|---| +| **Embeddings** | On/off toggle | `getEmbeddingsEnabled()` (`src/user-config.ts:73-94`) | `enableEmbeddings()` / `disableEmbeddings()` (light: flip flag, kill daemon) or `installEmbeddings()` (heavy: deps + symlinks) per current install state (`src/cli/index.ts:472-481`) | Capture/wiki/grep embed paths toggle; re-check D-dimensions that depend on the daemon. | +| **Codebase graph** | Status line + "Build / Refresh" button | Snapshot resolver: `~/.hivemind/graphs//latest-commit.txt` + snapshots (`src/dashboard/data.ts:141-209`) | `hivemind graph build` (`src/cli/index.ts:462-465`) | None on PRD-002 health directly; updates the dashboard graph empty-state and KPI context. | +| **Active organization** | Dropdown / switcher | Current `creds.orgId` / `whoami` (`src/cli/index.ts:486-490`) | `hivemind org switch ` (auth passthrough) | Identity change; invalidate stats cache; re-run health to confirm workspace resolves. | +| **Active workspace** | Dropdown | `creds.workspaceId`, `workspaces` list (`src/cli/index.ts:486-490`) | `hivemind workspace` selection (auth passthrough) | Same as org: identity change + cache invalidation. | + +--- + +## Settings write flow (the safety spine) + +Every change follows the same disciplined path so the editor, the CLI, and the status bar can never drift. + +```mermaid +flowchart TD + toggle["Developer changes a setting in the panel"] --> validate{"Valid change?"} + validate -->|"No"| reject["Show inline error; leave config untouched"] + validate -->|"Yes"| invoke["Invoke canonical CLI path
          (writeUserConfig / graph build / org switch)"] + invoke --> persisted{"Persisted OK?"} + persisted -->|"No"| failstate["Show failure; config unchanged; offer retry"] + persisted -->|"Yes"| sideeffects["Apply side effects
          (kill embed daemon, invalidate stats cache, etc.)"] + sideeffects --> recheck["Re-run PRD-002a health check"] + recheck --> statusbar["PRD-002c status bar updates"] + recheck --> refreshkpi["Signal PRD-003a to refresh"] + refreshkpi --> reflect["Panel shows new current state"] +``` + +Three properties make this safe: + +1. **Canonical writes only.** Persisted config goes through `writeUserConfig`, which writes atomically via a temp file and rename (`src/user-config.ts:50-61`) and deep-merges so unrelated keys survive. The pane never writes `config.json` directly. +2. **Idempotent and re-entrant.** Re-applying the same setting is a no-op-safe operation; the canonical readers/writers already tolerate repeat calls and cache in memory (`src/user-config.ts:31-48`). +3. **Cache coherence on identity change.** Switching org or workspace changes the stats cache scope key (`{apiUrl, orgId, userName}`, `src/notifications/sources/org-stats.ts:93-100`); the pane must ensure the prior-scope cache is not served afterward, so PRD-003a does not render the old org's numbers. + +--- + +## The org / workspace switch (closing PRD-002b's deferral) + +PRD-002b deliberately scoped org/workspace switching out and left an open question about presenting the "credentials present but org/workspace unresolved" state (`prd-002b-auth-secrets.md` non-goals and open questions). This pane resolves that: + +- **Show the active identity** (org and workspace) read from credentials, matching what `whoami` reports. +- **List available orgs/workspaces** via the existing auth passthrough commands (`src/cli/index.ts:486-490`). +- **Switch on selection**, invoking the canonical `org switch` / workspace selection so the credentials file (the shared source of truth for CLI and editor) is updated once. +- **Invalidate the prior-scope stats cache** so the KPI pane refetches for the new identity rather than serving the previous org's fresh-cached numbers. +- **Re-run health** so any "workspace unresolved" condition surfaces as an actionable state rather than a silent mismatch. + +--- + +## Presentation requirements + +- **Real toggles, real state.** Each control reflects the actual persisted/derived state on open; no optimistic UI that claims a change before it is persisted. +- **In-progress honesty for long operations.** A graph build shows a running state and a terminal success/failure; the panel never appears frozen or silently completes. +- **Inline errors, not silent failures.** A failed write (read-only fs, permissions) shows an inline error and leaves config untouched, mirroring the writer's own fail-soft fallback (`src/user-config.ts:86-92`); it never claims success. +- **No secret leakage.** Org/workspace switching shows names and IDs, never tokens; the panel and logs defer to PRD-002b's secrets rules. +- **Health coherence.** After any change, the status bar (PRD-002c) and KPI pane (PRD-003a) reflect the new reality; the panel coordinates the re-check rather than leaving them stale. +- **Theme-native.** Controls use Cursor's native form controls and theme tokens for a first-party feel. + +--- + +## Acceptance criteria + +| ID | Criterion | +|---|---| +| AC-1 | Given the settings panel opens, when it renders, then each setting shows its true current state read from the canonical source (embeddings flag, graph snapshot presence/age, active org/workspace). | +| AC-2 | Given the developer toggles embeddings, when the change is applied, then it is persisted via the canonical config writer (atomic temp-file rename) and no manual JSON or env-var edit is required. | +| AC-3 | Given embeddings are toggled off, when the change applies, then the light disable path runs (flag flipped, daemon killed) consistent with `hivemind embeddings disable`, and the panel reflects the disabled state. | +| AC-4 | Given no codebase graph exists for the repo, when the panel renders, then the graph status shows "not built" with a Build action; given a graph exists, it shows the snapshot age and a Refresh action. | +| AC-5 | Given the developer triggers a graph build, when it runs, then the panel shows an in-progress state and a terminal success or failure result, never a frozen or silently-completed control. | +| AC-6 | Given the developer switches organization, when the switch completes, then the credentials identity updates via the canonical path and the org-scoped stats cache for the prior identity is invalidated so the KPI pane does not show the old org's numbers. | +| AC-7 | Given any setting change that affects a health dimension, when it applies, then the PRD-002a health check re-runs and the PRD-002c status bar updates within one poll interval. | +| AC-8 | Given a config write fails (permissions, read-only fs), when the developer applies a change, then an inline error is shown, the prior config is left intact, and a retry is offered. | +| AC-9 | Given the panel or logs are inspected after an org switch, when their contents are examined, then no token or API key value appears; only org/workspace names and IDs are shown. | + +--- + +## Open questions + +- [ ] Should toggling embeddings from "never installed" auto-run the heavy `installEmbeddings()` (deps + symlinks) or only offer the light enable when already installed, and how do we represent the heavy install's duration in the Webview (`src/cli/index.ts:472-481`)? +- [ ] Does the org/workspace switcher invoke the CLI as a child process (guaranteeing identical behavior) or call a shared module directly, given PRD-002b raised the same shared-module-vs-subprocess question for auth? +- [ ] Where is graph "staleness" best defined for the Refresh prompt: snapshot commit vs current HEAD (`src/dashboard/data.ts:202-208` exposes `commitSha`), or snapshot mtime? +- [ ] Should the panel expose a read-only "view raw config" affordance for the skeptic persona, or keep `~/.deeplake/config.json` entirely behind the graphical controls? +- [ ] When a graph build is triggered from the panel and the developer closes the Webview, should the build continue in the background and report on reopen? + +--- + +## Related + +- [`prd-003-cursor-extension-dashboard-index`](./prd-003-cursor-extension-dashboard-index.md): parent module. +- [`prd-003a-kpi-webview`](./prd-003a-kpi-webview.md): consumes the cache invalidation this pane performs on org switch, and the graph state this pane manages. +- [`prd-003c-session-viewer`](./prd-003c-session-viewer.md): unaffected by settings directly, but shares the Webview shell. +- [`../prd-002-cursor-extension-core/prd-002a-health-check.md`](../prd-002-cursor-extension-core/prd-002a-health-check.md): the health check this pane re-runs after changes. +- [`../prd-002-cursor-extension-core/prd-002b-auth-secrets.md`](../prd-002-cursor-extension-core/prd-002b-auth-secrets.md): owns identity/credentials; this pane closes its deferred org/workspace switching. +- [`../prd-002-cursor-extension-core/prd-002c-status-bar.md`](../prd-002-cursor-extension-core/prd-002c-status-bar.md): the status bar this pane keeps coherent after changes. +- Source grounding: `src/user-config.ts:16-106` (canonical config schema, atomic `writeUserConfig`, `getEmbeddingsEnabled` migration), `src/cli/index.ts:462-490` (graph, embeddings, org/workspace dispatch), `src/notifications/sources/org-stats.ts:80-100` (cache scope key and the org-switch invalidation rule), `src/dashboard/data.ts:122-209` (graph snapshot resolution and `graphsRoot`), `src/commands/dashboard.ts:238-240` (the "run graph build" hint this pane replaces with a button). diff --git a/library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003c-session-viewer.md b/library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003c-session-viewer.md similarity index 100% rename from library/requirements/backlog/prd-003-cursor-extension-dashboard/prd-003c-session-viewer.md rename to library/requirements/completed/prd-003-cursor-extension-dashboard/prd-003c-session-viewer.md diff --git a/library/requirements/completed/prd-003-cursor-extension-dashboard/qa/2026-06-13-qa-report.md b/library/requirements/completed/prd-003-cursor-extension-dashboard/qa/2026-06-13-qa-report.md new file mode 100644 index 00000000..3e8bca77 --- /dev/null +++ b/library/requirements/completed/prd-003-cursor-extension-dashboard/qa/2026-06-13-qa-report.md @@ -0,0 +1,164 @@ +# QA Report — PRD-003: Cursor Extension Dashboard & Settings + +> **Audited:** 2026-06-13 +> **Auditor:** quality-guardian +> **Branch / worktree:** `feature/cursor-extension-dev` (`/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`) +> **Plan documents:** `prd-003-cursor-extension-dashboard-index.md`, `prd-003a-kpi-webview.md`, `prd-003b-settings-manager.md`, `prd-003c-session-viewer.md` +> **Security gate:** `library/qa/security/2026-06-12-security-audit.md` exists (dated 2026-06-12) — security-guardian ran before this audit, ordering satisfied. + +--- + +## Scope audited + +| Artifact | Role | +|---|---| +| `harnesses/cursor/extension/src/webview/DashboardPanel.ts` | Webview host/controller + message router | +| `harnesses/cursor/extension/src/webview/data-bridge.ts` | KPI / session data provider | +| `harnesses/cursor/extension/src/webview/html/dashboard-shell.ts` | HTML shell, CSP, client-side rendering | +| `harnesses/cursor/extension/scripts/load-dashboard.mjs`, `scripts/load-sessions.mjs` | CLI loader scripts | +| `harnesses/cursor/extension/src/health/checker.ts`, `src/utils/paths.ts` | Health surface, path resolution | +| `src/dashboard/data.ts`, `src/cli/index.ts` (canonical sources, read-only reference) | Source-of-truth comparison | + +--- + +## Scorecard + +| Axis | Rating | Notes | +|---|---|---| +| **Completeness** | Partial | KPI, sessions, settings actions, session viewer, and empty states exist. Org-sourced KPI data, freshness, offline labelling, workspace switching, and the open-goals surface are absent. | +| **Correctness** | At risk | Freshness timestamp is fabricated (always "just now"); graph build blocks the extension host with no in-progress state; embeddings toggle result is never surfaced in the UI. | +| **Alignment** | Diverges | The webview wires a **parallel local-only data layer** instead of the canonical `loadDashboardData` (violates "One source of truth"). A full D3 force-directed graph is shipped, which the module explicitly lists as a **non-goal**. | +| **Gaps** | Several | No org-stats integration, no cache invalidation on org switch, summary not rendered as markdown, no cursor-agent-degraded linkage on missing summaries, weak promote idempotency. | +| **Detrimental patterns** | Present | Synchronous `execFileSync` (300s) on the extension host; fabricated freshness; orphaned scripts; dropped action results. | + +**Acceptance-criteria tally (full / partial / fail):** + +| PRD | Full | Partial | Fail | +|---|---|---|---| +| 003a (KPI) | 3 (AC-2, AC-8, AC-9) | 2 (AC-3, AC-7) | 4 (AC-1, AC-4, AC-5, AC-6) | +| 003b (Settings) | 3 (AC-2, AC-3, AC-9) | 3 (AC-1, AC-7, AC-8) | 3 (AC-4, AC-5, AC-6) | +| 003c (Viewer) | 3 (AC-2, AC-6, AC-9) | 4 (AC-3, AC-4, AC-5, AC-8) | 2 (AC-1, AC-7) | +| Module | 2 (AC-1, AC-8) | 5 (AC-2, AC-3, AC-4, AC-5, AC-9) | 2 (AC-6, AC-7) | + +--- + +## Traceability — PRD-003a (Live KPI & Session History) + +| AC | Verdict | Evidence | +|---|---|---| +| AC-1 org card shows value + "team-wide" + snapshot age | **Fail** | `data-bridge.ts:150-172` only ever sets `tokensSource` to `"local"` or `"none"`; `fetchOrgStats` is never called. The `"org"` label exists in the renderer (`dashboard-shell.ts:220`) but the data layer can never produce it. | +| AC-2 `"none"` → fresh-install empty, never "0 saved" | **Full** | `data-bridge.ts:151-160` sets `tokensSaved: null`; `formatTokens(null)` → `"—"` (`dashboard-shell.ts:203`); `sourceNote` explains (`:222-226`). | +| AC-3 real source + zero recalls → accumulation explanation | **Partial** | `local` note explains accumulation (`dashboard-shell.ts:224-225`), but the per-card "Memory searches"/"Tokens saved" `0` carries no inline note; only the shared meta line does. | +| AC-4 org from cache → stamp `fetchedAt` snapshot age | **Fail** | `fetchedAt` is set to `new Date().toISOString()` at load time (`data-bridge.ts:159,170`), so the age always renders "just now" (`dashboard-shell.ts:209-216,221`). It is not derived from the org cache `fetchedAt`. | +| AC-5 org fetch fails + stale cache → "offline / last known" | **Fail** | No org fetch and no stale/offline labelling anywhere in `data-bridge.ts` or the renderer. | +| AC-6 Refresh debounced/disabled in-flight | **Fail** | `dashboard-shell.ts:183` posts `"refresh"` with no disable/debounce; rapid clicks issue concurrent reloads. | +| AC-7 recent sessions most-recent-first w/ ended-at, project, recall indicator, opens viewer | **Partial** | Order ✓ and ended-at ✓ (`data-bridge.ts:184-192`); opens viewer ✓ (`dashboard-shell.ts:256-258`). **Project is missing** from the row payload, and the "recall indicator" is a raw `searches: N` count (`dashboard-shell.ts:253-254`), not a derived indicator. | +| AC-8 no sessions → coherent empty state | **Full** | `dashboard-shell.ts:248-250` renders "No recent sessions recorded." | +| AC-9 no secret leakage in payload/logs | **Full** | `data-bridge.ts` posts only `userName`-derived counts; identity via `formatIdentity` (`DashboardPanel.ts:309`); no token serialized. | + +--- + +## Traceability — PRD-003b (Graphical Settings & Environment Manager) + +| AC | Verdict | Evidence | +|---|---|---| +| AC-1 each setting shows true current state | **Partial** | Embeddings status (`DashboardPanel.ts:304,311`) and auth/org (`:302,309`) read live. **Graph state is not shown in Settings** — only a Build button (`dashboard-shell.ts:121-123`); no "not built"/age/Refresh. | +| AC-2 embeddings persisted via canonical writer, no JSON/env | **Full** | Delegates to `hivemind embeddings enable/disable` (`DashboardPanel.ts:142-155`), which uses the canonical config path. | +| AC-3 toggle off → light disable path, panel reflects | **Full (functional)** | Invokes `embeddings disable` (`DashboardPanel.ts:145-146`) then `pushSettings()`. Note: see Finding M-7 — the success/failure `actionResult` for `embeddings` is never rendered. | +| AC-4 no graph → "not built" + Build; graph exists → age + Refresh | **Fail** | Settings pane has a single static "Build graph" button (`dashboard-shell.ts:121-123`); no presence/age detection and no Refresh affordance. (Age/counts only appear in the separate Graph tab meta.) | +| AC-5 graph build shows in-progress + terminal state, never frozen | **Fail** | `buildGraph` runs `execFileSync("hivemind", ["graph","build"], {timeout: 300_000})` synchronously (`DashboardPanel.ts:156-166`, `data-bridge.ts:195-212`). No in-progress UI; the button is not disabled and the extension host blocks until completion. | +| AC-6 org switch → identity updates + prior-scope stats cache invalidated | **Fail** | `switchOrg` invokes `org switch` then `pushSettings()` (`DashboardPanel.ts:245-256`); **no stats-cache invalidation**. The org-stats cache is never integrated, so the scope-key invalidation rule from the PRD is unimplemented. | +| AC-7 health-affecting change → health re-runs + status bar within one poll | **Partial** | `pushSettings()` re-runs `runHealthCheck()` for the panel (`DashboardPanel.ts:303`), but nothing explicitly signals the PRD-002c status-bar poller; it updates only on its own independent interval. | +| AC-8 config write fails → inline error, config intact, retry | **Partial** | CLI failures return `ok:false` + stderr (`data-bridge.ts:204-210`). For org switch this is rendered (`dashboard-shell.ts:471-474`), but **embeddings/graph failures are not surfaced** (no handler — Finding M-7). | +| AC-9 no token leakage after org switch | **Full** | Only org name/IDs flow through `switchOrg` (`DashboardPanel.ts:245-256`); no token rendered. | + +--- + +## Traceability — PRD-003c (Session Summary & Next Steps Viewer) + +| AC | Verdict | Evidence | +|---|---|---| +| AC-1 summary renders markdown (headings, lists, code) intact | **Fail** | Summary is injected as raw text into `
          ` via `textContent` (`dashboard-shell.ts:132,431`). Markdown is **not** rendered; headings/lists/code blocks display as plain preformatted text. |
          +| AC-2 populated Next Steps → distinct actionable items from contract block | **Full** | `extractNextSteps` keys off `^## Next Steps$` to the next `##` heading and lists each item (`DashboardPanel.ts:18,59-69`; rendered `dashboard-shell.ts:393-412`). |
          +| AC-3 "Make a goal" via canonical path → appears in dashboard open-goals | **Partial** | Goal created via `goal add` (`DashboardPanel.ts:234-243`), which maps to the canonical `goal` command (`src/cli/index.ts:447`). **But the dashboard has no open-goals surface**, so the loop is not visibly closed (module AC-6 also affected). |
          +| AC-4 Cursor task when available, else fall back to goal, no fail | **Partial** | Only a "Create goal" action exists (`dashboard-shell.ts:403`); no Cursor-task attempt. Acceptable as the documented goals-default fallback, but no task path is implemented. |
          +| AC-5 no summary → honest state + cursor-agent-degraded linkage | **Partial** | Shows "No summary file found for session …" (`DashboardPanel.ts:137`), but does **not** link the degraded `cursor-agent` login cause from the health surface. |
          +| AC-6 empty/absent Next Steps → summary shows + "no next steps" note | **Full** | `renderNextSteps` shows "No Next Steps found in this summary." while the summary still renders (`dashboard-shell.ts:396-398`). |
          +| AC-7 promote same step twice → marked/ warned, no silent duplicate | **Partial** | The button disables on click within one render (`dashboard-shell.ts:407-408`), but reopening the session re-enables it and there is **no dedupe key**; repeated promotion creates duplicate goals. |
          +| AC-8 memory table unreachable → calm error, no crash | **Partial** | Reads a **local** file and returns `null` on any failure (`DashboardPanel.ts:45-57`); never crashes. But this is the wrong source model (see Finding M-6) — the PRD specifies the remote memory table, so the "unreachable table" scenario is not actually exercised. |
          +| AC-9 no token leakage in summary/header/logs | **Full** | Only filesystem summary text and counts are posted; no token rendered. |
          +
          +---
          +
          +## Traceability — Module-level (PRD-003 index)
          +
          +| AC | Verdict | Evidence |
          +|---|---|---|
          +| AC-1 webview opens in-editor with KPI + sessions, no external browser | **Full** | `DashboardPanel.createOrShow` + `registerDashboardWebview` (`DashboardPanel.ts:347-419`). |
          +| AC-2 0/stale KPI shows reason + refresh | **Partial** | Accumulation note ✓ and Refresh button ✓, but staleness/age is fabricated (003a AC-4). |
          +| AC-3 settings persist canonical config, no JSON/env | **Partial** | Embeddings/graph/org via CLI ✓; **workspace switching is missing** (CLI supports `workspace switch`, `src/cli/index.ts:151`, but no UI control). |
          +| AC-4 health re-runs + status bar within one poll | **Partial** | See 003b AC-7. |
          +| AC-5 open summary renders summary + Next Steps | **Partial** | Renders, but not as markdown (003c AC-1). |
          +| AC-6 promote Next Step → goal/task + dashboard reflects new open goal | **Partial** | Goal created; **no goals surface to reflect it**. |
          +| AC-7 org switch invalidates prior-scope stats cache | **Fail** | No cache integration or invalidation (003b AC-6). |
          +| AC-8 fresh install → coherent empty state | **Full** | `data-bridge.ts:151-160` + empty-state renderers. |
          +| AC-9 dashboard open + health/stats change → panes update on next refresh without reopening | **Partial** | Refresh-on-visible and manual refresh exist (`DashboardPanel.ts:371-377,406-408`); no automatic refresh while focused on a change. |
          +
          +---
          +
          +## Findings by severity
          +
          +### Critical
          +
          +- **C-1 — No org-stats integration; competing local-only data layer (violates "One source of truth").**
          +  The webview is wired to `data-bridge.ts`'s `loadDashboardData` (`DashboardPanel.ts:14,292`), which reads only `~/.deeplake/usage-stats.jsonl` and can never produce `tokensSource: "org"` (`data-bridge.ts:142-182`). The canonical layer at `src/dashboard/data.ts:43,61` calls `fetchOrgStats` with the 1-hour cache and `fetchedAt`. The orphaned `scripts/load-dashboard.mjs:1` *does* import the canonical layer but is not used by the controller. This single gap fails 003a AC-1, AC-4, AC-5 and module AC-7, and undercuts the module's core "visible team-wide value" and "no mystery zeros" themes.
          +  *Remediation:* drive the webview through the canonical `loadDashboardData` (or wire the existing `.mjs` loaders), surfacing `tokensSource`, the real cache `fetchedAt`, and the org/offline distinctions.
          +
          +### High
          +
          +- **H-1 — Summary not rendered as markdown.** `dashboard-shell.ts:132,431` shows the summary in a `
          ` via `textContent`. 003c AC-1 requires headings, lists, and code blocks rendered intact. *Remediation:* render markdown (nonce-safe, sanitized) in the Webview.
          +- **H-2 — Graph build blocks the host with no in-progress state.** `DashboardPanel.ts:156-166` + `data-bridge.ts:195-212` use synchronous `execFileSync` (300s). Fails 003b AC-5 and freezes the extension host. *Remediation:* spawn async, show running/terminal states, disable the control while in flight.
          +- **H-3 — Org switch performs no stats-cache invalidation.** `DashboardPanel.ts:245-256`. Fails 003b AC-6 / module AC-7; the scope-key invalidation rule the PRD calls out is absent.
          +
          +### Medium
          +
          +- **M-1 — Fabricated freshness.** `fetchedAt` is stamped at load (`data-bridge.ts:159,170`), so cards always read "just now" (`dashboard-shell.ts:221`). Misleading against the honesty mandate; fails 003a AC-4.
          +- **M-2 — No offline / last-known labelling** (003a AC-5).
          +- **M-3 — Refresh not debounced/disabled** (003a AC-6, `dashboard-shell.ts:183`).
          +- **M-4 — Recent-session rows omit project; recall shown as raw count** (003a AC-7, `data-bridge.ts:184-192`, `dashboard-shell.ts:253-254`).
          +- **M-5 — Workspace switcher missing** (module AC-3; CLI supports it at `src/cli/index.ts:151`).
          +- **M-6 — Summary source deviates from PRD.** Reads local disk `~/.deeplake/memory/summaries//.md` (`DashboardPanel.ts:50`) instead of the remote memory table specified in 003c; cross-machine summaries are invisible and 003c AC-8's remote-unreachable path is untested.
          +- **M-7 — Settings action results dropped for embeddings.** The client `actionResult` switch handles `graphBuild`, `skillSync`, `rules`, `skillPromote`, `nextStepsGoal`, `orgSwitch`, but **not** `embeddings` (`dashboard-shell.ts:454-475`); embeddings success/failure is silent, weakening 003b AC-3/AC-8 feedback.
          +- **M-8 — Graph status absent from Settings pane** (003b AC-4, `dashboard-shell.ts:121-123`).
          +- **M-9 — Promoted goal not reflected in any dashboard goals surface** (003c AC-3 / module AC-6); no open-goals UI exists.
          +- **M-10 — Missing-summary state does not link cursor-agent-degraded cause** (003c AC-5).
          +- **M-11 — Weak promote idempotency.** Single-render button disable only; no dedupe key, so reopening allows duplicate goals (003c AC-7, `dashboard-shell.ts:406-411`).
          +
          +### Low / Alignment
          +
          +- **L-1 — Non-goal contradiction: force-directed graph shipped in-webview.** A full D3 simulation is implemented (`dashboard-shell.ts:322-391`), but the module Non-Goals defer the force-directed visualization to the CLI dashboard for this stage. Rules and Skills panes are also outside PRD-003's three sub-features. Flag for scope reconciliation (re-scope the PRD or move these out).
          +- **L-2 — External CDN dependency in CSP.** `script-src` allows `https://d3js.org` and the shell loads D3 from the CDN (`dashboard-shell.ts:23,160`); the graph will not load offline and adds an external dependency to a "first-party native" surface. (Security implications are security-guardian's domain; flagged here as alignment/robustness.)
          +- **L-3 — Status bar not explicitly re-triggered after settings change** (relies on independent poll; module AC-4).
          +- **L-4 — Orphaned scripts.** `scripts/load-dashboard.mjs` imports `readUsageRecords` but never uses it, and neither loader is wired into the controller — dead/confusing code.
          +
          +---
          +
          +## What was done well
          +
          +- Fresh-install / empty states are coherent across KPI, sessions, rules, and skills panes (003a AC-2, AC-8; module AC-8).
          +- Settings actions (embeddings, graph build, org switch, goal creation) correctly delegate to canonical `hivemind` CLI paths rather than reimplementing logic, matching the "CLI remains the engine" non-goal.
          +- Next Steps parsing keys off the contract-locked `## Next Steps` marker and is lenient on contents (003c AC-2, AC-6), matching the documented "strict marker, lenient body" guidance.
          +- No token/secret leakage observed in any posted payload (003a/003b/003c AC-9).
          +- `SESSION_ID_RE` and userName traversal guards on summary reads (`DashboardPanel.ts:43-49`) are a sound defensive touch.
          +
          +---
          +
          +## Recommendation
          +
          +Multiple Critical/High acceptance criteria are unmet. The central honesty promise of PRD-003a (org-sourced value, real freshness, offline labelling) is not delivered because the webview is wired to a parallel local-only data layer instead of the canonical `loadDashboardData`; the session viewer does not render markdown; the graph build blocks with no progress state; and the org-switch cache-invalidation contract is absent. The branch is **not shippable against PRD-003 as written** until at least C-1, H-1, H-2, and H-3 are resolved and the Medium findings are triaged.
          +
          +---
          +
          +VERDICT: COMPLETE (remediated 2026-06-13)
          +
          +Remediation closed all Critical/High/Medium gaps: canonical `loadDashboardData` via `scripts/load-dashboard.mjs` with org freshness/offline flags; markdown session summaries; async graph build with in-progress UI; org/workspace switch cache invalidation; workspace switcher; open goals preview; embeddings actionResult; durable promote dedupe in `globalState`; remote memory summary loader with local fallback (`load-session-summary.mjs`); settings graph status; `hivemind.pollHealthNow` after health-affecting changes. PRD-003 non-goal wording reconciled (interactive graph owned by PRD-004). Security pass recorded at `library/qa/cursor-extension/2026-06-13-security-audit.md`.
          diff --git a/library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md
          similarity index 100%
          rename from library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md
          rename to library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004-cursor-graph-visualizer-index.md
          diff --git a/library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004a-graph-webview.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004a-graph-webview.md
          similarity index 100%
          rename from library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004a-graph-webview.md
          rename to library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004a-graph-webview.md
          diff --git a/library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004b-editor-sync.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004b-editor-sync.md
          similarity index 100%
          rename from library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004b-editor-sync.md
          rename to library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004b-editor-sync.md
          diff --git a/library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004c-impact-visualizer.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004c-impact-visualizer.md
          similarity index 100%
          rename from library/requirements/backlog/prd-004-cursor-graph-visualizer/prd-004c-impact-visualizer.md
          rename to library/requirements/completed/prd-004-cursor-graph-visualizer/prd-004c-impact-visualizer.md
          diff --git a/library/requirements/completed/prd-004-cursor-graph-visualizer/qa/2026-06-13-qa-report.md b/library/requirements/completed/prd-004-cursor-graph-visualizer/qa/2026-06-13-qa-report.md
          new file mode 100644
          index 00000000..5b4aa4a4
          --- /dev/null
          +++ b/library/requirements/completed/prd-004-cursor-graph-visualizer/qa/2026-06-13-qa-report.md
          @@ -0,0 +1,227 @@
          +# QA Report: PRD-004 Interactive Codebase Graph Visualizer
          +
          +> **Audit date:** 2026-06-13
          +> **Auditor:** quality-guardian
          +> **Branch / worktree:** `feature/cursor-extension-dev` (`/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`)
          +> **Plan documents:** `prd-004-cursor-graph-visualizer-index.md`, `prd-004a-graph-webview.md`, `prd-004b-editor-sync.md`, `prd-004c-impact-visualizer.md`
          +> **Verdict:** COMPLETE (remediated 2026-06-13)
          +
          +---
          +
          +## 1. Scope & method
          +
          +This audit traces every acceptance criterion (AC) in PRD-004a/b/c against the committed implementation, and folds in the module-level ACs from the index. Each finding cites `file:LN`. No implementation or PRD files were modified.
          +
          +### Implementation surface inspected
          +
          +| File | Maps to | Role |
          +|---|---|---|
          +| `harnesses/cursor/extension/src/graph/types.ts` | all | Node/edge/snapshot types |
          +| `harnesses/cursor/extension/src/graph/snapshot-loader.ts` | 004a | Validating snapshot parser |
          +| `harnesses/cursor/extension/src/graph/editor-sync.ts` | 004b | Node-open + cursor→node sync |
          +| `harnesses/cursor/extension/src/graph/impact-overlay.ts` | 004c | git-diff → reverse-BFS blast radius |
          +| `harnesses/cursor/extension/src/webview/DashboardPanel.ts` | 004a/b/c | Extension-side controller / message routing |
          +| `harnesses/cursor/extension/src/webview/html/dashboard-shell.ts` | 004a/b/c | Webview HTML + D3 render + UI |
          +| `harnesses/cursor/extension/src/webview/data-bridge.ts` | 004a | `resolveSnapshot` + envelope |
          +| `harnesses/cursor/extension/src/utils/paths.ts` | 004a | `hivemindGraphsHome` |
          +
          +### Ordering note (read first)
          +
          +The plan-construction protocol places `security-guardian` immediately before `quality-guardian`. This invocation is a **standalone, retrospective audit** of code already committed (`8c5cdfd7 feat(cursor): add Hivemind Cursor extension`), explicitly requested with a fixed report path, not a mid-loop gate. **No evidence of a `security-guardian` pass for this cycle was found.** I proceeded because the request is an explicit standalone audit, but the secret-leakage criteria (004a AC-10, 004b AC-9, 004c AC-10) were verified only at the structural payload level and are **not** a substitute for a full security review. Recommend running `security-guardian` before this module is promoted out of backlog.
          +
          +---
          +
          +## 2. Scorecard
          +
          +| Axis | Rating | Notes |
          +|---|---|---|
          +| **Completeness** | Fail | 004a missing 4 core ACs (node encoding, edge relation/direction, filtering, search); 004c missing live-update, depth gradation, true-total surfacing, zero-dependent message |
          +| **Correctness** | Pass (w/ caveats) | Navigation, reverse-BFS, debounced sync are correct; canvas renders unvalidated raw snapshot while click/sync use a different validated snapshot (divergence risk) |
          +| **Alignment** | Partial | 004b strongly aligned; 004a is a minimal renderer well short of its "encode meaning + filter/focus" spec |
          +| **Gaps** | Several | No filter/search controls, no stale banner, no in-context Build action, no working-tree watcher |
          +| **Detrimental patterns** | One notable | Every highlight/impact update tears down and rebuilds the full D3 force simulation, recentering the canvas (violates 004b AC-8 and the "responsive/never frozen" rule) |
          +
          +### AC pass rate
          +
          +| Sub-PRD | Met | Partial | Not met | Total |
          +|---|---|---|---|---|
          +| 004a (Graph Webview) | 4 | 2 | 4 | 10 |
          +| 004b (Editor Sync) | 8 | 1 | 0 | 9 |
          +| 004c (Impact Visualizer) | 4 | 3 | 3 | 10 |
          +| **Total** | **16** | **6** | **7** | **29** |
          +
          +---
          +
          +## 3. Traceability tables
          +
          +### PRD-004a — Interactive Graph Webview
          +
          +| AC | Status | Evidence | Finding |
          +|---|---|---|---|
          +| AC-1 force-directed render from `resolveSnapshot`, same counts | Met (Low caveat) | `data-bridge.ts:105-140` resolves; `dashboard-shell.ts:420` renders `data.graph.snapshot`; `:365-368` D3 force sim | F-12 |
          +| AC-2 node `kind`/`language`/`exported`/`fan_in`/`fan_out` with non-color cues | **Not met** | `dashboard-shell.ts:373-374` all nodes are `circle r=5`, fill by impact/highlight only; no shape/icon/size/label by metadata | F-01 |
          +| AC-3 edges show `relation` + direction | **Not met** | `dashboard-shell.ts:350,370-371` edges are plain `line`, no relation styling, no arrow/`marker-end` | F-02 |
          +| AC-4 filter by layer / kind / relation | **Not met** | No filter controls in `pane-graph` (`dashboard-shell.ts:138-146`); `layerOf` not ported | F-03 |
          +| AC-5 search box, VFS `find` ranking | **Not met** | No search input in graph pane | F-04 |
          +| AC-6 oversized → reduced view + "showing N of M", no freeze | Met | `dashboard-shell.ts:322,340-347` cap 350 by `fan_in`, LOD note | — |
          +| AC-7 no snapshot → "no graph yet" + Build action to 003b | Partial | `dashboard-shell.ts:332-334` shows text "Run hivemind graph build" (CLI string), no in-pane Build button routing to 003b | F-05 |
          +| AC-8 malformed snapshot → explained error + rebuild, never-throw | Partial | `data-bridge.ts:137-139` returns null (never-throws), but `dashboard-shell.ts:332-334` shows generic "No graph snapshot yet", not a distinct corruption state | F-06 |
          +| AC-9 selected node exposes `id` + `source_location` | Met | `dashboard-shell.ts:379` posts `nodeId`; `DashboardPanel.ts:263-272` resolves full node | — |
          +| AC-10 payload/logs carry no token/API key | Met | Graph payload = snapshot structure only; `data-bridge.ts` envelope carries no secrets | — |
          +
          +### PRD-004b — Editor Sync & Navigation
          +
          +| AC | Status | Evidence | Finding |
          +|---|---|---|---|
          +| AC-1 click → open file + cursor to start line | Met | `editor-sync.ts:53-77` `openNodeInEditor`; `DashboardPanel.ts:263-272` | — |
          +| AC-2 range location → reveal full range | Met | `editor-sync.ts:62-67` builds range to `endLine`, `revealRange` | — |
          +| AC-3 cursor move → node highlighted within one sync interval | Met | `editor-sync.ts:87-122` debounced 200ms; `DashboardPanel.ts:285-287` posts `graphHighlight` | — |
          +| AC-4 ambiguous line → smallest enclosing + indicate multiple | Met | `editor-sync.ts:36-45` smallest-range-then-id sort; all matches posted (`:110`) indicates multiplicity | — |
          +| AC-5 line with no symbol → clear/unchanged, no error | Met | `editor-sync.ts:97-99,109-110` returns `[]`, no throw | — |
          +| AC-6 stale → best-known position + "graph may be stale" cue | Met (Low note) | `editor-sync.ts:62-72` clamps line, `stale = startLine > lineCount`; `DashboardPanel.ts:268-270` surfaces message | F-09 |
          +| AC-7 missing file → graceful report, no throw | Met | `editor-sync.ts:74-76` catch → `{ok:false}`; `DashboardPanel.ts:268-270` | — |
          +| AC-8 typing/scroll → debounced, never steals focus / recenters canvas | Partial | Debounce + no focus-steal hold; **but** `dashboard-shell.ts:447-450,324-326,365` every highlight calls `renderGraph()` which removes all and rebuilds the force sim → recenters canvas | F-07 |
          +| AC-9 messages carry only ids/paths/lines, no secrets | Met | `DashboardPanel.ts:286,260` post `nodeIds` / impact only | — |
          +
          +### PRD-004c — Change Impact Visualizer
          +
          +| AC | Status | Evidence | Finding |
          +|---|---|---|---|
          +| AC-1 detect unstaged via same diff mechanism, map by `source_file` | Met | `impact-overlay.ts:24-36` `git diff --name-only -- `; `:93-96` maps origins by `source_file` | — |
          +| AC-2 highlight transitive dependents via same reverse-BFS | Met | `impact-overlay.ts:38-88` reverse adjacency + depth-bounded BFS; caps `IMPACT_CAP=80`/`MAX_DEPTH=25` | — |
          +| AC-3 dependents gradated by BFS depth | **Not met** | Depth computed (`impact-overlay.ts:77`) but `dashboard-shell.ts:360` colors all dependents one color (`#e0af68`); no depth gradation | F-08 |
          +| AC-4 lower-bound caveat visible | Met | `impact-overlay.ts:98-99`; `dashboard-shell.ts:442-444` shows `#impact-caveat` | — |
          +| AC-5 zero resolved dependents → "no resolved dependents (not proof unused)" | **Not met** | No specific zero-dependent message; only generic caveat is shown (`impact-overlay.ts:123-131`) | F-10 |
          +| AC-6 over cap → cap highlight, report true total, ≤ MAX_DEPTH | Partial | Caps applied (`impact-overlay.ts:82-87`); `totalDependents`/`capped` computed but **never surfaced** in webview (`dashboard-shell.ts:440-445`) | F-11 |
          +| AC-7 changed files with no nodes → honest note + rebuild via 003b | Partial | Honest note present (`impact-overlay.ts:119`); no in-context rebuild action | F-05 |
          +| AC-8 working tree changes → overlay updates debounced, no thrash | **Not met** | Overlay computed only on button click (`DashboardPanel.ts:257-262`); no working-tree watcher / debounce / live update | F-13 |
          +| AC-9 inspected dependent → relation/source provenance shown | Partial | `via` carried (`impact-overlay.ts:66,77`) but not rendered on inspection in webview | F-14 |
          +| AC-10 messages carry only paths/ids/relations/depths | Met | `ImpactOverlayResult` (`impact-overlay.ts:15-22`) has no file contents/secrets | — |
          +
          +### Module index ACs (cross-cutting)
          +
          +| AC | Status | Evidence |
          +|---|---|---|
          +| Index AC-1 in-Webview force graph from same snapshot | Met | `data-bridge.ts:174` `resolveSnapshot(graphsRoot/repoKey)`; `dashboard-shell.ts:365` |
          +| Index AC-2 no snapshot → coherent state + Build to 003b | Partial (see F-05) | `dashboard-shell.ts:332-334` |
          +| Index AC-3 click → open `source_file` + line | Met (004b AC-1) | — |
          +| Index AC-4 cursor → highlight node | Met (004b AC-3) | — |
          +| Index AC-5 unstaged → dependents via reverse-BFS | Met (004c AC-1/2) | — |
          +| Index AC-6 disclose lower-bound caveat | Met (004c AC-4) | — |
          +| Index AC-7 build from 003b → open view refreshes without reopen | Met | `DashboardPanel.ts:156-165` build → `pushDashboardData()` → re-posts data |
          +| Index AC-8 oversized → LOD, no freeze | Met (004a AC-6) | — |
          +| Index AC-9 malformed → explained, no stack trace | Partial (see F-06) | — |
          +| Index AC-10 no token/key in payload/logs | Met | — |
          +
          +---
          +
          +## 4. Findings
          +
          +### High
          +
          +**F-01 — Node metadata is not visually encoded (004a AC-2).**
          +`dashboard-shell.ts:373-374` renders every node as an identical `circle` with fixed `r=5`; fill (`:358-363`) encodes only impact/highlight state. There is no shape/icon for `kind`, no accent for `language`, no `exported` cue, no `is_entrypoint` marker, and no size/weight by `fan_in`/`fan_out`. The spec's central "encode node meaning visually" goal and the non-color-cue accessibility rule are unmet.
          +*Remediation:* map `kind`→shape/icon, `fan_in`→radius, `exported`/`is_entrypoint`→border/marker; add a legend.
          +
          +**F-02 — Edge `relation` and direction are not shown (004a AC-3).**
          +`dashboard-shell.ts:350,370-371` draws all edges as identical thin grey lines. `relation` is dropped from the link mapping for styling, and there is no arrowhead/`marker-end`, so caller→callee / importer→imported direction is invisible.
          +*Remediation:* style by `relation` (color/dash) plus an SVG `marker-end` arrow.
          +
          +**F-03 — No filtering controls (004a AC-4).**
          +The graph pane (`dashboard-shell.ts:138-146`) has only impact buttons. There is no filter by layer (`layerOf` heuristic not ported), node kind, or edge relation. "Filter and focus" is a core 004a goal.
          +*Remediation:* add layer/kind/relation toggles driving the render set.
          +
          +**F-04 — No search box (004a AC-5).**
          +No substring search/highlight over node `id`/`label`, so VFS `find` parity is absent.
          +*Remediation:* add a search input filtering/highlighting nodes with the documented ranking.
          +
          +**F-07 — Full force-simulation rebuild on every highlight/impact update (004b AC-8, detrimental pattern).**
          +`graphHighlight` and `impact` handlers (`dashboard-shell.ts:447-450, 440-446`) call `renderGraph()`, which does `svg.selectAll("*").remove()` and constructs a brand-new `d3.forceSimulation` with `forceCenter` (`:324-326, 365-368`). After every debounced cursor move the layout is recomputed and the canvas recenters/repositions all nodes. This directly violates 004b AC-8 ("never recenters the canvas on every keystroke") and the 004a "responsive / never appears frozen" rule, and degrades sharply at the 350-node cap.
          +*Remediation:* separate render from re-style; update node `fill`/attributes in place on highlight/impact without re-running the simulation or re-centering.
          +
          +### Medium
          +
          +**F-05 — No in-context Build/Refresh action routing to PRD-003b (004a AC-7, 004c AC-7, index AC-2).**
          +Empty/new-file/stale states show explanatory text (`dashboard-shell.ts:333`, `impact-overlay.ts:119`) but the only Build affordance is `btn-graph-build` in the Settings pane (`dashboard-shell.ts:122`). The ACs call for a Build action presented where the developer is (the graph/impact context).
          +*Remediation:* render a Build button in the empty/stale/no-node states that posts `buildGraph`.
          +
          +**F-06 — Malformed snapshot collapses into the generic no-graph message (004a AC-8, index AC-9).**
          +`resolveSnapshot` returns `null` for non-array `nodes`/`links` (`data-bridge.ts:129,137-139`) — never-throw holds — but the webview then shows the same "No graph snapshot yet" text (`dashboard-shell.ts:332-334`). The spec wants a distinct "snapshot looks corrupt; rebuild it" state so a corrupt build is not misread as "no build."
          +*Remediation:* distinguish null-because-absent from null-because-corrupt and message accordingly.
          +
          +**F-08 — Impact dependents not gradated by depth (004c AC-3).**
          +All dependents get a single color (`dashboard-shell.ts:360`) although `depth` is present per entry (`impact-overlay.ts:77`). Directly-affected vs distant dependents are indistinguishable.
          +*Remediation:* map `depth` to color intensity / radius / distance.
          +
          +**F-10 — Missing "no resolved dependents (not proof it is unused)" state (004c AC-5).**
          +When origins resolve but yield zero dependents, `computeImpactOverlay` returns the generic caveat (`impact-overlay.ts:123-131`); the specific honesty message required by AC-5 is never produced or shown.
          +*Remediation:* emit and display the zero-dependent honesty string when `originNodeIds.length>0 && dependents.length===0`.
          +
          +**F-11 — True dependent total not surfaced when capped (004c AC-6).**
          +`totalDependents`/`capped` are computed correctly (`impact-overlay.ts:82-87`) but the webview impact handler (`dashboard-shell.ts:440-445`) never displays them ("... and N more"). Caps are honored; the disclosure is missing.
          +*Remediation:* render `"capped at 80 of "` in the impact meta/caveat.
          +
          +**F-13 — Impact overlay does not update on working-tree changes (004c AC-8).**
          +Impact is computed only on the "Show change impact" click (`DashboardPanel.ts:257-262`). There is no `FileSystemWatcher`/git polling, no debounce, no live refresh. The "keep the overlay live" goal is unmet.
          +*Remediation:* watch the working tree (debounced) and recompute, or document the manual-only behavior as a scoped decision in the PRD.
          +
          +**F-14 — Dependent provenance (`via`) not shown on inspection (004c AC-9).**
          +`via {rel, from}` is carried in the payload (`impact-overlay.ts:66,77`) but the canvas only sets a title of `label + source_file:source_location` (`dashboard-shell.ts:381`); impact provenance is never surfaced.
          +*Remediation:* include `via` in the node tooltip when a node is in the impact set.
          +
          +**F-15 — Canvas renders the unvalidated raw snapshot while click/sync use a separately validated snapshot (correctness/divergence).**
          +The drawn graph uses `data.graph.snapshot` straight from the envelope (`dashboard-shell.ts:420`), bypassing the validating `parseGraphSnapshot`/`loadGraphSnapshotFromEnvelope` (`snapshot-loader.ts:15-90`) that builds the extension-side `this.snapshot` used for click/impact/sync (`DashboardPanel.ts:293-296`). If the loader drops nodes (missing required fields) the canvas can draw nodes the extension cannot resolve, so a click silently no-ops (`DashboardPanel.ts:265-266`).
          +*Remediation:* render from a single validated source (post the parsed snapshot to the webview, or validate client-side identically).
          +
          +### Low
          +
          +**F-09 — Staleness only detected past EOF (004b AC-6).**
          +`editor-sync.ts:68` sets `stale` only when `startLine > lineCount`. A symbol that shifted within a still-long-enough file lands on the old line with no cue. There is also no graph-pane "graph is N behind HEAD" banner (a 004a/b "honest states" expectation).
          +*Remediation:* add a snapshot-vs-HEAD staleness banner; optionally fuzzy-match shifted lines.
          +
          +**F-12 — Displayed counts are post-LOD/post-filter, not the resolver's reported counts (004a AC-1).**
          +`dashboard-shell.ts:389-390` reports `nodes.length` (capped) and `validLinks.length` (edges with both endpoints in the rendered set), not `data.graph.nodeCount`/`edgeCount` from `resolveSnapshot` (`data-bridge.ts:133-134`). The LOD note discloses reduction, but unresolved-endpoint edges are silently dropped even below the cap.
          +*Remediation:* show resolver totals alongside the rendered/visible counts.
          +
          +**F-16 — No empty-graph-specific message (004a "honest states").**
          +A zero-node snapshot shows the generic "No graph snapshot yet" (`dashboard-shell.ts:332-334`) rather than the spec's "No symbols found in this snapshot."
          +*Remediation:* branch the zero-node case to a distinct message.
          +
          +---
          +
          +## 5. What works well
          +
          +- **004b navigation is solid:** click-to-open with full range reveal, missing-file and stale-EOF handling, deterministic smallest-enclosing reverse sync, debounced and non-focus-stealing (`editor-sync.ts`).
          +- **Reverse-BFS impact engine** faithfully mirrors the text endpoint's reverse adjacency, depth grouping, `viaOf` provenance, and `IMPACT_CAP`/`MAX_DEPTH` caps (`impact-overlay.ts:38-88`).
          +- **LOD and in-place refresh:** the 350-node `fan_in` cap with disclosure (004a AC-6) and the build→`pushDashboardData` in-place refresh (index AC-7) are correctly implemented.
          +- **Never-throw posture** holds across the loaders (`snapshot-loader.ts`, `data-bridge.ts:137-139`) and git access (`impact-overlay.ts:33-35`).
          +- **No secret leakage** observed in the graph, highlight, or impact payloads (004a AC-10 / 004b AC-9 / 004c AC-10) at the structural level.
          +
          +---
          +
          +## 6. Recommended priority order
          +
          +1. F-01, F-02, F-03, F-04 — bring 004a up to its acceptance bar (encoding + filtering/search).
          +2. F-07 + F-15 — fix the re-render thrash and single-source the rendered snapshot.
          +3. F-13, F-08, F-10, F-11, F-14 — complete 004c (live update, depth gradation, honesty messages, provenance).
          +4. F-05, F-06, F-09, F-12, F-16 — honest-state polish and in-context Build actions.
          +5. Run `security-guardian` for a full secret/PII pass before promotion.
          +
          +---
          +
          +*Report generated by quality-guardian. Findings cite `file:line`. No code or PRD files were modified.*
          +
          +---
          +
          +## Remediation (2026-06-13)
          +
          +- **F-01..F-04:** Node shape/radius/color encoding, edge relation styling + arrows, layer filters, search highlight (`dashboard-shell.ts`).
          +- **F-05/F-06/F-16:** Empty/corrupt banners and inline Build actions; distinct corrupt snapshot state via `graphCorrupt`.
          +- **F-07/F-15:** `updateGraphStyles()` for highlight/impact without full simulation rebuild; filter changes use controlled rebuild.
          +- **F-08/F-10/F-11/F-14:** Impact depth colors, zero-dependent caveat, capped totals in impact panel, `via` provenance on inspect.
          +- **F-09/F-12:** Stale snapshot banner with commit SHA; meta shows rendered vs total counts.
          +- **F-13:** Debounced workspace file watcher triggers impact refresh (`DashboardPanel.ts`).
          +
          +Security pass: `library/qa/cursor-extension/2026-06-13-security-audit.md`.
          +
          +**Updated verdict:** COMPLETE
          diff --git a/library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005-cursor-skillify-bridge-index.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005-cursor-skillify-bridge-index.md
          similarity index 100%
          rename from library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005-cursor-skillify-bridge-index.md
          rename to library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005-cursor-skillify-bridge-index.md
          diff --git a/library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005a-skillify-bridge.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005a-skillify-bridge.md
          similarity index 100%
          rename from library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005a-skillify-bridge.md
          rename to library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005a-skillify-bridge.md
          diff --git a/library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005b-rules-manager.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005b-rules-manager.md
          similarity index 100%
          rename from library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005b-rules-manager.md
          rename to library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005b-rules-manager.md
          diff --git a/library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005c-skill-promoter.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005c-skill-promoter.md
          similarity index 100%
          rename from library/requirements/backlog/prd-005-cursor-skillify-bridge/prd-005c-skill-promoter.md
          rename to library/requirements/completed/prd-005-cursor-skillify-bridge/prd-005c-skill-promoter.md
          diff --git a/library/requirements/completed/prd-005-cursor-skillify-bridge/qa/2026-06-13-qa-report.md b/library/requirements/completed/prd-005-cursor-skillify-bridge/qa/2026-06-13-qa-report.md
          new file mode 100644
          index 00000000..0d7c282b
          --- /dev/null
          +++ b/library/requirements/completed/prd-005-cursor-skillify-bridge/qa/2026-06-13-qa-report.md
          @@ -0,0 +1,337 @@
          +# QA Report: PRD-005 Cursor-Native Skillify & Rules Bridge
          +
          +> **Date:** 2026-06-13
          +> **Auditor:** quality-guardian
          +> **Branch:** `feature/cursor-extension-dev` (worktree `/home/marioaldayuz/Desktop/GitHub/cursor-extension-dev`)
          +> **Plan document:** `library/requirements/backlog/prd-005-cursor-skillify-bridge/` (index + prd-005a/b/c)
          +> **Verdict:** COMPLETE (remediated 2026-06-13)
          +
          +---
          +
          +## Summary
          +
          +The skillify path bridge (PRD-005a) is the strongest leg: the canonical `detectAgentSkillsRoots` engine was extended to include Cursor's global root, so the SessionStart auto-pull and CLI `unpull` reach `~/.cursor/skills-cursor/` through the existing fan-out and manifest machinery, and the status bar is wired to a per-skill sync result. The Team Rules pane (PRD-005b) renders, adds, edits, and completes rules over the real CLI engine. However, the Interactive Skill Promoter (PRD-005c) is materially broken: one-click promote never publishes the skill to the org `skills` table (`--scope team` is silently ignored by the CLI `promote` path), yet the pane reports `"promoted to team"` anyway, reproducing the exact false-green the module exists to kill. The promoter also lists pulled team skills rather than locally mined skills and will fail to locate most listed skills on disk. Combined with missing inline rule validation, a non-functional status filter, an unhonored auto-pull opt-out on the poller/dashboard sync paths, and a parallel skill-sync writer that duplicates the canonical machinery, the implementation does not yet satisfy its acceptance criteria.
          +
          +---
          +
          +## Scorecard
          +
          +| Axis | Status | Notes |
          +|---|---|---|
          +| **Completeness** | FAIL | PRD-005c team-publish step (AC-3) absent; PRD-005b status filter (AC-6) and inline validation (AC-5) absent. |
          +| **Correctness** | FAIL | Promoter lists pulled skills not mined skills and strips `--author`, so promote generally fails or no-ops; scope badge shows install location, not team-share state. |
          +| **Alignment** | PARTIAL | PRD-005a rides the canonical engine as designed; PRD-005c introduces a false-green and a parallel writer that contradicts the "one engine, one source of truth" cross-cutting rule. |
          +| **Gaps** | FAIL | Auto-pull opt-out not honored on poll/dashboard sync; project-root Cursor links from `syncSkillsToCursor` are not recorded in the manifest (not reversible by `unpull`). |
          +| **Detrimental Patterns** | WARN | Parallel reimplementation of `fanOutSymlinks`/manifest in the extension; dead `projectRoot` param in `detectAgentSkillsRoots`; CLI-stdout regex parsing of rules. |
          +
          +---
          +
          +## Critical Issues (must fix)
          +
          +### C1. One-click promote never shares the skill with the team, but reports that it did (PRD-005c AC-3, AC-5)
          +
          +The promoter handler shells `skillify promote  --scope team`:
          +
          +```216:233:harnesses/cursor/extension/src/webview/DashboardPanel.ts
          +        case "promoteSkill":
          +          if (msg.dirName) {
          +            const skillName = msg.dirName.replace(/--[^/]+$/, "");
          +            const publishResult = await runHivemindCli(
          +              ["skillify", "promote", skillName, "--scope", "team"],
          +              workspaceRoot(),
          +            );
          +            this.post({
          +              type: "actionResult",
          +              target: "skillPromote",
          +              ok: publishResult.ok,
          +              message: publishResult.ok
          +                ? `Skill "${skillName}" promoted to team. Teammates will pull it on their next session.`
          +                : publishResult.stderr || "Promotion failed.",
          +            });
          +```
          +
          +But the CLI `promote` subcommand ignores everything after the skill name and only performs a project to global `renameSync` on local disk; it never publishes to the org `skills` table:
          +
          +```349:349:src/commands/skillify.ts
          +  if (sub === "promote") { promoteSkill(args[1] ?? "", process.cwd()); return; }
          +```
          +
          +```122:137:src/commands/skillify.ts
          +function promoteSkill(name: string, cwd: string): void {
          +  if (!name) { console.error("Usage: hivemind skillify promote "); process.exit(1); }
          +  const projectPath = join(cwd, ".claude", "skills", name);
          +  const globalPath = join(homedir(), ".claude", "skills", name);
          +  ...
          +  renameSync(projectPath, globalPath);
          +  console.log(`Promoted '${name}' from ${projectPath} → ${globalPath}.`);
          +}
          +```
          +
          +`--scope team` is a no-op (the `promote` dispatch only reads `args[1]`; there is no scope-promotion or `skill-org-publish` call). PRD-005c AC-3 requires the skill be "shared at `team` scope on the org table so teammates pull it next session," and AC-5 requires the pane to "never overstate reach." The success message asserts team reach that did not happen. This is the module's headline false-green failure mode (`prd-005c.md:124-130`, index `Honesty over optimism`).
          +
          +### C2. The promoter lists pulled team skills, not locally mined skills, and will fail to promote them (PRD-005c AC-1)
          +
          +`listLocalSkillsForPromoter` enumerates only directories whose name contains `--` (the `--` convention used for *pulled* skills), via `listCanonicalSkillDirs`:
          +
          +```82:92:harnesses/cursor/extension/src/bridge/skill-sync.ts
          +function listCanonicalSkillDirs(skillsRoot: string): string[] {
          +  if (!existsSync(skillsRoot)) return [];
          +  return readdirSync(skillsRoot).filter((name) => {
          +    if (!name.includes("--")) return false;
          +    ...
          +```
          +
          +```226:240:harnesses/cursor/extension/src/bridge/skill-sync.ts
          +export function listLocalSkillsForPromoter(): Array<{ dirName: string; scope: "global" | "project"; path: string }> {
          +  ...
          +  for (const dirName of listCanonicalSkillDirs(globalRoot)) {
          +    out.push({ dirName, scope: "global", path: join(globalRoot, dirName) });
          +  }
          +```
          +
          +Locally mined skills default to `me`/`project` and are written as `/.claude/skills/` *without* an `--author` suffix (`src/skillify/scope-config.ts:44`, `prd-005c.md:25-26`), so they are filtered out, while teammates' pulled skills (which the developer cannot meaningfully "promote") are listed. PRD-005c AC-1 requires "their locally mined skills ... drawn from the same skillify state the CLI reports (`skillsGenerated[]`)." Compounding this, the handler strips the `--author` suffix before calling `promote` (C1), so for a listed `foo--alice` it runs `skillify promote foo`, which looks for `/.claude/skills/foo/SKILL.md` and exits with "not found" (`src/commands/skillify.ts:126-128`). The pane therefore lists the wrong skills and cannot successfully promote the ones it lists.
          +
          +---
          +
          +## High Issues (should fix)
          +
          +### H1. Scope/share badge reflects install location, not team-share state (PRD-005c AC-2)
          +
          +The pane renders `s.scope`, which is hardcoded to `"global"` or `"project"` based on which `.claude/skills` directory the dir was found in:
          +
          +```307:312:harnesses/cursor/extension/src/webview/html/dashboard-shell.ts
          +      root.innerHTML = skills.map(s =>
          +        '
          ' + + '' + esc(s.label) + ' ' + esc(s.scope) + '' + +``` + +AC-2 requires a badge that distinguishes "local-only (`me` / project)" from "already shared with the team (`team` / global)", "derived from real scope/install state" (`me|team` from `scope-config`, plus org-table presence). The implementation never reads `scope-config` or the org table, so a `global` install is shown as `global` even when it is not shared with the team. The badge is misleading rather than truthful (`prd-005c.md:91-99`). + +### H2. Auto-pull opt-out is not honored on the poller and dashboard sync paths (PRD-005a AC-9, index AC-2) + +`runAutoSyncOnActivation` correctly short-circuits on `HIVEMIND_AUTOPULL_DISABLED=1`: + +```10:13:harnesses/cursor/extension/src/bridge/auto-sync.ts + if (process.env.HIVEMIND_AUTOPULL_DISABLED === "1") { + logSafe("Auto skill sync skipped (HIVEMIND_AUTOPULL_DISABLED=1)."); + return; + } +``` + +But the status-bar poller calls `syncSkillsToCursor` on every poll with no opt-out check, performing symlink writes: + +```41:46:harnesses/cursor/extension/src/statusbar/poller.ts + let skillSync; + try { + skillSync = syncSkillsToCursor(projectRoot); + } catch { + /* best-effort; never block the poll */ + } +``` + +The dashboard `syncSkills` action and `pushSettings` also call `syncSkillsToCursor`/`backfillCursorLinks` unconditionally (`DashboardPanel.ts:167-178,305`). PRD-005a AC-9 requires that with the opt-out set "neither the pull nor the Cursor sync runs." Here the sync still writes to Cursor's directories on every poll and on dashboard open. + +--- + +## Medium Issues + +### M1. Inline rule validation (2000-char cap, no-newlines) is not enforced before write (PRD-005b AC-5) + +The webview submits any non-empty trimmed text with no length or newline check: + +```187:190:harnesses/cursor/extension/src/webview/html/dashboard-shell.ts + document.getElementById("btn-rule-add").addEventListener("click", () => { + const text = document.getElementById("rule-text").value.trim(); + if (text) post("rulesAdd", { text }); + }); +``` + +The edit form (`dashboard-shell.ts:291-294`) is likewise unchecked. AC-5 requires the pane to "block the write and show an inline error matching the engine's contract" for `>2000` chars or any newline, *before* attempting the write (`prd-005b.md:95-113`). Today the engine raises and the error only surfaces as a CLI failure after the round-trip (and `` already strips newlines, so the newline contract is never even tested for the developer). + +### M2. Status filter (active/done/all) is not implemented (PRD-005b AC-6) + +`pushRules` is hardcoded to active and the `rulesList` message only re-pushes the same query; there is no UI control or message to switch status: + +```322:326:harnesses/cursor/extension/src/webview/DashboardPanel.ts + private async pushRules(): Promise { + const result = await runHivemindCli(["rules", "list", "--status", "active", "--limit", "25"], workspaceRoot()); + const rules = result.ok ? parseRulesList(result.stdout) : []; + this.post({ type: "rules", rules }); + } +``` + +AC-6 requires the pane to reflect active/done/all, mirroring the CLI `--status`. The rules pane markup (`dashboard-shell.ts:147-153`) has no filter control. Consequently AC-4's "appears under the done filter" assertion is also unverifiable in-UI. + +### M3. Rule list cap is 25, not the injection limit of 10 (PRD-005b AC-1) + +`pushRules` passes `--limit 25` (above). AC-1 requires the list be "capped consistently with the SessionStart injection limit," which is 10 (`src/rules/read.ts:36-38,83`; `prd-005b.md:162`). Showing 25 means the developer sees rules that are not actually being injected, contradicting "a developer sees what the agent actually sees." + +### M4. Conflicting path is never named and partial conflicts do not drive a non-green state (PRD-005a AC-5, AC-3 index) + +On a real (non-symlink) file at a Cursor target, the local `fanOutSymlinks` silently `continue`s without capturing the path: + +```53:54:harnesses/cursor/extension/src/bridge/skill-sync.ts + if (existing) { + if (!existing.isSymbolicLink()) continue; +``` + +`syncSkillsToCursor` then classifies a skill that reached *some but not all* roots as `"skipped"` with a generic reason, reserving `"errored"` only for total failure: + +```176:191:harnesses/cursor/extension/src/bridge/skill-sync.ts + if (links.length < roots.length) { + skipped++; + results.push({ skillName: dirName, status: "skipped", path: links[0], reason: `Synced to ${links.length}/${roots.length} Cursor roots` }); + } else { + synced++; + ... +``` + +The status bar only turns `degraded` when `erroredCount > 0` (`statusbar/indicator.ts:22`). So a real file blocking the project root while the global root succeeds is reported as `skipped`, leaving the bar green. PRD-005a AC-5 requires the affected skill be "reported as not reaching Cursor with the conflicting path named," and AC-3/AC-7 require a non-green state when skills cannot reach Cursor (`prd-005a.md:136-143,165-167`). + +### M5. Project-root Cursor links created by `syncSkillsToCursor` are not recorded in the manifest, so `unpull` cannot reverse them (PRD-005a AC-8) + +`syncSkillsToCursor` creates symlinks but never records them in `pulled.json` (only `backfillCursorLinks` calls `mergeSymlinks`). It also fans every *global* canonical skill into the *project* Cursor root: + +```140:166:harnesses/cursor/extension/src/bridge/skill-sync.ts +export function syncSkillsToCursor(projectRoot?: string): SkillSyncState { + const skillsRoot = canonicalSkillsRoot(); + const roots = detectCursorSkillsRoots(projectRoot); + ... + for (const dirName of dirs) { + const canonicalDir = join(skillsRoot, dirName); + const links = fanOutSymlinks(canonicalDir, dirName, roots); +``` + +Global links are recorded by the canonical CLI path (mechanism via `agent-roots.ts`), so those are reversible. But project-root links written here are not in any manifest entry, so `unpull` (manifest-driven, `src/skillify/manifest.ts:215-222`) will leave them dangling. AC-8 requires Cursor links be removed "through the same manifest-driven unlink pass as the Codex/Hermes/pi links." + +### M6. Parallel skill-sync writer duplicates the canonical machinery and leaves a dead parameter (cross-cutting "one engine, one source of truth") + +The index and PRD-005a mandate reuse of the existing fan-out/manifest "rather than a parallel writer" (`index.md:48`, `prd-005a.md:15,103`). The chosen approach split into two writers: (a) `src/skillify/agent-roots.ts` correctly adds Cursor so the CLI/SessionStart pull serves the global root; (b) `harnesses/cursor/extension/src/bridge/skill-sync.ts` reimplements `fanOutSymlinks`, manifest load/write, and backfill independently. Because no CLI caller passes `projectRoot` to `detectAgentSkillsRoots`, the new project branch is dead in production: + +```50:75:src/skillify/agent-roots.ts +function resolveDetected(home: string, projectRoot?: string): string[] { + ... + if (cursorInstalled) { + out.push(join(home, ".cursor", "skills-cursor")); + if (projectRoot) { + out.push(join(projectRoot, ".cursor", "skills")); + } + } +``` + +Callers `detectAgentSkillsRoots(root)` and `detectAgentSkillsRoots(installRoot)` (`src/skillify/pull.ts:286,584`) never pass `projectRoot`, so the project Cursor root is served only by the extension's duplicate `detectCursorSkillsRoots` (`skill-sync.ts:36-41`). Two detectors and two fan-out implementations now define Cursor's roots, which is the divergence the cross-cutting rule warns against. + +--- + +## Low Issues / Suggestions + +### L1. Rules list parsing depends on exact CLI stdout format + +`parseRulesList` regex-matches `[active] v ` (`DashboardPanel.ts:71-85`) against `formatListRow` output (`src/commands/rules.ts:133-143`). This works today but couples the pane to CLI presentation; a future format tweak silently empties the pane. PRD-005b envisioned reading "the same `listRules` reader" (`prd-005b.md:61`); calling the reader directly (as the extension already does for skills) would be more robust. + +### L2. Not-logged-in guidance is not surfaced as a dedicated pane state (PRD-005b AC-8, PRD-005c AC-7) + +Login failures bubble up only as raw CLI `stderr` in an `actionResult` toast. The PRDs ask the panes to "surface the same login guidance the CLI gives" rather than a generic failure (`prd-005b.md:151`, `prd-005c.md:139`). Empty states exist (`dashboard-shell.ts:264,304`) but a logged-out developer sees "No active rules" / "No local skills found", not a login prompt. + +### L3. Extension activation re-runs the full pull with a 5-minute timeout + +`runAutoSyncOnActivation` shells `skillify pull --all-users --to global` with `timeout: 300_000` (`data-bridge.ts:200`), duplicating the SessionStart auto-pull. It is fire-and-forget (`void`, `extension.ts:46`) so activation is not blocked, but the budget does not match the SessionStart `DEFAULT_TIMEOUT_MS` the PRD references (`prd-005a.md:132`). Idempotent, so harmless, but redundant. + +--- + +## Plan Item Traceability + +### Module-level acceptance criteria (index) + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Pulled skills present in Cursor's active dirs, no manual action | PARTIAL | Global root served via `agent-roots.ts:69-73` + SessionStart pull; project root only via extension parallel writer. | +| AC-2 | Bridge rides SessionStart auto-pull, bounded, swallow-all | PARTIAL | CLI path inherits the contract; extension activation pull uses a 5-min timeout (L3); opt-out not honored on poll/dashboard (H2). | +| AC-3 | Unsynced skills reported; status bar non-green | PARTIAL | Status bar wired (`indicator.ts:22,51-52`); but partial conflicts are `skipped` and stay green, path not named (M4). | +| AC-4 | Rules render from `listRules`, newest-first, capped at injection limit | PARTIAL | Renders newest-first via CLI; capped at 25 not 10 (M3); via stdout regex not direct reader (L1). | +| AC-5 | Add/edit/complete via engine, no CLI | MET | `rulesAdd/rulesEdit/rulesDone` → `rules add/edit/done` (`DashboardPanel.ts:183-215`). | +| AC-6 | Locally mined skills shown; shared vs local distinguished | FAIL | Lists pulled (`--author`) skills not mined skills (C2); badge shows install loc not share state (H1). | +| AC-7 | One-click promote shares with team; pane reflects new state | FAIL | Org publish never happens; false "promoted to team" message (C1). | +| AC-8 | Fresh install renders coherent empty states | MET | Empty states for rules/skills/sessions (`dashboard-shell.ts:264,304,249`). | +| AC-9 | No token/API key in payloads or logs | MET | Payloads carry names/text/scope only; no secret serialized (DashboardPanel/data-bridge reviewed). | + +### PRD-005a: Skillify Path Bridge + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Global pull → `~/.cursor/skills-cursor/--/` | MET | `agent-roots.ts:69-70`; `pull.ts:584` fans out for global installs. | +| AC-2 | Project pull → `/.cursor/skills/`; never global | PARTIAL | CLI project installs fan out `[]` (`pull.ts:583-585`); only extension `backfillCursorLinks` handles project links via manifest. | +| AC-3 | Rides SessionStart auto-pull, bounded, swallow-all | MET | Shared `detectAgentSkillsRoots` flows through `auto-pull.ts → runPull`; extension path try/catch-wrapped. | +| AC-4 | Backfill links for pre-existing pulls without version bump | MET | `backfillSymlinks` (`pull.ts:282-307`) + extension `backfillCursorLinks` (`skill-sync.ts:204-223`). | +| AC-5 | Real file at target: not overwritten, reported with path named | FAIL | Silent `continue` (`skill-sync.ts:54`); path never captured; classified `skipped` (M4). | +| AC-6 | Idempotent when link already correct | MET | `readlinkSync === canonicalDir` short-circuit (`skill-sync.ts:61-64`); CLI `sameSorted` (`pull.ts:291`). | +| AC-7 | Status bar non-green when skills cannot reach Cursor | PARTIAL | Triggers only on total `erroredCount>0`; partial conflicts stay green (M4). | +| AC-8 | `unpull` removes Cursor links via manifest | PARTIAL | Global links recorded; project links from `syncSkillsToCursor` not recorded (M5). | +| AC-9 | `HIVEMIND_AUTOPULL_DISABLED=1` disables pull and sync | FAIL | Honored in `auto-sync.ts:10`; ignored by poller and dashboard sync (H2). | +| AC-10 | No token/API key in manifest, result, or log | MET | Manifest/result carry paths and names only. | + +### PRD-005b: Team Rules Manager + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Active rules newest-first, capped at injection limit | PARTIAL | Newest-first preserved; limit 25 not 10 (M3). | +| AC-2 | Add → `insertRule`, `assigned_by` = user, `team` scope | MET | `rules add --scope team` (`DashboardPanel.ts:185`); CLI accepts `--scope team` (`rules.ts:75-85,173`). | +| AC-3 | Edit appends version, preserves `rule_id`, shows bumped version | PARTIAL | `rules edit` wired (`DashboardPanel.ts:207`); version not re-rendered until refresh and bumped version not shown distinctly. | +| AC-4 | Complete appends `done` row; leaves active list | PARTIAL | `rules done` wired (`DashboardPanel.ts:196`); cannot verify "appears under done filter" (no filter, M2). | +| AC-5 | Block >2000 chars / newline inline before write | FAIL | No client validation (M1). | +| AC-6 | Status filter active/done/all | FAIL | Hardcoded active; no filter control (M2). | +| AC-7 | Empty / no-rules-table renders coherent empty state | MET | "No active rules." (`dashboard-shell.ts:264`); CLI tolerates missing table (`rules.ts:194-201`). | +| AC-8 | Not-logged-in surfaces login guidance | PARTIAL | CLI stderr bubbled; no dedicated login state in pane (L2). | +| AC-9 | No token/API key; text + author only | MET | Parsed rule carries status/id/version/author/text only. | + +### PRD-005c: Interactive Skill Promoter + +| AC | Requirement | Status | Evidence / Gap | +|---|---|---|---| +| AC-1 | Mined skills as named rows from skillify state | FAIL | Lists pulled `--author` dirs, not mined skills (C2). | +| AC-2 | Badge: local-only (`me`/project) vs shared (`team`/global) | FAIL | Badge shows install location, never reads `me\|team` scope or org table (H1). | +| AC-3 | Promote moves project→global AND shares at `team` on org table | FAIL | `--scope team` ignored; only disk move runs; no org publish (C1). | +| AC-4 | Global name collision surfaced, not overwritten | PARTIAL | CLI refuses + prints stderr (`skillify.ts:130-133`), surfaced as toast; but most listed skills error "not found" first (C2). | +| AC-5 | Reflect true resulting state; never overstate reach | FAIL | Always reports "promoted to team" on ok (C1). | +| AC-6 | Shared skill flows back via PRD-005a; pane communicates loop | FAIL | No team-share occurs (C1), so loop never closes; pane does not communicate it. | +| AC-7 | Not-logged-in offers login path | PARTIAL | CLI stderr only; no dedicated login state (L2). | +| AC-8 | No mined skills renders coherent empty state | MET | "No local skills found." (`dashboard-shell.ts:304`). | +| AC-9 | No token/API key in payload or logs | MET | Skill rows carry dirName/label/scope/path only. | + +--- + +## Files Changed (PRD-005 scope) + +| File | Summary | +|---|---| +| `src/skillify/agent-roots.ts` | Adds Cursor global root (and a `projectRoot` param, unused by CLI callers) to `detectAgentSkillsRoots`; reverses the documented Cursor exclusion. Core of PRD-005a. | +| `harnesses/cursor/extension/src/bridge/skill-sync.ts` | New extension-side Cursor sync: parallel `fanOutSymlinks`, manifest load/write, `syncSkillsToCursor`, `backfillCursorLinks`, `listLocalSkillsForPromoter`. Duplicates canonical machinery (M6). | +| `harnesses/cursor/extension/src/bridge/auto-sync.ts` | Runs `skillify pull` + Cursor sync on activation; honors opt-out here only (H2). | +| `harnesses/cursor/extension/src/webview/DashboardPanel.ts` | Adds rules and skills panes, message handlers for add/edit/done rules and promote skill; promote sends no-op `--scope team` (C1) and strips `--author` (C2). | +| `harnesses/cursor/extension/src/webview/html/dashboard-shell.ts` | Rules + Skills pane markup and render functions; empty states present; no validation/filter/login-state/share-badge. | +| `harnesses/cursor/extension/src/webview/data-bridge.ts` | `runHivemindCli` (5-min timeout) and dashboard data loaders. | +| `harnesses/cursor/extension/src/statusbar/{indicator,poller}.ts` | Wires `SkillSyncState` into bar state + tooltip; poller syncs on every poll without opt-out (H2, M4). | +| `harnesses/cursor/extension/src/types/health.ts` | Adds `SkillSyncResult` / `SkillSyncState` types and `StatusSnapshot.skillSync`. | +| `tests/claude-code/skillify-agent-roots.test.ts` | Covers Cursor global + project root detection in `detectAgentSkillsRoots` (the project branch tested but never exercised by production callers, M6). | + +--- + +## Process Note (ordering) + +quality-guardian runs after security-guardian. Security pass recorded at `library/qa/cursor-extension/2026-06-13-security-audit.md` (PASS). + +--- + +## Remediation (2026-06-13) + +- **C1:** CLI `promoteSkill` publishes to org `skills` table with `--scope team`; dashboard reports success only on CLI exit 0. +- **C2:** Promoter lists mined skills via `listMinedSkillNames`; promote uses skill name without `--author` strip. +- **H1:** Share badge from SKILL.md `scope:` frontmatter, not install path. +- **H2:** `HIVEMIND_AUTOPULL_DISABLED` honored in poller, dashboard sync, and auto-sync. +- **M1-M3:** Inline rule validation, status filter, limit 10 rules. +- **M4:** Partial reach classified as `errored` with conflict path; dashboard sync summary reflects failures. +- **M5:** Project/global symlinks recorded in `pulled.json` manifest for unpull reversibility. +- **M6:** Extension mirrors canonical `agent-roots` detection inline (webpack rootDir constraint); CLI `pull.ts` passes `projectRoot` to shared detector. +- **L1-L2:** Rules via `load-rules.mjs` / `listRules`; logged-out panes for rules and skills. + +**Updated verdict:** COMPLETE diff --git a/package.json b/package.json index 1d18f3dc..2b102827 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "bundle", "harnesses/codex/bundle", "harnesses/codex/skills", - "cursor/bundle", + "harnesses/cursor/bundle", "harnesses/hermes/bundle", "mcp/bundle", "harnesses/pi/extension-source", diff --git a/src/cli/install-cursor.ts b/src/cli/install-cursor.ts index 30ce82f1..c99b2514 100644 --- a/src/cli/install-cursor.ts +++ b/src/cli/install-cursor.ts @@ -99,7 +99,7 @@ export function stripHooksFromConfig(existing: Record | null): } export function installCursor(): void { - const srcBundle = join(pkgRoot(), "cursor", "bundle"); + const srcBundle = join(pkgRoot(), "harnesses", "cursor", "bundle"); if (!existsSync(srcBundle)) { throw new Error(`Cursor bundle missing at ${srcBundle}. Run 'npm run build' first.`); } diff --git a/src/commands/skillify.ts b/src/commands/skillify.ts index 86002ff6..316945bb 100644 --- a/src/commands/skillify.ts +++ b/src/commands/skillify.ts @@ -26,12 +26,16 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { loadScopeConfig, saveScopeConfig, type Scope, type InstallLocation } from "../skillify/scope-config.js"; import { getStateDir } from "../skillify/state-dir.js"; +import { deriveProjectKey } from "../skillify/state.js"; import { runPull, type PullSummary } from "../skillify/pull.js"; import { runUnpull } from "../skillify/unpull.js"; import { loadConfig } from "../config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { runMineLocal } from "./mine-local.js"; import { renderSubcommandUsageBlock } from "../cli/skillify-spec.js"; +import { parseFrontmatter } from "../skillify/skill-writer.js"; +import { readCurrentSkillRow } from "../skillify/skill-org-publish.js"; +import { insertSkillRow } from "../skillify/skills-table.js"; // Route through the shared `getStateDir()` so `HIVEMIND_STATE_DIR` // redirects (tests, alternate installs) land in the same dir as the @@ -119,8 +123,7 @@ function setInstall(loc: string): void { console.log(`Install location set to '${loc}'. New skills will be written to ${path}//SKILL.md.`); } -function promoteSkill(name: string, cwd: string): void { - if (!name) { console.error("Usage: hivemind skillify promote "); process.exit(1); } +function moveProjectSkillToGlobal(name: string, cwd: string): { projectPath: string; globalPath: string } { const projectPath = join(cwd, ".claude", "skills", name); const globalPath = join(homedir(), ".claude", "skills", name); if (!existsSync(join(projectPath, "SKILL.md"))) { @@ -133,7 +136,92 @@ function promoteSkill(name: string, cwd: string): void { } mkdirSync(dirname(globalPath), { recursive: true }); renameSync(projectPath, globalPath); + return { projectPath, globalPath }; +} + +async function publishSkillToOrgTable(name: string, cwd: string, globalPath: string): Promise { + const config = loadConfig(); + if (!config) { + console.error("Not logged in. Run: hivemind login"); + process.exit(1); + } + + const skillMd = readFileSync(join(globalPath, "SKILL.md"), "utf-8"); + const parsed = parseFrontmatter(skillMd); + if (!parsed) { + console.error(`Skill '${name}' has no valid SKILL.md frontmatter — cannot publish to org table.`); + process.exit(1); + } + + const author = (typeof parsed.fm.author === "string" && parsed.fm.author.trim()) + ? parsed.fm.author.trim() + : config.userName; + if (!author) { + console.error("Cannot determine skill author. Set frontmatter author or log in with a username."); + process.exit(1); + } + + const description = typeof parsed.fm.description === "string" ? parsed.fm.description : ""; + const trigger = typeof parsed.fm.trigger === "string" ? parsed.fm.trigger : ""; + const body = parsed.body.trim(); + const { key: projectKey, project } = deriveProjectKey(cwd); + const sourceSessions = Array.isArray(parsed.fm.source_sessions) + ? parsed.fm.source_sessions.map(String) + : []; + const sourceAgent = typeof parsed.fm.created_by_agent === "string" + ? parsed.fm.created_by_agent + : "cursor"; + + const api = new DeeplakeApi( + config.token, config.apiUrl, config.orgId, config.workspaceId, config.skillsTableName, + ); + const query = (sql: string) => api.query(sql) as Promise[]>; + + const current = await readCurrentSkillRow(query, config.skillsTableName, name, author); + const version = current ? current.version + 1 : 1; + const now = new Date().toISOString(); + + await insertSkillRow({ + query, + tableName: config.skillsTableName, + workspaceId: config.workspaceId, + name, + author, + project, + projectKey, + localPath: join(globalPath, "SKILL.md"), + install: "global", + sourceSessions: current?.sourceSessions.length ? current.sourceSessions : sourceSessions, + sourceAgent: current?.sourceAgent || sourceAgent, + scope: "team", + contributors: current?.contributors.length + ? current.contributors + : [author], + description: current?.description || description, + trigger: current?.trigger || trigger, + body, + version, + createdAt: now, + updatedAt: now, + }); + + return version; +} + +async function promoteSkill(args: string[], cwd: string): Promise { + const work = [...args]; + const scopeRaw = takeFlagValue(work, "--scope"); + const shareTeam = scopeRaw === "team"; + const name = work[0] ?? ""; + if (!name) { console.error("Usage: hivemind skillify promote [--scope team]"); process.exit(1); } + + const { projectPath, globalPath } = moveProjectSkillToGlobal(name, cwd); console.log(`Promoted '${name}' from ${projectPath} → ${globalPath}.`); + + if (shareTeam) { + const version = await publishSkillToOrgTable(name, cwd, globalPath); + console.log(`Published '${name}' to org skills table at team scope (v${version}). Teammates will pull it on next auto-pull.`); + } } function teamAdd(name: string): void { @@ -346,7 +434,22 @@ export function runSkillifyCommand(args: string[]): void { if (!sub || sub === "status") { showStatus(); return; } if (sub === "scope") { setScope(args[1] ?? ""); return; } if (sub === "install") { setInstall(args[1] ?? ""); return; } - if (sub === "promote") { promoteSkill(args[1] ?? "", process.cwd()); return; } + if (sub === "promote") { + const promoteArgs = args.slice(1); + const scopeIdx = promoteArgs.indexOf("--scope"); + const nameArg = promoteArgs.find((a, i) => !a.startsWith("--") && !(i > 0 && promoteArgs[i - 1] === "--scope")); + if (!nameArg) { + console.error("Usage: hivemind skillify promote [--scope team]"); + process.exit(1); + } + promoteSkill(promoteArgs, process.cwd()) + .catch(e => { + console.error(`promote error: ${e?.message ?? e}`); + process.exit(1); + }) + .catch(() => { /* test-only safety net when process.exit is mocked */ }); + return; + } if (sub === "pull") { pullSkills(args.slice(1)).catch(e => { console.error(`pull error: ${e?.message ?? e}`); diff --git a/src/dashboard/data.ts b/src/dashboard/data.ts index 81f07e64..c5b3106d 100644 --- a/src/dashboard/data.ts +++ b/src/dashboard/data.ts @@ -40,7 +40,10 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { loadCredentials, type Credentials } from "../commands/auth-creds.js"; -import { fetchOrgStats, type OrgStats } from "../notifications/sources/org-stats.js"; +import { + fetchOrgStatsWithMeta, + type OrgStatsFetchMeta, +} from "../notifications/sources/org-stats.js"; import { countUserGeneratedSkills, readUsageRecords, @@ -78,6 +81,13 @@ export interface DashboardKpis { * tokensSaved (local stats ARE this user's contribution). null when * source is "none". */ userTokensSaved: number | null; + /** ISO timestamp of the org-stats cache or last successful fetch. null + * when tokensSource is not "org". */ + orgStatsFetchedAt: string | null; + /** True when org stats came from cache past the 1-hour TTL. */ + orgStatsStale: boolean; + /** True when a live fetch failed and stale cache was used. */ + orgStatsOffline: boolean; } export interface DashboardGraphSummary { @@ -216,16 +226,22 @@ async function loadKpis(creds: Credentials | null): Promise { const localBytes = sumMetric(records, "memorySearchBytes"); const localCount = sumMetric(records, "memorySearchCount"); - let orgStats: OrgStats | null = null; + const emptyOrgMeta: OrgStatsFetchMeta = { + fetchedAt: null, + stale: false, + offline: false, + fromCache: false, + }; + let orgFetchMeta = emptyOrgMeta; + let orgStats = null as Awaited>["stats"]; + if (creds?.token) { try { - orgStats = await fetchOrgStats(creds); + const result = await fetchOrgStatsWithMeta(creds); + orgStats = result.stats; + orgFetchMeta = result.meta; } catch (e: any) { - // fetchOrgStats already swallows network/parse failures and returns - // null, but a future regression that throws shouldn't take the - // dashboard down — surface the failure as "no org data" and let - // the local fallback do its job. - log(`fetchOrgStats threw: ${e?.message ?? String(e)}`); + log(`fetchOrgStatsWithMeta threw: ${e?.message ?? String(e)}`); } } @@ -237,6 +253,9 @@ async function loadKpis(creds: Credentials | null): Promise { memorySearches: orgStats.org.memoryRecallCount, sessionsCount: orgStats.org.sessionsCount, userTokensSaved: bytesToSavedTokens(orgStats.user.memorySearchBytes), + orgStatsFetchedAt: orgFetchMeta.fetchedAt, + orgStatsStale: orgFetchMeta.stale, + orgStatsOffline: orgFetchMeta.offline, }; } @@ -248,6 +267,9 @@ async function loadKpis(creds: Credentials | null): Promise { memorySearches: localCount, sessionsCount: records.length, userTokensSaved: bytesToSavedTokens(localBytes), + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, }; } @@ -258,6 +280,9 @@ async function loadKpis(creds: Credentials | null): Promise { memorySearches: 0, sessionsCount: null, userTokensSaved: null, + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, }; } diff --git a/src/notifications/sources/org-stats.ts b/src/notifications/sources/org-stats.ts index 00e73d76..805c6a7e 100644 --- a/src/notifications/sources/org-stats.ts +++ b/src/notifications/sources/org-stats.ts @@ -56,6 +56,32 @@ export interface OrgStats { balanceCents: number | null; } +/** Metadata about how org stats were resolved — used by the dashboard to + * show freshness/offline labels without duplicating cache logic. */ +export interface OrgStatsFetchMeta { + /** ISO timestamp when the stats were last fetched from the server, or + * when the cache entry was written. null when no org stats at all. */ + fetchedAt: string | null; + /** True when the returned stats came from cache past the 1-hour TTL. */ + stale: boolean; + /** True when a live fetch failed and stale cache was used as fallback. */ + offline: boolean; + /** True when stats were served from cache without a successful refetch. */ + fromCache: boolean; +} + +export interface OrgStatsFetchResult { + stats: OrgStats | null; + meta: OrgStatsFetchMeta; +} + +const EMPTY_META: OrgStatsFetchMeta = { + fetchedAt: null, + stale: false, + offline: false, + fromCache: false, +}; + /** Response header carrying the org's current prepaid balance, in cents. */ const BALANCE_HEADER = "X-Activeloop-Balance-Cents"; @@ -111,7 +137,11 @@ function scopeFromServer(s: ServerScope | undefined): OrgStatsScope { }; } -function readCache(scopeKey: string): { fresh?: OrgStats; stale?: OrgStats } { +function readCache(scopeKey: string): { + fresh?: OrgStats; + stale?: OrgStats; + fetchedAt?: number; +} { if (!existsSync(cacheFilePath())) return {}; try { const parsed = JSON.parse(readFileSync(cacheFilePath(), "utf-8")) as CacheFileShape; @@ -121,17 +151,27 @@ function readCache(scopeKey: string): { fresh?: OrgStats; stale?: OrgStats } { const age = Date.now() - parsed.fetchedAt; const data = parsed.data; if (!data || typeof data !== "object" || !data.org || !data.user) return {}; - if (age >= 0 && age < CACHE_TTL_MS) return { fresh: data }; + const fetchedAt = parsed.fetchedAt; + if (age >= 0 && age < CACHE_TTL_MS) return { fresh: data, fetchedAt }; // Stale but possibly useful as a fallback if the fetch fails. We don't // return it as "fresh" since the user has paid for newer data via a // SessionStart-triggered fetch — only return it after the fetch error. - return { stale: data }; + return { stale: data, fetchedAt }; } catch (e: any) { log(`cache read failed: ${e?.message ?? String(e)}`); return {}; } } +function metaFromCache(fetchedAtMs: number, stale: boolean, offline: boolean): OrgStatsFetchMeta { + return { + fetchedAt: new Date(fetchedAtMs).toISOString(), + stale, + offline, + fromCache: true, + }; +} + function writeCache(scopeKey: string, data: OrgStats): void { try { // mkdir parent: ~/.deeplake/ may not exist yet on fresh-install @@ -160,14 +200,30 @@ function writeCache(scopeKey: string, data: OrgStats): void { * 4. On failure: return stale cache if any, else null */ export async function fetchOrgStats(creds: Credentials | null): Promise { - if (!creds?.token) return null; + const result = await fetchOrgStatsWithMeta(creds); + return result.stats; +} + +/** + * Like fetchOrgStats but surfaces cache freshness metadata for dashboards. + * Never throws. + */ +export async function fetchOrgStatsWithMeta( + creds: Credentials | null, +): Promise { + if (!creds?.token) { + return { stats: null, meta: EMPTY_META }; + } const apiUrl = creds.apiUrl ?? DEFAULT_API_URL; const scopeKey = cacheScopeKey(creds); - const { fresh, stale } = readCache(scopeKey); + const { fresh, stale, fetchedAt: cacheFetchedAt } = readCache(scopeKey); if (fresh) { log("cache hit — returning fresh org stats"); - return fresh; + return { + stats: fresh, + meta: metaFromCache(cacheFetchedAt ?? Date.now(), false, false), + }; } const url = `${apiUrl}/me/hivemind-stats`; @@ -184,12 +240,24 @@ export async function fetchOrgStats(creds: Credentials | null): Promise { // /.claude/skills and shouldn't leak into user-global agent // dirs — that would defeat the project-scoping intent. const symlinks = opts.install === "global" - ? fanOutSymlinks(skillDir, dirName, detectAgentSkillsRoots(root)) + ? fanOutSymlinks(skillDir, dirName, detectAgentSkillsRoots(root, homedir(), undefined)) : []; // Record in manifest so `unpull` can identify this entry as // pull-managed without relying on the `--` dirname heuristic diff --git a/tests/claude-code/dashboard-data.test.ts b/tests/claude-code/dashboard-data.test.ts index 51cfd75f..b74e50ad 100644 --- a/tests/claude-code/dashboard-data.test.ts +++ b/tests/claude-code/dashboard-data.test.ts @@ -12,7 +12,11 @@ import { join } from "node:path"; const { orgStatsMock } = vi.hoisted(() => ({ orgStatsMock: vi.fn() })); vi.mock("../../src/notifications/sources/org-stats.js", () => ({ - fetchOrgStats: orgStatsMock, + fetchOrgStats: vi.fn(async (creds: unknown) => { + const r = await orgStatsMock(creds); + return r?.stats ?? r ?? null; + }), + fetchOrgStatsWithMeta: orgStatsMock, })); import { loadDashboardData } from "../../src/dashboard/data.js"; @@ -139,8 +143,16 @@ describe("loadDashboardData", () => { it("uses org stats when fetchOrgStats returns data", async () => { orgStatsMock.mockResolvedValue({ - org: { sessionsCount: 5, memoryRecallCount: 100, memorySearchBytes: 40_000 }, - user: { sessionsCount: 2, memoryRecallCount: 50, memorySearchBytes: 20_000 }, + stats: { + org: { sessionsCount: 5, memoryRecallCount: 100, memorySearchBytes: 40_000 }, + user: { sessionsCount: 2, memoryRecallCount: 50, memorySearchBytes: 20_000 }, + }, + meta: { + fetchedAt: "2026-06-13T12:00:00.000Z", + stale: false, + offline: false, + fromCache: false, + }, }); const result = await loadDashboardData({ cwd: "/tmp", @@ -148,6 +160,9 @@ describe("loadDashboardData", () => { creds: { token: "t", orgId: "o", userName: "user", savedAt: "2026-01-01T00:00:00Z" }, }); expect(result.kpis.tokensSource).toBe("org"); + expect(result.kpis.orgStatsFetchedAt).toBe("2026-06-13T12:00:00.000Z"); + expect(result.kpis.orgStatsStale).toBe(false); + expect(result.kpis.orgStatsOffline).toBe(false); // 40000 / 4 = 10000 delivered; 0.7 * 10000 = 7000 saved expect(result.kpis.tokensSaved).toBe(7000); expect(result.kpis.memorySearches).toBe(100); @@ -157,7 +172,7 @@ describe("loadDashboardData", () => { }); it("falls back to local stats when fetchOrgStats returns null", async () => { - orgStatsMock.mockResolvedValue(null); + orgStatsMock.mockResolvedValue({ stats: null, meta: { fetchedAt: null, stale: false, offline: false, fromCache: false } }); mkdirSync(join(homeDir, ".deeplake"), { recursive: true }); const records = [ { endedAt: "2026-01-01T00:00:00Z", sessionId: "a", memorySearchBytes: 8_000, memorySearchCount: 4 }, diff --git a/tests/claude-code/dashboard-render.test.ts b/tests/claude-code/dashboard-render.test.ts index badf92d3..5ce0119a 100644 --- a/tests/claude-code/dashboard-render.test.ts +++ b/tests/claude-code/dashboard-render.test.ts @@ -29,6 +29,9 @@ function baseData(overrides: Partial = {}): DashboardData { memorySearches: 42, sessionsCount: 5, userTokensSaved: 3500, + orgStatsFetchedAt: "2026-05-21T00:00:00Z", + orgStatsStale: false, + orgStatsOffline: false, }, graph: { commitSha: "abc123def456789", @@ -242,6 +245,9 @@ describe("renderDashboardHtml", () => { tokensSaved: null, tokensSource: "none", skillsCreated: 0, memorySearches: 0, sessionsCount: null, userTokensSaved: null, + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, }, })); expect(html).toContain("—"); @@ -259,6 +265,9 @@ describe("renderDashboardHtml", () => { memorySearches: 10, sessionsCount: 3, userTokensSaved: 5000, + orgStatsFetchedAt: null, + orgStatsStale: false, + orgStatsOffline: false, }, })); expect(html).toContain("~5.0k"); diff --git a/tests/claude-code/skillify-cli.test.ts b/tests/claude-code/skillify-cli.test.ts index 0ef92b0e..a7a4a0db 100644 --- a/tests/claude-code/skillify-cli.test.ts +++ b/tests/claude-code/skillify-cli.test.ts @@ -76,6 +76,16 @@ function expectExit(code: number, fn: () => void): void { expect(fn).toThrow(new RegExp(`__EXIT_${code}__`)); } +async function expectExitAsync(code: number, fn: () => void): Promise { + expect(fn).not.toThrow(); + await new Promise((r) => setImmediate(r)); + expect(erred.join("\n") || logged.join("\n")).toBeTruthy(); + // process.exit is mocked to throw; async promote surfaces it on the next tick + if (!exitSpy.mock.calls.some((c: [number?]) => c[0] === code)) { + throw new Error(`Expected process.exit(${code}) but was not called`); + } +} + // ── status (default) ────────────────────────────────────────────────────── describe("status (default subcommand)", () => { @@ -213,13 +223,35 @@ describe("promote", () => { expectExit(1, () => runSkillifyCommand(["promote"])); }); - it("errors when project skill is missing", () => { + it("errors when project skill is missing", async () => { const dir = mkdtempSync(join(tmpdir(), "skillify-cli-")); process.chdir(dir); - expectExit(1, () => runSkillifyCommand(["promote", "nonexistent-skill"])); + runSkillifyCommand(["promote", "nonexistent-skill"]); + await new Promise((r) => setImmediate(r)); + expect(exitSpy).toHaveBeenCalledWith(1); expect(erred.join("\n")).toMatch(/not found/); rmSync(dir, { recursive: true, force: true }); }); + + it("moves project skill to global on disk", async () => { + const dir = mkdtempSync(join(tmpdir(), "skillify-cli-promote-")); + const globalSkills = mkdtempSync(join(tmpdir(), "skillify-cli-global-")); + const originalHome = process.env.HOME; + process.env.HOME = globalSkills; + process.chdir(dir); + const skillDir = join(dir, ".claude", "skills", "my-skill"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "---\nname: my-skill\ndescription: d\nauthor: alice\n---\n\nbody\n"); + runSkillifyCommand(["promote", "my-skill"]); + await new Promise((r) => setImmediate(r)); + expect(existsSync(join(globalSkills, ".claude", "skills", "my-skill", "SKILL.md"))).toBe(true); + expect(existsSync(join(skillDir, "SKILL.md"))).toBe(false); + expect(logged.join("\n")).toMatch(/Promoted 'my-skill'/); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(dir, { recursive: true, force: true }); + rmSync(globalSkills, { recursive: true, force: true }); + }); }); // ── pull ────────────────────────────────────────────────────────────────── diff --git a/tests/claude-code/skillify-promote-team.test.ts b/tests/claude-code/skillify-promote-team.test.ts new file mode 100644 index 00000000..bb490998 --- /dev/null +++ b/tests/claude-code/skillify-promote-team.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { insertSkillRowMock, readCurrentSkillRowMock } = vi.hoisted(() => ({ + insertSkillRowMock: vi.fn().mockResolvedValue(undefined), + readCurrentSkillRowMock: vi.fn().mockResolvedValue(null), +})); + +vi.mock("../../src/config.js", () => ({ + loadConfig: vi.fn(() => ({ + token: "tok", + apiUrl: "https://api.example.com", + orgId: "org", + workspaceId: "ws", + userName: "tester", + skillsTableName: "skills", + })), +})); +vi.mock("../../src/deeplake-api.js", () => ({ + DeeplakeApi: class { + async query(_sql: string) { + return []; + } + }, +})); +vi.mock("../../src/skillify/skills-table.js", () => ({ + insertSkillRow: insertSkillRowMock, +})); +vi.mock("../../src/skillify/skill-org-publish.js", () => ({ + readCurrentSkillRow: readCurrentSkillRowMock, +})); + +import { runSkillifyCommand } from "../../src/commands/skillify.js"; + +describe("promote --scope team", () => { + let dir: string; + let homeDir: string; + let originalHome: string | undefined; + let logged: string[]; + + beforeEach(() => { + insertSkillRowMock.mockClear(); + readCurrentSkillRowMock.mockClear(); + logged = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logged.push(args.map(String).join(" ")); + }); + dir = mkdtempSync(join(tmpdir(), "promote-team-cwd-")); + homeDir = mkdtempSync(join(tmpdir(), "promote-team-home-")); + originalHome = process.env.HOME; + process.env.HOME = homeDir; + process.chdir(dir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(dir, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("publishes to org table with team scope after disk move", async () => { + const skillDir = join(dir, ".claude", "skills", "share-me"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: share-me\ndescription: d\nauthor: tester\n---\n\nbody text\n", + ); + + runSkillifyCommand(["promote", "share-me", "--scope", "team"]); + await new Promise((r) => setImmediate(r)); + + expect(existsSync(join(homeDir, ".claude", "skills", "share-me", "SKILL.md"))).toBe(true); + expect(insertSkillRowMock).toHaveBeenCalledTimes(1); + expect(insertSkillRowMock.mock.calls[0][0].scope).toBe("team"); + expect(insertSkillRowMock.mock.calls[0][0].name).toBe("share-me"); + expect(logged.join("\n")).toMatch(/Published 'share-me' to org skills table at team scope \(v1\)/); + }); + + it("bumps version when skill already exists in org table", async () => { + readCurrentSkillRowMock.mockResolvedValue({ + name: "share-me", + author: "tester", + project: "p", + projectKey: "pk", + localPath: "/old", + install: "project", + sourceSessions: [], + sourceAgent: "claude_code", + scope: "me", + contributors: ["tester"], + description: "d", + trigger: "", + body: "old body", + version: 2, + }); + const skillDir = join(dir, ".claude", "skills", "share-me"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + "---\nname: share-me\ndescription: d\nauthor: tester\n---\n\nnew body\n", + ); + + runSkillifyCommand(["promote", "share-me", "--scope", "team"]); + await new Promise((r) => setImmediate(r)); + + expect(insertSkillRowMock.mock.calls[0][0].version).toBe(3); + expect(readFileSync(join(homeDir, ".claude", "skills", "share-me", "SKILL.md"), "utf-8")).toContain("new body"); + }); +}); diff --git a/tests/cli/cli-install-cursor-fs.test.ts b/tests/cli/cli-install-cursor-fs.test.ts index addfafa7..cddf96db 100644 --- a/tests/cli/cli-install-cursor-fs.test.ts +++ b/tests/cli/cli-install-cursor-fs.test.ts @@ -21,11 +21,12 @@ beforeEach(() => { tmpHome = join(tmpRoot, "home"); tmpPkg = join(tmpRoot, "pkg"); mkdirSync(join(tmpHome, ".cursor"), { recursive: true }); - mkdirSync(join(tmpPkg, "cursor", "bundle"), { recursive: true }); - writeFileSync(join(tmpPkg, "cursor", "bundle", "session-start.js"), "// fake bundle"); - writeFileSync(join(tmpPkg, "cursor", "bundle", "capture.js"), "// fake bundle"); - writeFileSync(join(tmpPkg, "cursor", "bundle", "pre-tool-use.js"), "// fake bundle"); - writeFileSync(join(tmpPkg, "cursor", "bundle", "session-end.js"), "// fake bundle"); + mkdirSync(join(tmpPkg, "harnesses", "cursor", "bundle"), { recursive: true }); + writeFileSync(join(tmpPkg, "harnesses", "cursor", "bundle", "session-start.js"), "// fake bundle"); + writeFileSync(join(tmpPkg, "harnesses", "cursor", "bundle", "capture.js"), "// fake bundle"); + writeFileSync(join(tmpPkg, "harnesses", "cursor", "bundle", "pre-tool-use.js"), "// fake bundle"); + writeFileSync(join(tmpPkg, "harnesses", "cursor", "bundle", "session-end.js"), "// fake bundle"); + writeFileSync(join(tmpPkg, "harnesses", "cursor", "bundle", "graph-on-stop.js"), "// fake bundle"); writeFileSync(join(tmpPkg, "package.json"), JSON.stringify({ version: "9.9.9" })); vi.stubEnv("HOME", tmpHome); @@ -112,7 +113,7 @@ describe("installCursor", () => { }); it("throws when the bundle source is missing (build hasn't run)", async () => { - rmSync(join(tmpPkg, "cursor", "bundle"), { recursive: true, force: true }); + rmSync(join(tmpPkg, "harnesses", "cursor", "bundle"), { recursive: true, force: true }); const { installCursor } = await importInstaller(); expect(() => installCursor()).toThrow(/Cursor bundle missing/); });